15 Commits
Author SHA1 Message Date
reya d1b83fdc33 add chat plane 2026-09-16 20:46:22 +07:00
reya 4329385abe add control fold and roster 2026-09-16 20:05:51 +07:00
reya d926c1e3ea update concord backend 2026-09-16 17:35:33 +07:00
reya 66f75ad105 add basic concord backend 2026-09-16 16:57:04 +07:00
reya 5f2a5d7a37 add concord crate and basic cryptography 2026-09-16 16:21:38 +07:00
reya 319b1038d2 add plan 2026-09-16 11:03:33 +07:00
reya 5500082426 chore: update metadata 2026-09-16 10:23:53 +07:00
reya 7a34d66c7c fix: release endpoint 2026-09-16 10:19:53 +07:00
reya 57c85f5b99 fix: auto updater (#48)
Reviewed-on: #48
2026-09-16 02:53:16 +00:00
reya bf7080654d chore: use gpui image cache (#47)
Reviewed-on: #47
2026-09-16 02:21:12 +00:00
reya 42983383b0 chore: update script 2026-09-16 09:05:09 +07:00
reya e2b10c173e fix: markdown rendering (#46)
Reviewed-on: #46
2026-09-16 01:52:52 +00:00
reya 9e33da717b feat: add support for encrypted attachment (#45)
Reviewed-on: #45
2026-09-16 01:25:14 +00:00
reya 584ab34df6 chore: release version 1.0.1 2026-09-07 13:58:43 +07:00
reya 0514c1d982 fix: panic on flatpak installations 2026-09-07 13:49:36 +07:00
48 changed files with 8490 additions and 883 deletions
+13 -1
View File
@@ -152,11 +152,23 @@ jobs:
echo "Artifacts structure:" echo "Artifacts structure:"
find artifacts -type f -exec ls -la {} \; find artifacts -type f -exec ls -la {} \;
- name: Generate SHA256SUMS
run: |
# One `<sha256> <path>` line per artifact. The in-app updater reads
# this to verify a download before installing it, so it must be
# published alongside every release. Written outside the directory
# being hashed so the checksums file never includes itself.
find artifacts -type f ! -name SHA256SUMS -print0 \
| sort -z \
| xargs -0 sha256sum > SHA256SUMS.raw
mv SHA256SUMS.raw artifacts/SHA256SUMS
cat artifacts/SHA256SUMS
- name: Create draft release - name: Create draft release
id: create_release id: create_release
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
with: with:
server_url: "https://git.reya.su/" server_url: "https://git.reya.info/"
repository: "reya/coop" repository: "reya/coop"
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
draft: true draft: true
+150
View File
@@ -0,0 +1,150 @@
# Rust coding guidelines
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
- Propagate errors with `?` when the calling function should handle them
- Use `.log_err()` or similar when you need to ignore errors but want visibility
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
* Avoid creative additions unless explicitly requested
* Use full words for variable names (no abbreviations like "q" for "queue")
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
Example:
```rust
executor.spawn({
let task_ran = task_ran.clone();
async move {
*task_ran.borrow_mut() = true;
}
});
```
# Timers in tests
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
# GPUI
GPUI is a UI framework which also provides primitives for state and concurrency management.
## Context
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
* `App` is the root context type, providing access to global state and read and update of entities.
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
## `Window`
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
## Entities
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
* `thing.entity_id()` returns `EntityId`
* `thing.downgrade()` returns `WeakEntity<T>`
* `thing.read(cx: &App)` returns `&T`.
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
## Concurrency
All use of entities and UI rendering occurs on a single foreground thread.
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
* Awaiting the task in some other async context.
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
* Storing the task in a field, if the work should be halted when the struct is dropped.
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
## Elements
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
Example:
```
struct TextWithBorder(SharedString);
impl Render for TextWithBorder {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().border_1().child(self.0.clone())
}
}
```
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
The style methods on elements are similar to those used by Tailwind CSS.
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
## Input events
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
## Actions
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
## Notify
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
## Entity events
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
# Pull request hygiene
When an agent opens or updates a pull request, it must:
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
- Avoid trailing punctuation in PR titles.
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
- Include a `Release Notes:` section as the final section in the PR body.
- Use one bullet under `Release Notes:`:
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
- `- N/A` for docs-only and other non-user-facing changes.
- Format release notes exactly with a blank line after the heading, for example:
```
Release Notes:
- N/A
```
Generated
+422 -301
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "1.0.0" version = "1.0.2"
edition = "2024" edition = "2024"
publish = false publish = false
@@ -27,6 +27,14 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
sha2 = "0.10"
data-encoding = "2"
hkdf = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] } chrono = { version = "0.4.38", features = ["wasmbind"] }
@@ -45,6 +53,7 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] } tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
errno = { version = "0.3.14", default-features = false } errno = { version = "0.3.14", default-features = false }
instant = "0.1" instant = "0.1"
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
[patch.crates-io] [patch.crates-io]
# Use stacker's psm version which may have better WASM support # Use stacker's psm version which may have better WASM support
+810
View File
@@ -0,0 +1,810 @@
# Concord support — backend & API plan
Concord is an encrypted community/channel protocol over Nostr: shared-key "Private Streams" (CORD-01), communities with a self-certifying owner id and three planes (CORD-02), public/private channels (CORD-03), an owner-rooted signed roster (CORD-04), invites (CORD-05), rekeys/refoundings (CORD-06) and disappearing messages (CORD-08).
Scope of this plan: **backend + public Rust API**. No views, no widgets, no copy.
## 1. Scope
**In scope**
- New `crates/concord` crate: derivations, stream codec, control/chat/guestbook planes, authority fold, ephemeral state, invites, rekeys, dissolution, storage, sync engine.
- Public API for a future UI: `ConcordRegistry` + `Entity<Community>` / `Entity<Channel>` + events, mirroring the shape of `crates/chat`.
- The minimum surgical edits to existing crates required for coexistence (see §11).
**Out of scope**
- Any UI work.
- CORD-07 audio/video. Reserve `23313`, the `concord/voice-*` labels and the `voice` metadata flag so nothing else claims them, and implement nothing.
- Pins (CORD-04 §7) ship in the last milestone; the design accounts for `vsk 11` early so the fold is not retrofitted.
- Cross-client interop testing (Vector/Armada/Grimoire). Tracked as follow-up work, not blocking.
## 2. Sources of truth
| Doc | What we take from it |
| --- | --- |
| CORD-01 | Stream event shape, seal forms 20013/20014, encoding rules, binding, deletions |
| CORD-02 | `community_id`, `community_root`, `control_root`, epochs, 3 planes, metadata, invites, Community List, dissolution |
| CORD-03 | Channel keying, metadata, message kinds, `channel`/`epoch` binding, threads vs quotes |
| CORD-04 | Editions, `vac`, the roster, permission bits, banlist, the three removals, pins |
| CORD-05 | Bundle, link (naddr + fragment), relay dictionary, Invite List, Registry, Direct Invite |
| CORD-06 | Rekey blobs, chunking, `prevcommit` continuity, Refounding, compaction, races |
| CORD-08 | `message_expiration`, NIP-40 tagging, ingest/purge enforcement, timer notice 1740 |
Appendix A (derivations) and Appendix B (kinds) of CORD-02 are **frozen**: every labeled byte and every kind number is part of the wire format. Treat both as constants with golden-vector tests.
Reference implementations for cross-checking behaviour (not for copying code): Vector (`crates/vector-core/src/community/v2/*`), Armada, Grimoire.
## 3. Reuse map — nostr-sdk APIs we build on
Verified against the pinned revision (`nostr` 0.45.4 / `nostr-sdk` 0.45.2, git `b230cec`).
| Concord need | Existing API |
| --- | --- |
| NIP-44 under a raw conversation key | `nostr::nips::nip44::v2::{ConversationKey, encrypt_to_bytes_with_nonce, decrypt_to_bytes}` |
| NIP-44 conversation key for a keypair | `ConversationKey::derive(&SecretKey, &PublicKey)` (self-ECDH for streams) |
| NIP-44 under a signer | `nip44::{encrypt, decrypt}` (already wrapped by `state::UniversalSigner`) |
| Event id recomputation | `EventId::compute(pubkey, created_at, kind, tags, content)`, `UnsignedEvent::compute_id` |
| Event (de)serialization | `Event::{from_json, as_json, verify}`, `UnsignedEvent::from_json` |
| Building events | `EventBuilder::new(..).tags(..).custom_created_at(..)`, `FinalizeEvent::finalize(&Keys)`; `UnsignedEvent::new(..)` for rumors, whose tags are the author's contract and must not be normalized |
| Tags | `Tag::{custom, identifier, public_key, expiration}`, `Tags`, `SingleLetterTag` |
| Kinds | `Kind::GiftWrap` (1059), `Kind::Custom(21059|20013|20014|3308|…)`, `Kind::is_ephemeral` |
| Publish | `Client::send_event(&event).to(relays).ack_policy(AckPolicy::none())` |
| Subscribe | `Client::subscribe(target).with_id(..).close_on(..)`, `SubscribeAutoCloseOptions`, `ReqExitPolicy` |
| Backfill | `Client::fetch_events(target)`, `Client::stream_events(target)` |
| Local persistence | `Client::database()``query(Filter)`, `save_event(&Event)` |
| Invite link parsing | `Nip19::from_bech32``Nip19::Coordinate(Nip19Coordinate)` |
| Signer abstraction | `state::UniversalSigner` (`AsyncSignEvent` + `AsyncNip44`) |
| Relay auth | `nostr_sdk::{Authenticator, SignerAuthenticator}` |
**Not needed.** `secp256k1` (use `nostr::SecretKey::from_slice` + `Keys::new`), `base64` (use `data_encoding::BASE64`, already a workspace dep), `bech32` (NIP-19 is in the SDK), any new storage engine (the client's LMDB database is enough), any new HTTP client.
**Dependencies added so far:** `hkdf = "0.12"` at M0 (already in `Cargo.lock` transitively) and `rand = "0.10"` at M1 for the NIP-44 nonce, pinned to the exact instance `nostr` already builds (`default-features = false`, features `std` + `sys_rng`) so `nostr`'s `os-rng` and ours unify on one `rand`/`getrandom`. `sha2` and `data-encoding` were already workspace deps. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates so far, and all of these are direct-dependency lines only.
**Why not depend on Vector's crates.** `vector-core` (published, MIT) holds the only other Rust Concord implementation, in `src/community/v2/*`. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold:
- **No shared types.** It exact-pins the nostr family (`nostr = "=0.45.1"`, `nostr-sdk = "=0.45.1"`, `nostr-connect = "=0.45.1"`, `nostr-blossom = "=0.45.0"`) with the note that a caret range would let a consumer resolve a mixed set, while we track git master (`b230cec`, 0.45.4 / 0.45.2). A registry 0.45.1 and a git 0.45.4 cannot unify, so a build linking both carries two `nostr` crates whose `Event`/`Keys`/`PublicKey`/`Client` are unrelated types.
- **Not wasm-buildable.** `rusqlite` (bundled C SQLite), `libc`, `rustls`, `reqwest`, `image`, `bip39`, and a `tokio` `net` + `rt-multi-thread` requirement; `VectorCore::init` installs a process-global rustls provider and raises the fd limit. Coop's `web` target is wasm32.
- **It is an application core, not a Concord library.** 80k+ lines over 111 files, built on process-global singletons (`state::STATE`, `MY_SECRET_KEY`, one app-data dir, one live account, `traits::set_event_emitter`) and its own SQLite schema, relay pool and blocking `listen()` loop. Adopting it means handing it the nsec and letting it own the client, the database and the event loop — replacing `state`, `chat` and `person` rather than reusing a component. Its `login` stores raw secret-key bytes in that global vault, so an account whose key lives in a signer cannot drive it.
- **There is no cryptography to share.** Both implementations call the same audited crates — `hkdf`, `sha2`, nostr's secp256k1 keypair, and nostr's NIP-44 v2. Vector's comment on that same dependency is "audited RustCrypto crate rather than a hand-rolled construction". Confirmed in M1 by reading `community/cipher.rs`: it is a ~20-line wrapper that draws an OS nonce, calls `nostr::nip44::v2::encrypt_to_bytes_with_nonce`, and base64s the result — which is precisely what `stream.rs` does. Their `stream.rs` likewise calls `nostr_sdk::prelude::nip44::v2::{decrypt_to_bytes, ConversationKey}` directly. Our `derive.rs` holds the frozen `info` layout and label table, and `stream.rs` the seal/wrap ordering; both are wire format, not primitives.
So Vector's crates earn their place as an **oracle, not a dependency**: the golden vectors in `derive.rs` are their published data, and their `community/v2/stream.rs` was diffed against our §7 before the codec was written. It agrees on every wire detail, and contributed the `ms` first-wins rule, the Control Plane's no-`ms` rumor shape and the `rewrap_seal` contract.
## 4. Crate layout
New crate `crates/concord`, picked up automatically by the `crates/*` workspace member glob.
```
crates/concord/
Cargo.toml
src/lib.rs init, ConcordRegistry, ConcordEvent, signal bus, subscriptions, ingest pipeline
src/derive.rs frozen HKDF / group_key / locators / commitments + golden vectors
src/stream.rs CORD-01: seal + wrap + open (SealForm, OpenedStream), channel/epoch binding
src/edition.rs CORD-04 §1: edition hash, parse, chain fold, floor-aware head selection
src/control.rs control plane: genesis, content types, the control fold, the edition writer
src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint
src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite
src/rekey.rs CORD-06: blob codec, continuity, refounding, compaction, dissolution
src/store.rs local persistence + opened-rumor cache + history queries
```
`Community` and `Channel` GPUI entities live in `src/lib.rs` next to the registry — they are the public surface, not a separate concern. Eleven modules, each with real content; no single-fn files.
Dependencies: `common`, `state`, `person`, `device`, `settings`, `gpui`, `nostr` (for `nip44` features), `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow`, `flume`, `smallvec`, `itertools`, `futures`, `log`, `instant`. Everything under `cfg(not(target_arch = "wasm32"))` follows the `chat`/`state` split so the crate still builds for web.
Declare only what a milestone actually uses. As of M3 the crate depends on `nostr`, `nostr-sdk`, `hkdf`, `sha2`, `data-encoding`, `rand`, `serde`, `serde_json`, `anyhow` (plus `nostr-memory` and `smol` for tests). `rand` is pinned to the `0.10.2` instance `nostr` already builds and shares its `getrandom`, which the `web` crate already enables `wasm_js` on — so no new package and no new wasm obligation. `serde`/`serde_json` were already in the graph via `nostr`; promoting `serde_json` from dev to main for the metadata content types added no package either, only the `concord → serde` edge. M3 added no dependency of its own.
## 5. Core types
```rust
pub struct CommunityId([u8; 32]); // sha256 commitment, never on the wire
pub struct ChannelId([u8; 32]);
pub struct Epoch(pub u64);
/// A derived stream: signing keypair + the self-ECDH conversation key that
/// encrypts the wraps. Memoised in a bounded process-wide cache.
pub struct GroupKey { keys: Keys, conversation: ConversationKey }
impl GroupKey {
pub fn pk(&self) -> PublicKey;
pub fn pk_hex(&self) -> String; // lowercase; Debug prints only this, never key material
pub fn keys(&self) -> &Keys;
pub fn conversation(&self) -> &ConversationKey;
}
pub enum SealForm { Encrypted, Plaintext }
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey, // the seal's verified pubkey
pub seal_form: SealForm,
pub seal: Event, // retained: compaction re-wraps plaintext seals verbatim
pub wrapper_id: EventId,
pub at_ms: u64, // created_at * 1000 + ms tag
pub rumor: UnsignedEvent,
}
```
Ordering everywhere uses `at_ms`, never `created_at`, and ties break on the lower inner rumor id.
## 6. Frozen derivations (`derive.rs`)
Implemented and pinned in `crates/concord/src/derive.rs`.
```rust
fn build_info(label: &str, id: &[u8; 32], epoch: Option<u64>) -> Vec<u8>; // label ‖ 0x00 ‖ id[32] ‖ epoch_be[8]?
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32]; // HKDF-SHA256, zero-length salt, L = 32
fn hkdf_to_secret_key(ikm: &[u8], info: &[u8]) -> Result<SecretKey>; // A.3 scalar_normalize, counter from 0
fn group_key(label: &str, secret: &[u8], id: &[u8; 32], epoch: Option<u64>) -> Result<GroupKey>;
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
pub fn control_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // read key
pub fn control_signer_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>; // write key
pub fn guestbook_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
pub fn channel_rekey_group_key(root: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey>;
pub fn base_rekey_group_key(root: &[u8; 32], id: &CommunityId, epoch: Epoch) -> Result<GroupKey>;
pub fn dissolved_group_key(id: &CommunityId) -> Result<GroupKey>; // no epoch field
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId; // plain SHA-256
pub fn verify_community_id(id: &CommunityId, owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> bool;
pub fn epoch_key_commitment(epoch: Epoch, key: &[u8; 32]) -> [u8; 32]; // plain SHA-256
pub fn grant_locator(id: &CommunityId, member: &[u8; 32]) -> [u8; 32];
pub fn banlist_locator(id: &CommunityId) -> [u8; 32];
pub fn pins_locator(id: &CommunityId, channel: &ChannelId) -> [u8; 32];
pub fn invite_links_locator(id: &CommunityId, creator: &[u8; 32]) -> [u8; 32];
pub fn recipient_locator(rotator: &[u8; 32], recipient: &[u8; 32], scope: &[u8; 32], epoch: Epoch) -> [u8; 32];
pub fn invite_bundle_key(token: &[u8; 16]) -> [u8; 32]; // raw hkdf32 output; used as a NIP-44 conversation key
pub fn clear_memo(); // drop memoised keys on signer change
```
Appendix A.6, as implemented — `ikm` / `id` / `epoch`. The id is *always* present (all-zeroes where a label has no meaningful one); the epoch is the only omittable field.
| Label | ikm | id | epoch |
| --- | --- | --- | --- |
| `concord/channel` | channel key or `community_root` | `channel_id` | yes |
| `concord/control` | `community_root` | `community_id` | yes |
| `concord/control-signer` | `control_root` | `community_id` | yes |
| `concord/rekey-pseudonym` | prior `community_root` | `channel_id` | new epoch |
| `concord/base-rekey-pseudonym` | prior `community_root` | `community_id` | new epoch |
| `concord/recipient-pseudonym` | `rotator_xonly ‖ recipient_xonly` (64 B) | scope id | new epoch |
| `concord/guestbook` | `community_root` | `community_id` | yes |
| `concord/dissolved` | `community_id` | zeroes | — |
| `concord/grant` | `community_id` | member x-only | — |
| `concord/banlist` | `community_id` | zeroes | — |
| `concord/pins` | `community_id` | `channel_id` | — |
| `concord/invite-links` | `community_id` | creator x-only | — |
| `concord/invite-key` | token (16 B) | zeroes | — |
The CORD-07 `concord/voice-*` labels and the retired `concord/invite-locator` / `concord/invite-signer` are reserved and listed here only: they are underived, and the table stays append-only. Every label a derivation does use has a distinct pinned output, so a duplicated label cannot pass the vectors.
Rules that must be enforced by construction, not by convention:
- Hex is lowercase everywhere; pubkeys are x-only hex, never bech32; tag numbers are decimal with no leading zeros (`"4"`, never `04`/`+4`).
- The epoch field is *omitted*, not zeroed, for labels with no epoch; a test asserts `dissolved_group_key` differs from the same derivation with `Some(0)`.
- `scalar_normalize` retries by appending a counter byte to the same `info`, starting at `0`, and reports exhaustion instead of panicking — so plane keys return `Result<GroupKey>`.
- Group keys are memoised by a digest of their inputs, so no deriving secret is a map key, bounded at 1024 entries.
**Golden vectors.** `derive.rs` pins all 18 published vectors (the seed and `pk` for channel, control, control-signer and guestbook; both keyed labels at epoch `0` and at `0x0102030405060708`; both rekey labels at epoch 1; dissolved; all four locators; the invite key; the community id; the epoch-key commitment), cross-checked against an independent Python implementation (RFC 5869 HKDF plus pure-integer secp256k1) before being frozen. One vector is missing upstream — `pins_locator` — so we mint it from our own implementation and pin it, flagged in the test as self-referential. Changing any pinned value means the wire format changed.
## 7. Stream codec (`stream.rs`) — implemented in M1
```rust
pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
pub enum SealForm { Encrypted, Plaintext }
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
pub rumor: UnsignedEvent,
}
pub fn split_ms(at_ms: u64) -> (u64, u16);
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError>;
pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError>;
pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author: &Keys) -> Result<Event, StreamError>;
pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, at: Timestamp, extra: &[Tag]) -> Result<(Event, Keys), StreamError>;
pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, at: Timestamp) -> Result<(Event, Keys), StreamError>;
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError>;
pub fn open_wrap_at(wrap: &Event, address: &PublicKey, conversation: &ConversationKey, verify_wrap_signature: bool) -> Result<OpenedStream, StreamError>;
pub fn build_rumor_ms(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_ms: u64) -> UnsignedEvent;
pub fn build_rumor_secs(kind: u16, author: PublicKey, content: &str, tags: Vec<Tag>, at_secs: u64) -> UnsignedEvent;
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag>;
pub fn check_channel_binding(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<(), StreamError>;
```
`StreamError` is a typed enum, not `anyhow`: the caller has to tell a drop from a fatal, and M1's acceptance criterion is that rejections happen in the documented order.
Refinements against the draft this plan opened with, decided after diffing Vector's `crates/vector-core/src/community/v2/stream.rs` (Concord has no crate of its own there, and `envelope.rs` does not exist):
- `build_rumor` became `build_rumor_ms` plus `build_rumor_secs`. The Control Plane edition carries **no** `ms` tag, because editions fold by version, not by time.
- `rewrap_seal` was added to the codec. Without it the plaintext-seal carry-forward has no expression, and M7's compaction is its only caller.
- NIP-44 is reached through `nostr`'s own `nip44::v2::{encrypt_to_bytes_with_nonce, decrypt_to_bytes, ConversationKey}`, with a fresh OS nonce per message and `data_encoding::BASE64` for carriage. This is exactly what Vector does; there is no cryptography of theirs to reuse.
Design points that are easy to get wrong:
- The wrap is signed by the **stream key** with a random ephemeral `p` tag — NIP-59 reversed. `extra` is how the caller mirrors a NIP-40 expiration onto the wrap.
- The seal is signed by the **real author** and carries `created_at` equal to the rumor's. It is never published bare.
- Control plane **must** use the plaintext seal; chat, guestbook and rekey planes **must** use the encrypted one. Each plane asserts its own form at both ends.
- The control plane is a write-restricted stream: the wrap key derives from `control_root` while the content is encrypted under the `community_root`-derived conversation key. `open_wrap_at` takes the two halves separately for this reason.
- Open order: kind → address match → wrap signature (only when `verify_wrap_signature`) → NIP-44 open → seal kind → seal signature → rumor parse → `rumor.pubkey == seal.pubkey` → recompute the rumor id and reject a mismatch → strict `ms` resolve.
- Enforce the 65 535-byte NIP-44 cap at every nesting layer before publishing.
- Do not verify the wrap signature for ordinary planes: every reader holds the group key, so the signature proves nothing. It matters only for the restricted control plane and for rekeys.
- The ephemeral wrap keypair is returned to the caller so a client may NIP-09-scrub its own wrap later.
- **A duplicate `ms` tag takes the first value; it is not rejected.** Rejecting made Vector and Armada disagree on whether the event *exists*, and because `ms` orders messages that divergence reached membership. `ms` is the publisher's own value, so conceding a second tag grants an attacker no reach a single one did not. A present-but-valueless `ms`, or one that is not a lone canonical decimal in `0..=999`, is `BadMs` and the event is dropped, never clamped — `u64::from_str` alone would accept a leading `+`, a second encoding a strict peer rejects, so the digit check comes first.
- A binding tag that names the same key twice is rejected outright, since first-match would then be the reader's choice rather than the author's; a valueless tag counts as absent, so a true absence reports `MissingTag`.
## 8. Planes, state and folds
### 8.1 Editions, authority and the control fold (`edition.rs`, `roles.rs`)
```rust
pub const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; // frozen, cross-client (27 bytes)
// sha256( u64be(len(label)) ‖ label ‖ entity[32] ‖ u64be(version)
// ‖ flag[1] ‖ prev[32] ‖ u64be(len(content)) ‖ content )
// `prev` is always 33 bytes: 0x01 ‖ hash, or 0x00 ‖ zeroes when absent.
// The hash commits to no actor: identity enters only via the rumor id.
pub fn edition_hash(entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8]) -> [u8; 32];
pub struct ParsedEdition { author: PublicKey, subkind: String, entity: [u8; 32], version: u64,
prev: Option<[u8; 32]>, citation: Option<AuthorityCitation>,
content: String, self_hash: [u8; 32], rumor_id: EventId };
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError>;
pub struct FoldResult { pub head: Option<usize>, pub gap: bool, pub anchored: bool }
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult;
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize>; // highest, contiguity ignored
// One entity's committed head, and the refuse-downgrade floor a later fold is judged
// against.
pub struct EntityHead { entity: [u8; 32], version: u64, self_hash: [u8; 32], rumor_id: EventId }
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
pub struct HeadSelection { pub head: Option<usize>, pub gap: bool }
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection;
```
- Tag grammar: `["vsk", sub]`, `["eid", hex32]`, `["ev", decimal]`, `["ep", hex32]`, `["vac", eid, version, hash]`. Duplicates of any of the five reject the edition; `ev` must pass a decimal check before parsing. A version of `0` parses and then reads as a gap — the rule lives in the fold, not the parser.
- **Versions start at 1, not 0** (CORD-04 §1: "climbs from 1"). Genesis is `(version 1, prev None)` for both entities.
- **The edition hash is not the signature.** The actor's Schnorr signature covers the kind-20014 plaintext seal; `edition_hash` is a separate SHA-256 used only for chaining (`ep`, `vac`). `content` is the rumor's content string byte-verbatim, never re-serialized, which is what lets compaction re-wrap a head and preserve its hash.
- The domain label is `vector-community/v1/edition`, not a `concord/…` label. Inconsistent with Appendix A.6, frozen anyway — do not "fix" it.
- Tie-break at equal version is the lower **inner rumor id** (the kind-3308 rumor), never the outer wrap id and never `created_at`. Only one of the two implementations that must agree applies to a wrap, so the inner id is the only stable choice.
- `gap` is a safety signal: a tracking client (already holds the floor) fails closed for that entity and refetches; a bootstrapping client (floor 0) may accept the highest authority-verified head, which is what makes compaction re-wrapping work. `bootstrap_head` therefore takes no floor: it *is* the floor-zero path. `fold_head` is the composition — floor 0 takes `bootstrap_head`; under a held floor the chain-anchored head wins and any upper gap is reported; everything below the floor is a stale relay, not a gap; and a head detached from the floor converges a same-version fork to its lower rumor id when that is genuinely earlier than what we hold, else fails closed as withholding.
- **Owner anchoring is not in the fold.** `fold` is a pure function of chain shape; authority is a pre-filter the caller applies to the candidate set before folding. `community_id` proves the owner, and `is_authorized` short-circuits `owner == actor`, so the owner needs no Grant entity at all.
- Entity coordinates are `vsk 0``community_id`, `1``role_id`, `2``channel_id`, `3``grant_locator`, `4``banlist_locator`, `8``invite_links_locator`, `11``pins_locator`. `5` is reserved, `6`/`9` belong to the 33301 invite marker, `7` is retired. All derive from `community_id` only, so a refounding re-wraps heads verbatim.
- The Control Plane is **plaintext-seal only**. A 20013-encrypted control edition is rejected, because compaction re-wraps a signed plaintext seal byte-verbatim into the new epoch; accepting an encrypted one would let a later compaction fork the chain.
- **Genesis is exactly two owner-signed editions** — community metadata (`vsk 0`, `eid = community_id`) and one public `#general` channel (`vsk 2`, fresh random `channel_id`) — at epoch 0, version 1, no `ep`, no `vac`. No default roles, no scaffolding, and no Grant for the owner. Secrets minted: `owner_salt`, `community_root`, `control_root` (deliberately not derived from `community_id`).
```rust
// CORD-04 §3, frozen. 1<<7 was MANAGE_INVITES and is burned, never reassigned.
// MANAGE_ROLES 1<<0 · MANAGE_CHANNELS 1<<1 · MANAGE_METADATA 1<<2 · KICK 1<<3 ·
// BAN 1<<4 · MANAGE_MESSAGES 1<<5 · CREATE_INVITE 1<<6 · VIEW_AUDIT_LOG 1<<8 ·
// MENTION_EVERYONE 1<<9 · PIN_MESSAGES 1<<11 · reserved: MANAGE_EMOJI 1<<10, MANAGE_EVENTS 1<<12
pub struct Permissions(pub u64);
impl Permissions {
pub const STAFF_MASK: u64; // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|BAN|CREATE_INVITE|PIN_MESSAGES
pub fn contains(self, bits: u64) -> bool;
pub fn union(self, other: Self) -> Self;
pub fn is_staff(self) -> bool;
}
pub enum RoleScope { Server, Channel(ChannelId) } // {"kind":"server"} / {"kind":"channel","channel_id":…}
pub struct Role { role_id: RoleId, name: String, position: u32, permissions: Permissions,
scope: RoleScope, color: u32, extra: Extra }
pub struct Grant { member: PublicKey, role_ids: Vec<RoleId>, control_wrap: Option<String>, extra: Extra }
pub struct CommunityRoles { roles: BTreeMap<RoleId, Role>, grants: BTreeMap<PublicKey, Grant> }
impl CommunityRoles {
pub fn role(&self, role_id: &RoleId) -> Option<&Role>;
pub fn roles_of(&self, member: &PublicKey) -> impl Iterator<Item = &Role>;
pub fn effective_permissions(&self, member: &PublicKey) -> Permissions; // union of granted role bits
pub fn has_permission(&self, member: &PublicKey, bits: u64) -> bool;
pub fn highest_position(&self, member: &PublicKey) -> Option<u32>; // lowest position they hold
pub fn is_authorized(&self, actor, owner, permission: u64) -> bool; // owner == actor → true
pub fn outranks(&self, actor, owner, target_position: u32) -> bool; // strict `<`
pub fn can_act_on_position(&self, actor, owner, target_position: u32, permission: u64) -> bool;
pub fn can_act_on_member(&self, actor, owner, target: &PublicKey, permission: u64) -> bool;
pub fn is_staff(&self, member, owner) -> bool;
}
// The delegation fixpoint. Content is parsed once, up front: the fixpoint revisits
// every candidate on each pass.
pub enum AuthorityContent { Role(Role), Grant(Grant), Banlist(Vec<PublicKey>) }
pub struct AuthorityEdition { entity: [u8; 32], meta: EditionMeta, author: PublicKey,
citation: Option<AuthorityCitation>, content: AuthorityContent }
impl AuthorityEdition {
pub fn parse(edition: &ParsedEdition, community_id: &CommunityId) -> Option<Self>;
}
pub struct Roster { roles: CommunityRoles, banned: BTreeSet<PublicKey>, floors: Floors, gapped: bool }
pub fn fold_roster(owner, community_id, editions: &[AuthorityEdition], floors: &Floors,
held_bans: &BTreeSet<PublicKey>) -> Roster;
pub fn citation_ok(owner, community_id, author, citation: Option<&AuthorityCitation>,
floors: &Floors) -> bool;
```
Authority rules as implemented:
- The owner is position 0, proven by `community_id`, supreme, unremovable, and **not a Role**: no Role may claim position 0, and every gate short-circuits `owner == actor`. The owner therefore needs no Grant and cites nothing.
- A member's rank is the **lowest** position among their Roles; a roleless member sits at `u32::MAX`. Two Roles may share a position (peers, neither acts on the other); display tie-breaks on the lower `role_id`.
- The actor must hold the required bit **and strictly outrank** the target. Equal cannot act on equal.
- `AuthorityEdition::parse` drops, rather than repairs: a `role_id` that is not its own coordinate, a `position` of 0, a Grant whose `member` does not hash to its entity, a `vsk 4` at a coordinate that is not this community's banlist locator, malformed JSON, and any `vsk` this type does not own. A Grant's `role_ids` truncate at 64 on read.
- **Refuse-downgrade**: an edition below the persisted floor for its entity is never a candidate.
- The fold is a **Jacobi fixed point** — authority propagates one delegation level per pass, bounded by `2 × (entities) + 8`. Convergence compares the roster only, not the heads. Cross-pass state is exactly the accepted roster plus its heads, and `citation_ok` reads the *previous* pass's heads, so the first pass sees none.
- **Roles** replay each entity's versions **ascending**, one winner per version group, `admissible` collecting the winners that pass. Gates, in order: not banned; `can_act_on_position(author, owner, position, MANAGE_ROLES)`; if a predecessor was admitted, the same call against *its* position; then the citation. The highest admissible version wins. Replaying ascending is what makes the second gate work: without it an admin at position 5 republishes a position-1 role at position 9, every check passes since 9 is beneath them, and a role that outranked them ends up beneath them along with everyone holding it.
- **Grants** take no version-group replay: the first candidate in vector order clearing every gate wins. Role references resolve **partially** — the resolvable subset is carried and the rest fold in on a later pass — because all-or-nothing resolution deadlocks the ordinary growth path (an admin creates a role, the owner grants it to them, and neither can go first, collapsing the entire roster including the owner's own grants). The final gate ranks every resolved position *and* the member.
- A **citation** that cannot be resolved parks the edition; a missing one is tolerated only where the rank gates carry the weight. For a Role that is everywhere. For a Grant it is not: a revoke names no position, so its rank test is vacuous — hence an uncited Grant may add authority but **never remove** it.
- The **banlist** is folded after a preliminary roster, since a ban only exists once someone authorized to place it does. Its head is the highest edition whose author currently holds `BAN` and does not already sit in the held banlist; each entry is kept only if that author strictly outranks the target, and the list caps at 500. **Withholding retains the held list** rather than un-banning nobody on a relay's word. The final roster is then re-folded with the banned set excluded, so a banned admin loses their authority in the same pass.
- A staff-making Grant carries `control_wrap`, a NIP-44 pairwise ciphertext of `epoch_be[8] ‖ control_root[32]`, adopted **only if it derives to the `control_pk` the member already holds** for the named epoch. Delivery, never authority.
- Caps: **100 Roles per community**, by the 100 lowest `role_id`, applied *after* authorization so forged low ids cannot evict a real role; the grants then shed the dropped ids. **64 Roles per member**, at parse. **500 banlist entries**, at fold.
**Two deliberate divergences from the reference implementation** (Vector is an oracle, not a specification):
1. **The role fork winner.** Vector's role branch walks version groups with `.iter().rev()`, taking each group's *highest* inner id, while its own adjacent comment says forks break on the lowest and the rest of its codebase (`fold_head`, `version::fold`, its invite-registry test) does use the lowest. No test in Vector pins the branch. We implement **lowest inner id**, per CORD-04 §1 — and our tests pin it.
2. **Banlist candidates must be `vsk 4`.** Vector collects banlist candidates from every edition sitting at the banlist locator regardless of `vsk`, so a `vsk 1` forged there can win the banlist head, parse to an empty list, and clear the ban. We require `vsk::BANLIST` for a candidate at all.
One reference limitation we **reproduce and do not fix** (recorded here rather than silently diverging): the grant rank gate reads the *previous* pass's roster, so a mid-rank `MANAGE_ROLES` holder who cites a real, folded grant of their own can revoke a higher-ranked member whose authority is still propagating. Fixing it means resolving a Grant's target rank against the same pass, which changes the fixpoint's convergence argument. Revisit only with a spec amendment.
### 8.2 Communities, channels, metadata
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
The Control Plane's whole projection is one call:
```rust
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition],
floors: &Floors, held_bans: &BTreeSet<PublicKey>) -> ControlFold;
```
- The roster is folded first, and `vsk 0` / `vsk 2` are then judged against it: the head of each entity is the highest edition whose author *currently* holds `MANAGE_METADATA` / `MANAGE_CHANNELS`, is not banned, and either is the owner or cites their own folded Grant. Pre-filtering before the chain fold is what stops a demoted admin's later, higher-version edition from being the head.
- A `vsk 2` whose entity is the community's own id is excluded, and a `vsk 0` at any other coordinate with it: the floor row keys on the entity alone, so the two would otherwise share and corrupt one chain.
- `None` means "this client saw no authorized edition", never "the value is gone": a caller keeps what it holds rather than walking the community backwards. That is also how a withheld or downgraded entity reads.
- A `deleted` channel is reported as metadata with `deleted: true`; the policy of dropping it belongs to the store.
Writes go through one primitive, so every edition names the head it supersedes and a client cannot silently fork a chain it cannot see:
```rust
pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
head: Option<&'a EntityHead>, citation: Option<AuthorityCitation> }
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
impl ControlWriter {
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
}
```
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`.
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
### 8.3 Guestbook and member list (`guestbook.rs`)
```rust
pub enum GuestbookEntry {
Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> },
Leave { member: PublicKey, at_ms: u64 },
Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> },
Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 },
}
pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool)
-> BTreeMap<PublicKey, MemberState>;
pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>, // author → newest ms published
banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>,
refound: Option<&Refound>) -> BTreeSet<PublicKey>;
```
- Entries dated more than an hour ahead of local time are dropped. An `ms` outside `0..999` drops the entry rather than being interpreted.
- Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id.
- A Kick counts only when its signer holds `KICK` and outranks the target.
- A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback.
- The member list is `coalesced Joined observed authors banlist`, and observation counts **forward only** (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with `ms == 0` is present by construction.
### 8.4 Chat plane (`chat.rs`) — implemented in M4
Kinds (CORD-02 Appendix B): `9` message, `1111` NIP-22 comment, `7` NIP-25 reaction,
`5` NIP-09 delete, `3302` edit, `3310` WebXDC peer signal, `23311` ephemeral typing.
```rust
pub struct ChatRumor { id, author, kind, channel, epoch, at_ms, content,
expiration: Option<Timestamp>, action: ChatAction }
pub enum ChatAction {
Message { reply_to: Option<ReplyRef>, thread_root: Option<ReplyRef> },
Reaction { target: EventId, emoji: String },
Edit { target: EventId, content: String },
Delete { target: EventId, target_kind: Option<u16> },
Typing,
Opaque,
}
pub struct ReplyRef { id: EventId, author: Option<PublicKey> }
pub struct Target { reply: ReplyRef, kind: u16 } // the wire commits the target's kind
pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>, at_ms) -> UnsignedEvent;
pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms) -> UnsignedEvent;
pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms) -> UnsignedEvent;
pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms) -> UnsignedEvent;
pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>, at_ms) -> UnsignedEvent;
pub fn build_typing(author, channel, epoch, at_ms) -> UnsignedEvent;
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool)
-> Result<(Event, Keys), ChatError>;
pub fn open(wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch)
-> Result<(OpenedStream, ChatRumor), ChatError>;
pub fn plane_keys(held: &[(Epoch, [u8; 32])], channel: &ChannelId) -> Result<Vec<(Epoch, GroupKey)>>;
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage>;
pub struct ChatMessage {
pub id: EventId,
pub author: PublicKey,
pub channel: ChannelId,
pub epoch: Epoch,
pub kind: Kind,
pub content: String,
pub reply_to: Option<EventId>, // a kind 9's `q`, or a comment's lowercase `e`
pub thread_root: Option<EventId>, // a comment's uppercase `E`
pub at_ms: u64,
pub expiration: Option<Timestamp>,
pub edited_at: Option<u64>,
pub deleted: bool,
pub reactions: BTreeMap<PublicKey, String>,
}
```
- `open` returns the `OpenedStream` next to the typed rumor because the two halves go
different ways: the caller caches the envelope, and folds the rumor.
- Ordering is `(at_ms, id)` everywhere, ties on the lower inner rumor id. The fold emits
newest first; mutations replay ascending on `(at_ms, Reverse(id))` so the last one
applied wins — the highest `at_ms` and, between equal ones, the lower id. A deletion is
terminal: a later edit never revives it.
- **M4 honors a delete only from the message's own author.** A moderator delete (a `vac`
citation under `MANAGE_MESSAGES`) needs the roster, so the builder's `citation`, the
fold's `can_delete` predicate and its tests land with M5. Failing closed here loses a
moderator's reach, never a member's authorship.
- `kind 15` is coop's own file-message convention, outside the CORD registry — it is
accepted on the read side so a second coop device's files are not dropped, and
`send_file` lands with the registry.
- A reference's author slot is a SHOULD on the wire, so it is optional. NIP-25 nonetheless
makes `p` a requirement, so a builder must be handed a `Target` built from the message it
acts on and never one whose author is empty, or a peer that requires the tag drops the result.
- `ms` orders a page but cannot page within one: a relay's `until` filter is second-granular,
so the cursor step below is what has to cope with a boundary second.
- `seal_rumor` gates the kind at publish and mirrors a NIP-40 `expiration` onto the wrap
(CORD-08 §2). The timer's policy — ingest refusal, the sweep, kind 1740 — is M8.
- **`media` and `mentions` are deliberately absent.** Both are pure post-processing of
`content` by `common` (`extract_and_remove_media_urls`, `NostrParser`) and both return a
gpui type, and a protocol crate does not take a UI dependency for a derived field. They
land with the first consumer that renders them.
### 8.5 Invites (`invite.rs`)
```rust
pub struct CommunityInvite { community_id, owner, owner_salt, community_root, root_epoch,
control_pk: Option<String>, channels: Vec<ChannelGrant>,
relays: Vec<String>, name: String, icon: Option<ImageRef>,
expires_at: Option<u64>, creator_npub: Option<String>, label: Option<String>,
extra: Map<String, Value> }
impl CommunityInvite { pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id
pub fn expired(&self, now_ms: u64) -> bool; }
pub fn build_bundle(token: &[u8; 16], link_signer: &Keys, invite: &CommunityInvite) -> Result<Event, InviteError>; // 33301, d = ""
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError>; // vsk 9
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError>;
pub fn encode_fragment(relays: &[RelayUrl], token: &[u8; 16]) -> String; // version byte 4, flags, ≤ 3 relays, base64url
pub fn decode_fragment(fragment: &str) -> Result<(Vec<RelayUrl>, [u8; 16]), InviteError>;
pub fn build_direct_invite(receiver: &PublicKey, invite: &CommunityInvite, signer: &UniversalSigner) -> Task<Result<Event, Error>>; // 3313 rumor → 13 seal → k-tagged 1059
```
The link rides `naddr` (`Nip19Coordinate` for kind 33301, link signer, empty `d`) in the path and the token + bootstrap relays in the fragment. A fragment is never sent to a server. The bundle is decrypted with `invite_bundle_key(token)`, and the joiner must recompute `community_id` from `owner` + `owner_salt`.
Bounds before allocation: reject a bundle with more than 256 channels, truncate the relay list to 5, refuse an expired one.
### 8.6 Rekeys and refoundings (`rekey.rs`)
```rust
pub enum RekeyScope { Channel(ChannelId), Base }
pub fn encode_blob_plaintext(scope, epoch, new_root, control_pk, control_root) -> Vec<u8>; // 72 | 104 | 136 bytes
pub fn parse_blob_plaintext(bytes: &[u8], scope, epoch) -> Result<KeyDelivery, RekeyError>;
pub fn build_rekey_rumor(rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, chunk) -> UnsignedEvent;
pub fn plan_refounding(fold, removed: &[PublicKey]) -> Result<Refounding, RekeyError>;
pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap heads verbatim, plaintext seals preserved
```
- The subscription for rekeys is precomputed from the *next* epoch's address, per private channel and once for the base.
- A receiver accepts a key only after: locating its blob, decrypting with the rotator↔recipient conversation key, checking the bound `scope` and `epoch` inside the plaintext, and matching `prevcommit` against the key it currently holds.
- Only after holding **all** `n` chunks of one `(rotator, newepoch, prevcommit)` set, with none containing its locator, may a client conclude it was removed.
- Send cap 80 blobs per event, accept cap 120 (Vector's documented erratum: the CORD-01 double envelope pushes 120 blobs past a 64 KB relay limit). Record the reason in a comment so nobody "fixes" it back.
- Compacted control heads are re-wrapped with their original signature intact, which is exactly why the control plane uses the plaintext seal.
- Two concurrent refoundings converge on the lexicographically lowest new base key; the heal is down-only.
- Authority: a channel rekey needs `MANAGE_CHANNELS`, a refounding needs `BAN`, and in both the rotator must strictly outrank every removed target. Holding a key is never authority.
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored.
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3
Three layers, no new storage engine:
1. **Raw wraps** (kind 1059) are persisted automatically by the SDK's relay pool when a subscription or fetch matches a filter. Nothing to write.
2. **Opened rumors** are cached locally as NIP-78 `Kind::ApplicationSpecificData` events signed by a session-local keypair, exactly like `chat::set_rumor`. Tags: `["d", rumor_id]` (replace key), `["c", channel_hex]`, `["p", author]`, `["k", kind]`, `["e", wrap_id]`, `["t", "concord"]`. Contents are the rumor JSON.
- The `c`/`t` keys deliberately differ from chat's `r` key so the two message namespaces can never collide in one database.
- `created_at` is the **message's own second** (from `at_ms`), not the wall clock. Otherwise `until` and the ordering would page on cache time rather than message time.
- The read path dedupes by rumor id and keeps the newest `created_at`, because the local signing key changes per session and each session leaves its own copy. The query therefore carries no filter `limit` — every copy has to be in hand before they can be collapsed — and the cap is applied to the deduplicated result instead.
- The layer takes `&dyn NostrDatabase`, not `&Client`: it is local-only, which keeps it testable without a relay or a GPUI context.
```rust
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>;
pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;
pub async fn backfill(client: &Client, database: &dyn NostrDatabase, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], until: Option<Timestamp>, limit: usize)
-> Result<Vec<ChatRumor>>;
```
`query_rumors` returns `UnsignedEvent`, not `Event`: the cached payload *is* a rumor, which is also what `OpenedStream` carries, so the caller never has to re-parse.
**Landed in M4:** `backfill` — newest-first relay paging across every held epoch. It derives
every held epoch's plane key once, fetches `kinds [1059, 21059]` by all of those addresses in
one filter with an inclusive `until`, opens each wrap against the plane whose address it
carries, caches it, and pages until the page is short of the limit, adds nothing new, or the
cursor cannot advance. That last case is real: `until` has second granularity, so a page that
begins and ends inside one boundary second has nowhere left to step and its remainder stays
unreachable until a relay serves it. Capped at `MAX_PAGES` so a relay that only ever repeats
itself cannot loop a client forever.
3. **Community state** — one local document per community, `Kind::ApplicationSpecificData` with `["d", "concord/<community_id>"]`:
```rust
pub struct CommunityState {
pub id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
pub control_root: Option<[u8; 32]>, // present iff the holder is staff
pub control_pks: BTreeMap<u64, PublicKey>, // epoch → the plane's signer address
pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch
pub relays: Vec<RelayUrl>,
pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id
pub added_at_ms: u64,
}
```
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key.
M3 added the two bridges between this document and the fold:
```rust
impl CommunityState {
pub fn floors(&self) -> Floors; // the fold's input
pub fn apply_fold(&mut self, fold: &ControlFold); // the fold's output
}
```
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it.
Writes are debounced (a fold head changes on every edition); reads load once at init.
**Decision, stated for the record:** this document stores raw community keys unencrypted in a plaintext local database. That matches the existing posture — `chat` already caches decrypted message rumors in the same LMDB. If that posture ever changes, the state document is the one to wrap with NIP-44-to-self, since it is the only local artifact holding keys.
## 10. Sync engine and GPUI conventions
`ConcordRegistry` mirrors `ChatRegistry`'s shape exactly: a foreground GPUI entity holding `Entity<Community>` handles, a `flume` signal bus, one background notification listener, one foreground consumer, and task slots that are cleared when the signer changes.
**Subscription.** Community relays come from the folded metadata. `init`/`join` add them to the client (`client.add_relay(url).and_connect()`), then:
```rust
let filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(plane_authors) // guestbook, control signer, all held channel planes,
// rekey addresses for epoch + 1, dissolved address
.since(Timestamp::from_secs(now - FRESH_WINDOW))
.limit(0); // live tail only; history comes from backfill
client.subscribe(target).with_id(SubscriptionId::new(CONCORD_SUB)).await?;
```
Targeted subscribe against the community relays, with a pool-wide subscribe as the fallback path. Rebuild idempotently whenever a plane's address changes (join, channel added, rekey folded).
**Routing.** `dispatch` matches on the `subscription_id` carried in `RelayMessage::Event`, dedupes by wrap id (both subscriptions and several relays deliver the same wrap), then recognises the plane by **wrap author** against the derived addresses it holds — never by trial decryption. Recognition order: held channel planes → guestbook → control signer → rekey addresses → dissolved.
**Ingest pipeline.** Unwrap, verify and fold all happen inside `cx.background_spawn`, never on the foreground thread: secp256k1 verification per edition and per seal is far too expensive for the UI thread.
```rust
enum Signal {
Chat { community: CommunityId, channel: ChannelId, message: Box<ChatMessage> },
Control { community: CommunityId, heads: Vec<EntityHead>, roster: Box<FoldedRoster> },
Guestbook { community: CommunityId, members: BTreeSet<PublicKey> },
Rekey { community: CommunityId, scope: RekeyScope, epoch: Epoch },
Dissolved(CommunityId),
Eose(SubscriptionId),
Error(ConcordError),
}
```
The consumer is `cx.spawn(async move |this, cx| { while let Ok(signal) = rx.recv_async().await { this.update(cx, |this, cx| this.apply(signal, cx))?; } })`, which updates entities and calls `cx.notify()`.
Rules taken from the project guidelines:
- Crypto, folding, database queries and network I/O only in `cx.background_spawn`.
- Foreground tasks are `cx.spawn` with `this.update(cx, ..)`; any entity update happens there, and the inner `cx` is always used.
- Tasks are stored in fields (`tasks`, `listener`, `consumer`) so they are cancelled on signer change and dropped with the registry. `detach()` only for genuinely fire-and-forget work such as the local state save.
- Long-running paging is bounded by explicit page and step caps, not by unbounded loops.
- Every fallible path returns `Result` and surfaces through `ConcordEvent::Error`; nothing is silently swallowed.
**Registry API.**
```rust
pub fn init(window: &mut Window, cx: &mut App);
pub struct ConcordRegistry { /**/ }
impl ConcordRegistry {
pub fn global(cx: &App) -> Entity<Self>;
pub fn loading(&self) -> bool;
pub fn communities(&self) -> Vec<Entity<Community>>;
pub fn community(&self, id: &CommunityId, cx: &App) -> Option<WeakEntity<Community>>;
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Community>>;
pub fn create(&mut self, params: CommunityParams, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
pub fn join(&mut self, link: &str, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
pub fn accept_direct_invite(&mut self, rumor: &UnsignedEvent, cx: &mut Context<Self>) -> Task<Result<CommunityId, Error>>;
pub fn leave(&mut self, id: &CommunityId, cx: &mut Context<Self>);
pub fn discard_invite(&mut self, id: &CommunityId, cx: &mut Context<Self>);
pub fn refresh(&mut self, id: &CommunityId, cx: &mut Context<Self>);
pub fn shutdown(&mut self, cx: &mut Context<Self>); // halt subscriptions, keep our own state
}
```
**Community API** (`Entity<Community>`, `EventEmitter<CommunityEvent>`):
```rust
pub fn id(&self) -> CommunityId;
pub fn owner(&self) -> PublicKey;
pub fn name(&self) -> SharedString; pub fn description(&self) -> Option<SharedString>;
pub fn icon(&self) -> Option<ImageRef>;
pub fn relays(&self) -> Vec<RelayUrl>;
pub fn epoch(&self) -> Epoch;
pub fn dissolved(&self) -> bool;
pub fn channels(&self) -> Vec<Entity<Channel>>;
pub fn channel(&self, id: &ChannelId, cx: &App) -> Option<WeakEntity<Channel>>;
pub fn members(&self) -> BTreeSet<PublicKey>;
pub fn banned(&self) -> BTreeSet<PublicKey>;
pub fn roles(&self) -> &CommunityRoles;
pub fn permissions(&self, member: &PublicKey) -> u64;
pub fn is_staff(&self, member: &PublicKey) -> bool;
pub fn message_expiration(&self) -> Option<u64>;
// authority actions — each returns a publish task and nothing optimistic
pub fn set_metadata(&mut self, meta: CommunityMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_channel(&mut self, name: &str, private: bool, cx: &mut Context<Self>) -> Task<Result<ChannelId, Error>>;
pub fn edit_channel(&mut self, id: &ChannelId, meta: ChannelMetadata, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_role(&mut self, role: Role, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn assign_roles(&mut self, member: &PublicKey, roles: &[[u8; 32]], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn ban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn unban(&mut self, members: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn kick(&mut self, member: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn rekey_channel(&mut self, id: &ChannelId, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn refound(&mut self, removed: &[PublicKey], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn dissolve(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn create_invite(&mut self, params: InviteParams, cx: &mut Context<Self>) -> Task<Result<String, Error>>;
pub fn revoke_invite(&mut self, token: &[u8; 16], cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn direct_invite(&mut self, receiver: &PublicKey, cx: &mut Context<Self>) -> Task<Result<(), Error>>;
pub fn save_community_list(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>>; // kind 13302, multi-device sync
```
**Channel API** (`Entity<Channel>`): `id`, `name`, `private`, `epoch`, `deleted`, plus
```rust
pub fn messages(&self, until: Option<Timestamp>, limit: usize, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>;
pub fn send(&self, content: &str, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn send_file(&self, file: FileAttachment, reply_to: Option<EventId>, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn edit(&self, id: EventId, content: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn delete(&self, id: EventId, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn react(&self, id: EventId, emoji: &str, cx: &App) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
pub fn typing(&self, cx: &App) -> Task<Result<(), Error>>; // kind 23311, ephemeral
pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // vsk 11, PIN_MESSAGES
```
`CommunityEvent` and `ChannelEvent` mirror `ChatEvent`: one variant per thing the UI has to react to (`Updated`, `Members`, `Added`, `Removed`, `Dissolved`, `Error`, plus channel-level `Incoming`, `Reload`).
Every send funnels through one function so the rules cannot drift: it seals and wraps the
rumor, mirrors any NIP-40 `expiration` onto the wrap, publishes via `send_event(..).to(relays)`,
retains the ephemeral wrap key for a later NIP-09 scrub, and echoes its own wrap through the
same ingest path so send-then-read never waits on a relay round-trip.
## 11. Integration with existing crates
1. **`crates/chat/src/lib.rs` — required fix, moved from M2 to the milestone that first subscribes.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. **M2 and M3 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
2. **`desktop/src/main.rs` and `web/src/lib.rs`** — add `concord::init(window, cx)` after `chat::init(window, cx)`.
3. **`Cargo.toml`** — add `hkdf = "0.12"` to `[workspace.dependencies]`; add the crate to `desktop` and `web` dependencies. No other workspace changes.
4. **No changes** to `state`, `person`, `device`, `settings`, `common`, or `ui`.
## 12. Security invariants to test, not to assume
Each of these has burned a real implementation, or is a documented cross-client trap:
- Recompute every rumor id and reject a claimed mismatch; never trust an embedded `id`.
- Require `rumor.pubkey == seal.pubkey`.
- Require the plaintext seal form on Control and the encrypted form on Chat/Guestbook/Rekey — a strict reader must drop a mis-sealed edition rather than fold a chain a later compaction would fork.
- Check `channel` **and** `epoch` against the plane whose key opened the wrap; reject duplicates of either tag.
- Reject duplicate `vsk`/`eid`/`ev`/`ep`/`vac` tags; require decimal-with-no-leading-zeros on every numeric tag. The one exception is `ms`, which takes its first value rather than erroring — see §7 for why rejecting it reached membership.
- Refuse a tombstone whose `eid` is not this community's id.
- Adopt a `control_root` from a Grant only if it derives to the `control_pk` held for that epoch; adopt a rekey blob only if its bound plaintext matches the scope and epoch and its `prevcommit` matches the key currently held.
- Never conclude removal from a partial rekey chunk set.
- Drop guestbook entries more than an hour in the future; treat an out-of-range `ms` as malformed, not as an interpretation opportunity.
- Never honour a Snapshot from anyone but the refounder of that epoch.
- Refuse to write a Pin List from a list the writer could not read.
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
- Lowercase hex only; x-only pubkeys only; no version tag anywhere.
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate.
- **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete honored only from the message's own author.
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, the guestbook's future-clock and Snapshot rules, and the write-side counterparts.
## 13. Milestones
| # | Deliverable | Done when |
| --- | --- | --- |
| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | ✅ `cargo test -p concord` pins every derivation; all labels match Appendix A.6 |
| M1 | `stream.rs` + `store.rs` | ✅ seal/wrap/open round-trips for both seal forms; hostile wraps rejected in the documented order; the local cache reads back with the group key gone |
| M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
| M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order |
| M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test |
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused |
| M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet |
Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone. The sync engine and its GPUI wiring (§10, §11) have no row of their own because they are cross-cutting: every plane they consume has to exist first, so they follow M8, and they are the milestone that applies §11's `chat::handle_notifications` routing fix.
M1 closed with `cargo test -p concord` (5 tests), `cargo clippy -p concord --all-targets`, and `cargo fmt -p concord --check` all clean. `rand` was added to the workspace pinned to the same `0.10.2` instance `nostr` already builds, so `Cargo.lock` gained no package.
M2 closed the same way at 7 tests, with `serde` added to the crate's dependencies (`serde_json` promoted from dev to main for the metadata content types) — `Cargo.lock` gained no package again, only the `concord → serde` edge.
M3 closed at 14 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
M4 closed at 19 tests, again with no dependency change and `Cargo.lock` untouched — relay paging is `Client::fetch_events` over the same `NostrDatabase` the cache already used, so nothing new was needed. New: `src/chat.rs` (the whole channel plane) and, in `src/store.rs`, `backfill` plus the pure `advance` page step it is built from, which is what the paging test drives instead of a socket. `edition::canonical_decimal` became `pub(crate)` so the chat tag grammar shares one decimal check.
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`.
What M4 still defers, and to what: **moderator deletes and the `can_delete` predicate** (M5 — M4 honors a delete only from the message's own author, so a moderator's reach is missing rather than forged); **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8).
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
## 14. Open questions and risks
1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement `13302` per spec, enforce the 50-membership cap and pre-publish size check, and treat `33302` as an interop follow-up. Confirm with Armada before writing the multi-device code.
2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (`community/v2/streamauth`), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation.
3. **`invite_bundle_key` — resolved in M0.** Appendix A.6 was read in full: the raw HKDF output *is* the NIP-44 conversation key, and the derivation is now pinned by a vector.
4. **`pins_locator` has no upstream vector.** Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test.
5. **Relay set.** Up to 5 recommended, and both reads and writes fan out across them. Coop's client is a gossip client with `no_background_refresh`, so community relays must be added explicitly and re-added on metadata change.
6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted.
7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite.
8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4M8 depends on it except the UX of using a remote signer at all.
10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing.
## 15. Test strategy
- **Unit, pure:** derivations against golden vectors, edition hash, fold, coalesce, memberlist, blob codec, caps and rejection paths. These need no GPUI context and should be exhaustive — they are where cross-client divergence is caught.
- **Integration, GPUI:** `TestAppContext` with two registries sharing an in-memory database, driving wraps through the ingest path; timeouts and delays use `cx.background_executor().timer(..)` per the project guidelines, never `smol::Timer`.
- **Round-trip:** every builder paired with its parser, asserting the parse produces the identical structure, including unknown-field round-tripping on all content types.
- **Negative:** every bullet in §12 gets a test that constructs the hostile input and asserts the drop.
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+1 -1
View File
@@ -2,7 +2,7 @@
"id": "aurora", "id": "aurora",
"name": "Aurora", "name": "Aurora",
"author": "Coop", "author": "Coop",
"url": "https://github.com/lumehq/coop", "url": "https://coopchat.xyz",
"light": { "light": {
"background": "#fdfcfeff", "background": "#fdfcfeff",
"surface_background": "#f8f8ffff", "surface_background": "#f8f8ffff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "forest", "id": "forest",
"name": "Forest", "name": "Forest",
"author": "Coop", "author": "Coop",
"url": "https://github.com/lumehq/coop", "url": "https://coopchat.xyz",
"light": { "light": {
"background": "#fbfefcff", "background": "#fbfefcff",
"surface_background": "#f4fbf6ff", "surface_background": "#f4fbf6ff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ocean", "id": "ocean",
"name": "Ocean", "name": "Ocean",
"author": "Coop", "author": "Coop",
"url": "https://github.com/lumehq/coop", "url": "https://coopchat.xyz",
"light": { "light": {
"background": "#fafefeff", "background": "#fafefeff",
"surface_background": "#f2fbfaff", "surface_background": "#f2fbfaff",
+4 -1
View File
@@ -8,5 +8,8 @@ publish.workspace = true
gpui.workspace = true gpui.workspace = true
instant.workspace = true instant.workspace = true
log.workspace = true log.workspace = true
serde.workspace = true
serde_json.workspace = true
ureq.workspace = true
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] } gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
+259 -113
View File
@@ -1,62 +1,99 @@
#![cfg(not(target_arch = "wasm32"))] #![cfg(not(target_arch = "wasm32"))]
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window}; use std::sync::Arc;
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use instant::{Duration, Instant};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration;
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
mod source;
pub use gpui_updater_core::UpdateStatus as AutoUpdateStatus;
const GITEA_API_BASE: &str = "https://git.reya.info/api/v1";
const GITEA_REPO_OWNER: &str = "reya";
const GITEA_REPO_NAME: &str = "coop";
/// Delay before the automatic check that runs on startup.
const AUTO_CHECK_DELAY: Duration = Duration::from_secs(120);
/// How long a failure stays visible before the status reverts to "Up to date".
const ERROR_DISPLAY_DURATION: Duration = Duration::from_secs(5);
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION"; const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
fn get_github_repo_owner() -> String { fn uses_managed_updates() -> bool {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string()) // The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
} std::env::var("FLATPAK_ID").is_ok()
// Allow opting out of in-app updates via an explicit environment variable.
fn get_github_repo_name() -> String { || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string()) // The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
} || std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
fn is_flatpak_installation() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
} }
/// Initialize the auto-update system. /// Initialize the auto-update system.
///
/// Skips initialization when running as a Flatpak (updates are handled by the
/// Flatpak distribution channel). Otherwise creates the global [`AutoUpdater`]
/// entity and schedules a check for updates after a 2-minute delay.
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(window: &mut Window, cx: &mut App) {
if is_flatpak_installation() { if uses_managed_updates() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak"); log::info!(
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
);
return; return;
} }
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(window, cx)), cx); let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let Some(filter) = asset_filter_for(os, arch) else {
log::info!(
"Skipping auto-update initialization: no installable release artifact is published for {os}/{arch}"
);
return;
};
let Ok(version) = Version::parse(env!("CARGO_PKG_VERSION")) else {
log::error!(
"Skipping auto-update initialization: crate version {:?} is not valid semver",
env!("CARGO_PKG_VERSION")
);
return;
};
AutoUpdater::set_global(
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
cx,
);
} }
struct GlobalAutoUpdater(Entity<AutoUpdater>); struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {} impl Global for GlobalAutoUpdater {}
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
/// The global auto-updater entity.
///
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
/// (GitHub repo, Flatpak detection, delayed auto-check).
///
/// Retrieve the global instance via [`AutoUpdater::global`].
pub struct AutoUpdater { pub struct AutoUpdater {
/// The underlying gpui-updater entity that does the heavy lifting. /// The blocking engine, driven on the background executor.
pub updater: Entity<Updater>, engine: Arc<UpdateEngine<GiteaSource>>,
status: UpdateStatus,
/// The newer release found by the last successful check, if any.
available: Option<Release>,
/// Currently running app version. /// Currently running app version.
pub version: Version, pub version: Version,
/// Keeps the observer subscription alive. /// The in-flight check or download, if any.
_subscription: Subscription, task: Option<Task<()>>,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
} }
impl AutoUpdater { impl AutoUpdater {
/// Whether auto-update is available for this installation.
pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some()
}
/// Retrieve the global auto updater instance, if one was initialized.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalAutoUpdater>()
.map(|global| global.0.clone())
}
/// Retrieve the global auto updater instance. /// Retrieve the global auto updater instance.
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone() cx.global::<GlobalAutoUpdater>().0.clone()
@@ -66,92 +103,49 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state)); cx.set_global(GlobalAutoUpdater(state));
} }
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); window: &mut Window,
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config));
let repo_owner = get_github_repo_owner(); // Schedule an auto-check after a 2-minute delay
let repo_name = get_github_repo_name();
let source =
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
_ => "",
});
let updater: Entity<Updater> =
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
// When an update becomes available, automatically download and install it.
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
let status = this.updater.read(cx).status().clone();
if matches!(status, UpdateStatus::Available(_)) {
this.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
if matches!(status, UpdateStatus::Errored(_)) {
this.error_time = Some(Instant::now());
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
this.update(cx, |_this, cx| cx.notify()).ok();
})
.detach();
} else {
this.error_time = None;
}
cx.notify();
});
// Schedule an auto-check after a 2-minute delay (deferred to run at the
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| { cx.defer_in(window, |_this, _window, cx| {
let duration = Duration::from_secs(120);
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await; cx.background_executor().timer(AUTO_CHECK_DELAY).await;
this.update(cx, |this, cx| { this.update(cx, |this, cx| this.check(cx)).ok();
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
})
.ok();
}) })
.detach(); .detach();
}); });
Self { Self {
updater, engine,
status: UpdateStatus::Idle,
available: None,
version, version,
_subscription: subscription, task: None,
error_time: None,
} }
} }
pub fn idle(&self, cx: &App) -> bool { /// Whether nothing is happening, so the UI can hide the status line.
let status = self.updater.read(cx).status(); pub fn idle(&self) -> bool {
if status == &UpdateStatus::Idle { matches!(self.status, UpdateStatus::Idle)
return true;
}
if matches!(status, UpdateStatus::Errored(_))
&& self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
return true;
}
false
} }
pub fn status(&self, cx: &App) -> SharedString { /// Whether a verified update is installed and waiting for a restart.
let status = self.updater.read(cx).status(); pub fn staged(&self) -> bool {
matches!(self.status, UpdateStatus::Staged(_))
}
match status { /// A short, human-readable description of the current status.
UpdateStatus::Idle => "Up to date".into(), pub fn status(&self) -> SharedString {
match &self.status {
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(), UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(), UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => { UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0); let total_mb = total.map(|t| t as f64 / 1_048_576.0);
@@ -165,16 +159,168 @@ impl AutoUpdater {
UpdateStatus::Staged(version) => { UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into() format!("Version {version} ready — restart to apply").into()
} }
UpdateStatus::Errored(msg) => { UpdateStatus::Errored(message) => format!("Update failed: {message}").into(),
if self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
"Up to date".into()
} else {
format!("Update failed: {msg}").into()
} }
} }
/// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
} }
self.set_status(UpdateStatus::Checking, cx);
let engine = self.engine.clone();
self.task = Some(cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { engine.check() })
.await;
this.update(cx, |this, cx| {
this.task = None;
match result {
Ok(Some(release)) => {
log::info!("Update {} is available", release.version);
let version = release.version.clone();
this.available = Some(release);
this.set_status(UpdateStatus::Available(version), cx);
this.download_and_install(cx);
}
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
Err(error) => {
log::warn!("Update check failed: {error}");
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
}
/// Download the available update, verify it, and swap it into place.
fn download_and_install(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
let Some(release) = self.available.clone() else {
return;
};
let engine = self.engine.clone();
self.set_status(
UpdateStatus::Downloading {
downloaded: 0,
total: None,
},
cx,
);
self.task = Some(cx.spawn(async move |this, cx| {
let downloaded = Arc::new(AtomicU64::new(0));
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
let done = Arc::new(AtomicBool::new(false));
let download_task = {
let (engine, release) = (engine.clone(), release.clone());
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
cx.background_executor().spawn(async move {
let result = engine.download(&release, |got, expected| {
downloaded.store(got, Ordering::Relaxed);
total.store(expected.unwrap_or(0), Ordering::Relaxed);
});
done.store(true, Ordering::Relaxed);
result
})
};
loop {
let got = downloaded.load(Ordering::Relaxed);
let total = total.load(Ordering::Relaxed);
this.update(cx, |this, cx| {
this.set_status(
UpdateStatus::Downloading {
downloaded: got,
total: (total != 0).then_some(total),
},
cx,
);
})
.ok();
if done.load(Ordering::Relaxed) {
break;
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
let artifact = match download_task.await {
Ok(artifact) => artifact,
Err(error) => {
log::warn!("Update download failed: {error}");
this.update(cx, |this, cx| {
this.task = None;
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
})
.ok();
return;
}
};
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
let installed = {
let engine = engine.clone();
cx.background_executor()
.spawn(async move { engine.install(&artifact) })
.await
};
this.update(cx, |this, cx| {
this.task = None;
match installed {
Ok(installed) => {
if let Some(path) = installed.restart_path {
cx.set_restart_path(path);
}
let version = release.version.clone();
this.set_status(UpdateStatus::Staged(version), cx);
}
Err(error) => {
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
}
/// Relaunch into the staged update.
pub fn restart(&mut self, cx: &mut Context<Self>) {
if !self.staged() {
log::warn!("Ignoring restart request: no update is staged");
return;
}
cx.restart();
}
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
let errored = matches!(status, UpdateStatus::Errored(_));
self.status = status;
if errored {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |this, cx| {
this.set_status(UpdateStatus::Idle, cx);
})
.ok();
})
.detach();
}
cx.notify();
} }
} }
+369
View File
@@ -0,0 +1,369 @@
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
use serde::Deserialize;
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
const RELEASE_PAGE_SIZE: usize = 20;
/// Which published artifact belongs to a target platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetFilter {
extension: &'static str,
arch: &'static str,
}
impl AssetFilter {
/// Whether `name` is the installable artifact for this target.
fn matches(&self, name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.ends_with(self.extension) && name.contains(self.arch)
}
}
pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
let extension = match os {
"macos" => ".dmg",
"linux" => ".tar.gz",
_ => return None,
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`/`x64`.
("macos", "aarch64") => "aarch64",
("macos", "x86_64") => "x64",
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
("linux", "aarch64") => "aarch64",
("linux", "x86_64") => "x86_64",
_ => return None,
};
Some(AssetFilter { extension, arch })
}
/// Reads releases from a Gitea repository's Releases.
pub struct GiteaSource {
api_base: String,
owner: String,
repo: String,
filter: AssetFilter,
}
impl GiteaSource {
/// Build a source for `owner/repo` on the Gitea instance at `api_base`
/// (e.g. `https://git.reya.info/api/v1`).
pub fn new(
api_base: impl Into<String>,
owner: impl Into<String>,
repo: impl Into<String>,
filter: AssetFilter,
) -> Self {
Self {
api_base: api_base.into().trim_end_matches('/').to_string(),
owner: owner.into(),
repo: repo.into(),
filter,
}
}
fn releases_url(&self) -> String {
format!(
"{}/repos/{}/{}/releases?limit={RELEASE_PAGE_SIZE}",
self.api_base, self.owner, self.repo
)
}
}
impl UpdateSource for GiteaSource {
fn fetch_latest(&self) -> Result<Release> {
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
let release = newest_published(&releases)
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
let asset = release
.assets
.iter()
.find(|asset| self.filter.matches(&asset.name))
.ok_or(Error::NoMatchingAsset {
target_os: std::env::consts::OS,
target_arch: std::env::consts::ARCH,
})?;
// Resolve the published checksum so the engine can reject a truncated or substituted download.
let sha256 = release
.assets
.iter()
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
.map(|sums| http::get_string(&sums.browser_download_url))
.transpose()?
.and_then(|sums| sha256_for(&sums, &asset.name));
Ok(Release {
version: parse_tag(&release.tag_name)?,
notes: release
.body
.clone()
.filter(|body| !body.trim().is_empty())
.or_else(|| release.name.clone()),
asset: Asset {
name: asset.name.clone(),
url: asset.browser_download_url.clone(),
size: asset.size,
},
signature: None,
signature_url: None,
sha256,
})
}
}
fn newest_published(releases: &[GiteaRelease]) -> Option<&GiteaRelease> {
releases
.iter()
.filter(|release| !release.draft && !release.prerelease)
.filter_map(|release| {
parse_tag(&release.tag_name)
.ok()
.map(|version| (version, release))
})
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, release)| release)
}
/// The SHA-256 recorded for `asset_name` in a `shasum`-style checksums file.
fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
sums.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let (hash, path) = (parts.next()?, parts.next()?);
let path = path.strip_prefix('*').unwrap_or(path);
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
(base == asset_name).then(|| hash.to_ascii_lowercase())
})
}
/// A release as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
/// A release asset as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
/// Blocking HTTP helpers for release metadata.
mod http {
use std::time::Duration;
use gpui_updater_core::{Error, Result};
use serde::de::DeserializeOwned;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
fn agent() -> Agent {
Agent::config_builder()
.user_agent(USER_AGENT)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.timeout_resolve(Some(CONNECT_TIMEOUT))
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
.build()
.into()
}
fn get_bytes(url: &str) -> Result<Vec<u8>> {
let mut response = agent().get(url).call().map_err(|error| match error {
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
other => Error::Http(other.to_string()),
})?;
response
.body_mut()
.read_to_vec()
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
}
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
pub(super) fn get_string(url: &str) -> Result<String> {
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use gpui_updater_core::Version;
use super::*;
const PUBLISHED: &[&str] = &[
"Coop_1.0.1_aarch64.dmg",
"Coop_1.0.1_x64.dmg",
"coop-linux-aarch64.tar.gz",
"coop-linux-x86_64.tar.gz",
"coop_1.0.1_aarch64.snap",
"coop_1.0.1_arm64-setup.exe",
"coop_1.0.1_x64-setup.exe",
"coop_1.0.1_x86_64.snap",
"su.reya.coop_aarch64.flatpak",
"su.reya.coop_x86_64.flatpak",
];
fn selected(os: &str, arch: &str) -> Option<&'static str> {
let filter = asset_filter_for(os, arch)?;
PUBLISHED.iter().copied().find(|name| filter.matches(name))
}
#[test]
fn picks_the_artifact_matching_os_and_architecture() {
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
assert_eq!(
selected("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
);
assert_eq!(
selected("linux", "x86_64"),
Some("coop-linux-x86_64.tar.gz")
);
}
#[test]
fn has_no_target_for_windows_or_unknown_platforms() {
assert_eq!(asset_filter_for("windows", "x86_64"), None);
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "riscv64"), None);
}
#[test]
fn ignores_package_formats_and_sidecars_that_are_not_the_artifact() {
let macos = asset_filter_for("macos", "aarch64").unwrap();
assert!(!macos.matches("coop_1.0.1_aarch64.snap"));
assert!(!macos.matches("su.reya.coop_aarch64.flatpak"));
assert!(!macos.matches("Coop_1.0.1_aarch64.dmg.minisig"));
let linux = asset_filter_for("linux", "x86_64").unwrap();
assert!(!linux.matches("coop_1.0.1_x64-setup.exe"));
assert!(!linux.matches("coop_1.0.1_x86_64.snap"));
}
#[test]
fn reads_checksums_by_basename_ignoring_directory_prefix() {
let sums = "\
abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
123456 *linux-x64-artifacts/coop-linux-x86_64.tar.gz
789abc SHA256SUMS
";
assert_eq!(
sha256_for(sums, "Coop_1.0.1_aarch64.dmg").as_deref(),
Some("abcdef")
);
assert_eq!(
sha256_for(sums, "coop-linux-x86_64.tar.gz").as_deref(),
Some("123456")
);
assert_eq!(sha256_for(sums, "coop_1.0.1_x64-setup.exe"), None);
}
#[test]
fn newest_published_skips_drafts_prereleases_and_bad_tags() {
let releases: Vec<GiteaRelease> = serde_json::from_str(
r#"[
{
"tag_name": "v1.0.2",
"draft": true,
"assets": []
},
{
"tag_name": "v2.0.0-rc.1",
"prerelease": true,
"assets": []
},
{
"tag_name": "nightly",
"assets": []
},
{
"tag_name": "v1.0.0",
"assets": [
{
"name": "coop-linux-x86_64.tar.gz",
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.0/coop-linux-x86_64.tar.gz",
"size": 26160329
}
]
},
{
"tag_name": "v1.0.1",
"name": "v1.0.1",
"body": "Fixed app panic on flatpak installations",
"assets": [
{
"name": "coop-linux-x86_64.tar.gz",
"browser_download_url": "https://git.reya.info/reya/coop/releases/download/v1.0.1/coop-linux-x86_64.tar.gz",
"size": 26160329
}
]
}
]"#,
)
.unwrap();
let newest = newest_published(&releases).unwrap();
assert_eq!(newest.tag_name, "v1.0.1");
assert_eq!(parse_tag(&newest.tag_name).unwrap().to_string(), "1.0.1");
assert_eq!(newest.assets.len(), 1);
assert_eq!(newest.assets[0].size, 26160329);
}
#[test]
#[ignore = "requires network access to the release host"]
fn live_release_source_resolves_the_running_platform() {
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
.expect("this platform should be supported");
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
let release = source
.fetch_latest()
.expect("release lookup should succeed");
assert!(
release.version >= Version::new(1, 0, 0),
"unexpected version {}",
release.version
);
assert!(
source.filter.matches(&release.asset.name),
"unexpected artifact {}",
release.asset.name
);
assert!(
release.asset.url.starts_with("https://"),
"{} ",
release.asset.url
);
}
}
+8 -7
View File
@@ -21,6 +21,7 @@ mod room;
pub use message::*; pub use message::*;
pub use room::*; pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -629,7 +630,7 @@ impl ChatRegistry {
/// Load all rooms from the database. /// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) { pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_task(cx); let task = self.query_chat_rooms(cx);
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
@@ -650,8 +651,8 @@ impl ChatRegistry {
})); }));
} }
/// Create a task to load rooms from the database /// Query the chat rooms from the database
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> { fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
@@ -677,7 +678,7 @@ impl ChatRegistry {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::LOWERCASE_K, "14"); .custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
let events = client.database().query(filter).await?; let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new(); let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
@@ -719,8 +720,8 @@ impl ChatRegistry {
/// Parse a nostr event into a message and push it to the belonging room /// Parse a nostr event into a message and push it to the belonging room
/// ///
/// If the room doesn't exist, it will be created. /// - If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages. /// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
Tag::identifier(id), Tag::identifier(id),
Tag::public_key(rumor.pubkey), Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]), Tag::custom("r", [room_id]),
Tag::custom("k", ["14"]), Tag::custom("k", [rumor.kind.to_string()]),
]; ];
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json()) let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
+79 -36
View File
@@ -4,6 +4,9 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls}; use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri}; use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -21,61 +24,90 @@ pub struct Message {
pub mentions: Vec<Mention>, pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
} }
impl From<&Event> for Message { impl From<&Event> for Message {
fn from(val: &Event) -> Self { fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags); val.id,
let (media, string) = extract_and_remove_media_urls(&val.content); val.pubkey,
val.created_at,
Self { val.kind,
id: val.id, &val.content,
author: val.pubkey, &val.tags,
content: string, )
media,
created_at: val.created_at,
mentions,
replies_to,
}
} }
} }
impl From<&UnsignedEvent> for Message { impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self { fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known // Event ID must be known
id: val.id.unwrap(), val.id.unwrap(),
author: val.pubkey, val.pubkey,
content: string, val.created_at,
media, val.kind,
created_at: val.created_at, &val.content,
mentions, &val.tags,
replies_to, )
}
} }
} }
impl From<&NewMessage> for Message { impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self { fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content); from_parts(
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known // Event ID must be known
id: val.rumor.id.unwrap(), val.rumor.id.unwrap(),
author: val.rumor.pubkey, val.rumor.pubkey,
content: string, val.rumor.created_at,
val.rumor.kind,
&val.rumor.content,
&val.rumor.tags,
)
}
}
fn from_parts(
id: EventId,
author: PublicKey,
created_at: Timestamp,
kind: Kind,
content: &str,
tags: &Tags,
) -> Message {
let file = if kind == KIND_FILE_MESSAGE {
FileAttachment::from_tags(content, tags)
} else {
None
};
let has_file = file.is_some();
let replies_to = extract_reply_ids(tags);
// For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file {
Vec::new()
} else {
extract_mentions(content)
};
let (media, content) = if has_file {
(Vec::new(), String::new())
} else {
extract_and_remove_media_urls(content)
};
Message {
id,
author,
content,
media, media,
created_at: val.rumor.created_at, created_at,
mentions, mentions,
replies_to, replies_to,
} file,
} }
} }
@@ -105,6 +137,17 @@ impl Hash for Message {
} }
} }
impl Message {
/// Single-line representation for reply previews, notifications and copy.
pub fn preview(&self) -> SharedString {
if let Some(file) = &self.file {
return format!("[File] {}", file.display_name()).into();
}
self.content.clone().into()
}
}
/// New message. /// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage { pub struct NewMessage {
+46 -15
View File
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; use state::{NostrRegistry, TIMEOUT, UniversalSigner};
use crate::NewMessage; use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available."; const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
@@ -439,12 +439,52 @@ impl Room {
let content: String = content.into(); let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect(); let replies: Vec<EventId> = replies.into_iter().collect();
let persons = PersonRegistry::global(cx); // Get current user's public key
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?;
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(self.conversation_tags(&replies, sender, cx))
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies.into_iter().collect();
// Get current user's public key // Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?; let sender = nostr.read(cx).current_user()?;
let mut tags = self.conversation_tags(&replies, sender, cx);
tags.extend(file.tags());
// Construct a file message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
let persons = PersonRegistry::global(cx);
// Construct event's tags // Construct event's tags
let mut tags = vec![]; let mut tags = vec![];
@@ -454,8 +494,8 @@ impl Room {
} }
// Add all reply tags // Add all reply tags
for id in replies.into_iter() { for id in replies {
tags.push(Tag::event(id)) tags.push(Tag::event(*id))
} }
// Add all receiver tags (no intermediate allocation) // Add all receiver tags (no intermediate allocation)
@@ -467,16 +507,7 @@ impl Room {
})); }));
} }
// Construct a direct message rumor event tags
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
} }
/// Select the appropriate signer based on signer kind and available keys. /// Select the appropriate signer based on signer kind and available keys.
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
config: &SignerKind, config: &SignerKind,
) -> Result<SendReport, Error> { ) -> Result<SendReport, Error> {
let k_tag = Tag::custom("k", vec!["14"]); let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
let mut extra_tags = vec![k_tag]; let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config // Determine the receiver public key based on the config
+32
View File
@@ -0,0 +1,32 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use gpui::SharedString;
use nostr_sdk::prelude::*;
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` is kept around so the composer can preview
/// the file without downloading and decrypting it again.
pub(crate) struct PendingFile {
pub file: FileAttachment,
pub path: PathBuf,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
}
/// A `file://` url for a decrypted file, so it can be opened by the OS
pub(crate) fn file_url(path: &Path) -> String {
format!("file://{}", path.display())
}
+439 -48
View File
@@ -1,10 +1,11 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*; pub use actions::*;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::Error;
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus}; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
use common::{TimestampExt, coop_cache}; use common::TimestampExt;
use futures::lock::Mutex; use futures::lock::Mutex;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -12,8 +13,8 @@ use gpui::{
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg, SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
white, retain_all, svg, white,
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
use regex::Regex; use regex::Regex;
use settings::{AppSettings, SignerKind}; use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload}; use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -30,11 +33,13 @@ use ui::input::{Input, InputEvent, InputState};
use ui::menu::DropdownMenu; use ui::menu::DropdownMenu;
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
use ui::tooltip::Tooltip;
use ui::{ use ui::{
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
h_flex, v_flex, h_flex, v_flex,
}; };
use crate::file::*;
use crate::text::RenderedText; use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
@@ -46,6 +51,7 @@ static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions; mod actions;
mod file;
mod text; mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> { pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -96,6 +102,12 @@ pub struct ChatPanel {
/// Media Attachment /// Media Attachment
attachments: Entity<Vec<Url>>, attachments: Entity<Vec<Url>>,
/// Uploaded, encrypted file attachments which are not sent yet
encrypted_attachments: Entity<Vec<PendingFile>>,
/// Decrypted attachments of file messages, by message id
decrypted_files: HashMap<EventId, DecryptedFile>,
/// Upload state /// Upload state
uploading: bool, uploading: bool,
@@ -110,6 +122,7 @@ impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// Define attachments and replies_to entities // Define attachments and replies_to entities
let attachments = cx.new(|_| vec![]); let attachments = cx.new(|_| vec![]);
let encrypted_attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new()); let replies_to = cx.new(|_| HashSet::new());
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
@@ -185,6 +198,8 @@ impl ChatPanel {
subject_bar, subject_bar,
replies_to, replies_to,
attachments, attachments,
encrypted_attachments,
decrypted_files: HashMap::new(),
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
reports_by_id, reports_by_id,
sent_ids: Arc::new(Mutex::new(Vec::new())), sent_ids: Arc::new(Mutex::new(Vec::new())),
@@ -370,21 +385,32 @@ impl ChatPanel {
} }
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get the message which includes all attachments // Get the message which includes all plain attachments
let content = self.get_input_value(cx); let content = self.get_input_value(cx);
// Get the replies to this message // Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect(); let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty // Uploaded files are sent as encrypted file messages
if content.trim().is_empty() { let files: Vec<FileAttachment> = self
.encrypted_attachments
.read(cx)
.iter()
.map(|pending| pending.file.clone())
.collect();
// Return if there is nothing to send
if content.trim().is_empty() && files.is_empty() {
window.push_notification("Cannot send an empty message", cx); window.push_notification("Cannot send an empty message", cx);
return; return;
} }
// If replying to exactly one message with only a valid emoji, // If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message // send as a reaction instead of a text message
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty() if replies.len() == 1
&& EMOJI_RE.is_match(&content)
&& self.attachments.read(cx).is_empty()
&& files.is_empty()
{ {
for reply in &replies { for reply in &replies {
self.send_reaction(&content, reply, window, cx); self.send_reaction(&content, reply, window, cx);
@@ -393,7 +419,15 @@ impl ChatPanel {
return; return;
} }
self.send_message(&content, replies, false, window, cx); // Send the text part, including the plain attachment urls
if !content.trim().is_empty() {
self.send_message(&content, replies.clone(), false, window, cx);
}
// Send every file as its own encrypted file message
for file in files {
self.send_file(file, replies.clone(), window, cx);
}
} }
fn send_reaction( fn send_reaction(
@@ -426,29 +460,59 @@ impl ChatPanel {
return; return;
} }
let room = self.room.clone();
let content = value.to_string();
let sent_ids = self.sent_ids.clone();
// Upgrade room and create rumor + send task in a single read lock // Upgrade room and create rumor + send task in a single read lock
let Some(room_entity) = room.upgrade() else { let Some(room) = self.room.upgrade() else {
return; return;
}; };
// Create rumor and send task let outcome = room.read_with(cx, |room, cx| {
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { let rumor = room.rumor(value, replies, reaction, cx)?;
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?; let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task)) Some((rumor, send_task))
}) { });
Some(pair) => pair,
None => { match outcome {
window.push_notification("Failed to create message", cx); Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
return; None => window.push_notification("Failed to create message", cx),
} }
}
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
fn send_file(
&mut self,
file: FileAttachment,
replies: Vec<EventId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(room) = self.room.upgrade() else {
return;
}; };
let outcome = room.read_with(cx, |room, cx| {
let rumor = room.file_rumor(file, replies, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
});
match outcome {
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
None => window.push_notification("Failed to create message", cx),
}
}
/// Insert a rumor optimistically and track the send reports of its gift wraps
fn dispatch(
&mut self,
rumor: UnsignedEvent,
send_task: Task<Vec<SendReport>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = rumor.id.expect("rumor must have an id"); let id = rumor.id.expect("rumor must have an id");
let sent_ids = self.sent_ids.clone();
// Insert optimistic message and clear input // Insert optimistic message and clear input
if rumor.kind != Kind::Reaction { if rumor.kind != Kind::Reaction {
@@ -487,6 +551,10 @@ impl ChatPanel {
this.clear(); this.clear();
cx.notify(); cx.notify();
}); });
self.encrypted_attachments.update(cx, |this, cx| {
this.clear();
cx.notify();
});
self.replies_to.update(cx, |this, cx| { self.replies_to.update(cx, |this, cx| {
this.clear(); this.clear();
cx.notify(); cx.notify();
@@ -604,7 +672,7 @@ impl ChatPanel {
let Some(message) = self.message(id) else { let Some(message) = self.message(id) else {
return; return;
}; };
let content = message.content.to_string(); let content = message.preview().to_string();
let item = ClipboardItem::new_string(content); let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item); cx.write_to_clipboard(item);
@@ -630,6 +698,9 @@ impl ChatPanel {
// Get the user's configured blossom server // Get the user's configured blossom server
let server = AppSettings::get_file_server(cx); let server = AppSettings::get_file_server(cx);
// Encrypt attachments which are not part of a message being written
let encrypted = self.input.read(cx).value().trim().is_empty();
// Ask user for file upload // Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions { let path = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
@@ -639,36 +710,95 @@ impl ChatPanel {
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| { // Selecting no file means the prompt was cancelled
this.set_uploading(true, cx); let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update_in(cx, |this, window, cx| {
this.upload_file(server, path, encrypted, window, cx);
})?; })?;
let mut paths = path.await??.context("Not found")?; Ok(())
let path = paths.pop().context("No path")?; }));
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
})?;
} }
Err(e) => {
/// Upload a file, encrypted when the attachment is the whole message
fn upload_file(
&mut self,
server: Url,
path: PathBuf,
encrypted: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_uploading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = if encrypted {
upload_encrypted(server.clone(), path.clone(), cx)
.await
.map(|file| Uploaded::File(file, path.clone()))
} else {
upload(server.clone(), path.clone(), cx)
.await
.map(Uploaded::Url)
};
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx); this.set_uploading(false, cx);
match result {
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
Err(e) if encrypted => {
this.report_encrypted_upload_error(server, path, e, window, cx)
}
Err(e) => {
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
cx, cx,
); );
}
}
})?; })?;
}
}
Ok(()) Ok(())
})); }));
} }
/// Report a failed encrypted upload, offering to retry it without encryption
fn report_encrypted_upload_error(
&mut self,
server: Url,
path: PathBuf,
error: Error,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity().downgrade();
window.push_notification(
Notification::error(error.to_string())
.title("Encrypted upload failed")
.action(move |_this, _window, _cx| {
let view = view.clone();
let server = server.clone();
let path = path.clone();
Button::new("retry-without-encryption")
.label("Upload without encryption")
.on_click(move |_ev, window, cx| {
view.update(cx, |this, cx| {
this.upload_file(server.clone(), path.clone(), false, window, cx);
})
.ok();
})
}),
cx,
);
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) { fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading; self.uploading = uploading;
cx.notify(); cx.notify();
@@ -690,6 +820,88 @@ impl ChatPanel {
}); });
} }
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
this.push(PendingFile { file, path });
cx.notify();
});
}
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
self.encrypted_attachments.update(cx, |this, cx| {
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
this.remove(ix);
cx.notify();
}
});
}
/// Download and decrypt the attachment of a file message for preview
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn(async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update(cx, |this, cx| {
match result {
Ok(path) => {
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
/// Decrypt the attachment of a file message and open it with the OS
fn open_file(
&mut self,
id: EventId,
file: FileAttachment,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.decrypted_files.get(&id) {
Some(DecryptedFile::Ready(path)) => {
cx.open_url(&file_url(path));
return;
}
Some(DecryptedFile::Loading) => return,
_ => {}
};
self.decrypted_files.insert(id, DecryptedFile::Loading);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = download_and_decrypt_to_file(&file, cx).await;
this.update_in(cx, |this, _window, cx| {
match result {
Ok(path) => {
cx.open_url(&file_url(&path));
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
}
Err(e) => {
this.decrypted_files
.insert(id, DecryptedFile::Failed(e.to_string().into()));
}
}
cx.notify();
})?;
Ok(())
}));
}
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx) persons.read(cx).get(public_key, cx)
@@ -929,6 +1141,16 @@ impl ChatPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let file = self.messages.get(ix).and_then(|message| {
let file = message.file.clone()?;
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
.then_some((message.id, file))
});
if let Some((id, file)) = file {
self.load_file(id, file, cx);
}
if let Some(message) = self.messages.get(ix) { if let Some(message) = self.messages.get(ix) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let show_author = self.is_group_start(ix); let show_author = self.is_group_start(ix);
@@ -936,7 +1158,7 @@ impl ChatPanel {
.rendered_texts_by_id .rendered_texts_by_id
.entry(message.id) .entry(message.id)
.or_insert_with(|| { .or_insert_with(|| {
RenderedText::new(&message.content, &message.mentions, &persons, cx) RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
}) })
.element(ix.into(), window, cx); .element(ix.into(), window, cx);
@@ -1016,8 +1238,11 @@ impl ChatPanel {
.when(has_replies, |this| { .when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx)) this.children(self.render_message_replies(replies, cx))
}) })
.child(rendered_text) .when(message.file.is_none(), |this| this.child(rendered_text))
.child(self.render_media(&message.media, cx)) .child(self.render_media(&message.media, cx))
.when_some(message.file.as_ref(), |this, file| {
this.child(self.render_message_file(&id, file, cx))
})
.when(has_reactions, |this| { .when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx)) this.child(self.render_reactions(&id, cx))
}), }),
@@ -1123,7 +1348,7 @@ impl ChatPanel {
.w_full() .w_full()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&message.content)), .child(message.preview()),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click({ .on_click({
@@ -1427,7 +1652,7 @@ impl ChatPanel {
.size_16() .size_16()
.when(cx.theme().shadow, |this| this.shadow_lg()) .when(cx.theme().shadow, |this| this.shadow_lg())
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.object_fit(ObjectFit::ScaleDown), .object_fit(ObjectFit::Cover),
) )
.child( .child(
div() div()
@@ -1464,6 +1689,159 @@ impl ChatPanel {
items items
} }
/// Render the encrypted file attachment of a message
fn render_message_file(
&self,
id: &EventId,
file: &FileAttachment,
cx: &Context<Self>,
) -> AnyElement {
let state = self.decrypted_files.get(id);
if let Some(path) = state
.and_then(|state| match state {
DecryptedFile::Ready(path) => Some(path),
_ => None,
})
.filter(|_| file.is_image())
{
return div()
.child(
img(path.clone())
.border_1()
.border_color(cx.theme().border_variant)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
)
.into_any_element();
}
let label = match state {
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
Some(DecryptedFile::Failed(error)) => error.clone(),
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
None => SharedString::from("Click to decrypt"),
};
self.render_file_chip(id, file, label, cx)
}
/// Render an encrypted file as a chip which decrypts and opens it on click
fn render_file_chip(
&self,
id: &EventId,
file: &FileAttachment,
label: SharedString,
cx: &Context<Self>,
) -> AnyElement {
h_flex()
.id(SharedString::from(format!("file-{id}")))
.self_start()
.items_start()
.min_w_0()
.gap_2()
.p_2()
.border_1()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius)
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
.child(
v_flex()
.min_w_0()
.overflow_hidden()
.text_sm()
.child(div().line_height(relative(1.2)).child(file.display_name()))
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(label),
),
)
.on_click({
let file = file.clone();
let id = *id;
cx.listener(move |this, _, window, cx| {
this.open_file(id, file.clone(), window, cx);
})
})
.into_any_element()
}
/// Render an uploaded, encrypted file which is not sent yet
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
let file = &pending.file;
let label = file.display_name();
div()
.id(SharedString::from(file.url.to_string()))
.relative()
.w_16()
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
.map(|this| {
if file.is_image() {
this.child(
img(pending.path.clone())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.object_fit(ObjectFit::Cover),
)
} else {
this.child(
div()
.size_16()
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border_variant)
.bg(cx.theme().surface_background)
.text_xs()
.text_center()
.child("Preview not available"),
)
}
})
.child(
v_flex()
.absolute()
.top_neg_1()
.right_neg_1()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.border_1()
.border_color(cx.theme().border_variant)
.bg(gpui::green())
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
)
.on_click({
let url = file.url.clone();
cx.listener(move |this, _, _, cx| {
this.remove_pending_file(&url, cx);
})
})
}
fn render_pending_file_list(
&self,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoIterator<Item = impl IntoElement> {
let mut items = vec![];
for pending in self.encrypted_attachments.read(cx).iter() {
items.push(self.render_pending_file(pending, cx));
}
items
}
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement { fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) { if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -1512,7 +1890,7 @@ impl ChatPanel {
.text_sm() .text_sm()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&text.content)), .child(text.preview()),
) )
} else { } else {
div() div()
@@ -1640,8 +2018,13 @@ impl Focusable for ChatPanel {
impl Render for ChatPanel { impl Render for ChatPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
let is_typing = !self.input.read(cx).value().trim().is_empty();
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
v_flex() v_flex()
.image_cache(coop_cache(self.id.clone(), 100)) .image_cache(retain_all(self.id.clone()))
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.size_full() .size_full()
.when(*self.subject_bar.read(cx), |this| { .when(*self.subject_bar.read(cx), |this| {
@@ -1673,10 +2056,8 @@ impl Render for ChatPanel {
.map(|this| { .map(|this| {
if self.messages.is_empty() { if self.messages.is_empty() {
this.child( this.child(
div() h_flex()
.size_full() .size_full()
.flex()
.items_center()
.justify_end() .justify_end()
.child(self.render_announcement(cx)), .child(self.render_announcement(cx)),
) )
@@ -1701,7 +2082,17 @@ impl Render for ChatPanel {
.w_full() .w_full()
.gap_1p5() .gap_1p5()
.children(self.render_attachment_list(window, cx)) .children(self.render_attachment_list(window, cx))
.children(self.render_pending_file_list(window, cx))
.children(self.render_reply_list(window, cx)) .children(self.render_reply_list(window, cx))
.when(is_typing && pending_attachments, |this| {
this.child(
div()
.px_1()
.text_xs()
.text_color(cx.theme().text_warning)
.child(WARNING),
)
})
.child( .child(
h_flex() h_flex()
.items_end() .items_end()
+120 -31
View File
@@ -1,17 +1,19 @@
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::{Arc, LazyLock};
use chat::Mention; use chat::Mention;
use common::RangeExt;
use gpui::{ use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
}; };
use person::PersonRegistry; use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme; use theme::ActiveTheme;
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight { pub enum Highlight {
Code, Code,
@@ -39,25 +41,61 @@ impl RenderedText {
content: &str, content: &str,
mentions: &[Mention], mentions: &[Mention],
persons: &Entity<PersonRegistry>, persons: &Entity<PersonRegistry>,
markdown: bool,
cx: &App, cx: &App,
) -> Self {
Self::render(content, mentions, markdown, |mention| {
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
})
}
fn render(
content: &str,
mentions: &[Mention],
markdown: bool,
resolve_mention: impl Fn(&Mention) -> String,
) -> Self { ) -> Self {
let mut text = String::new(); let mut text = String::new();
let mut highlights = Vec::new(); let mut highlights = Vec::new();
let mut link_ranges = Vec::new(); let mut link_ranges = Vec::new();
let mut link_urls = Vec::new(); let mut link_urls = Vec::new();
render_plain_text_mut( render_text_mut(
content, content,
mentions, mentions,
&mut text, &mut text,
&mut highlights, &mut highlights,
&mut link_ranges, &mut link_ranges,
&mut link_urls, &mut link_urls,
persons, markdown,
cx, resolve_mention,
); );
text.truncate(text.trim_end().len()); // Trim trailing whitespace and adjust highlight and link ranges.
let trimmed_len = text.trim_end().len();
// Retain highlights and link ranges that are within the trimmed text.
if trimmed_len < text.len() {
highlights.retain_mut(|(range, _)| {
range.end = range.end.min(trimmed_len);
range.start < range.end
});
let mut ix = 0;
while ix < link_ranges.len() {
let range = &mut link_ranges[ix];
range.end = range.end.min(trimmed_len);
if range.start < range.end {
ix += 1;
} else {
link_ranges.remove(ix);
link_urls.remove(ix);
}
}
text.truncate(trimmed_len);
}
RenderedText { RenderedText {
text: SharedString::from(text), text: SharedString::from(text),
@@ -70,10 +108,18 @@ impl RenderedText {
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background; let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent; let color = cx.theme().text_accent;
let code_font = if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
};
InteractiveText::new( InteractiveText::new(
id, id,
StyledText::new(self.text.clone()).with_default_highlights( StyledText::new(self.text.clone())
.with_default_highlights(
&window.text_style(), &window.text_style(),
self.highlights.iter().map(|(range, highlight)| { self.highlights.iter().map(|(range, highlight)| {
( (
@@ -112,13 +158,21 @@ impl RenderedText {
}, },
) )
}), }),
), )
.with_font_family_overrides(self.highlights.iter().filter_map(
|(range, highlight)| match highlight {
Highlight::Code | Highlight::InlineCode(_) => {
Some((range.clone(), code_font.into()))
}
_ => None,
},
)),
) )
.on_click(self.link_ranges.clone(), { .on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone(); let link_urls = self.link_urls.clone();
move |ix, _, cx| { move |ix, _, cx| {
let url = &link_urls[ix]; let url = &link_urls[ix];
if url.starts_with("http") { if WEB_URL.is_match(url) {
cx.open_url(url); cx.open_url(url);
} }
} }
@@ -128,15 +182,15 @@ impl RenderedText {
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_plain_text_mut( fn render_text_mut(
block: &str, block: &str,
mut mentions: &[Mention], mut mentions: &[Mention],
text: &mut String, text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>, highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>, link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>, link_urls: &mut Vec<String>,
persons: &Entity<PersonRegistry>, markdown: bool,
cx: &App, resolve_mention: impl Fn(&Mention) -> String,
) { ) {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
@@ -145,34 +199,58 @@ fn render_plain_text_mut(
let mut strikethrough_depth = 0; let mut strikethrough_depth = 0;
let mut link_url = None; let mut link_url = None;
let mut list_stack = Vec::new(); let mut list_stack = Vec::new();
let mut code_block = false;
let mut options = Options::all(); // Only enable the extensions that make sense for chat messages. Notably this leaves
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST); // out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
} else {
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
};
for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() { for (event, source_range) in events {
let prev_len = text.len(); let prev_len = text.len();
match event { match event {
Event::Text(t) => { Event::Text(t) => {
// Process text with mention replacements if code_block {
text.push_str(t.as_ref());
highlights.push((prev_len..text.len(), Highlight::Code));
continue;
}
let t_str = t.as_ref(); let t_str = t.as_ref();
let mut last_processed = 0; let mut last_processed = 0;
while let Some(mention) = mentions.first() { while let Some(mention) = mentions.first() {
if !source_range.contains_inclusive(&mention.range) { if mention.range.start >= source_range.end {
break; break;
} }
// Calculate positions within the current text mentions = &mentions[1..];
let mention_start_in_text = mention.range.start - source_range.start; if mention.range.start < source_range.start
let mention_end_in_text = mention.range.end - source_range.start; || mention.range.end > source_range.end
{
continue;
}
let Some(token) = block.get(mention.range.clone()) else {
continue;
};
let Some(offset) = t_str[last_processed..].find(token) else {
continue;
};
let mention_start_in_text = last_processed + offset;
let mention_end_in_text = mention_start_in_text + token.len();
// Add text before this mention // Add text before this mention
if mention_start_in_text > last_processed { if mention_start_in_text > last_processed {
let before_mention = &t_str[last_processed..mention_start_in_text]; let before_mention = &t_str[last_processed..mention_start_in_text];
process_text_segment( process_text_segment(
before_mention, before_mention,
prev_len + last_processed,
bold_depth, bold_depth,
italic_depth, italic_depth,
strikethrough_depth, strikethrough_depth,
@@ -185,9 +263,7 @@ fn render_plain_text_mut(
} }
// Process the mention replacement // Process the mention replacement
let profile = persons.read(cx).get(&mention.public_key, cx); let replacement_text = resolve_mention(mention);
let replacement_text = format!("@{}", profile.name());
let replacement_start = text.len(); let replacement_start = text.len();
text.push_str(&replacement_text); text.push_str(&replacement_text);
let replacement_end = text.len(); let replacement_end = text.len();
@@ -195,7 +271,6 @@ fn render_plain_text_mut(
highlights.push((replacement_start..replacement_end, Highlight::Mention)); highlights.push((replacement_start..replacement_end, Highlight::Mention));
last_processed = mention_end_in_text; last_processed = mention_end_in_text;
mentions = &mentions[1..];
} }
// Add any remaining text after the last mention // Add any remaining text after the last mention
@@ -203,7 +278,6 @@ fn render_plain_text_mut(
let remaining_text = &t_str[last_processed..]; let remaining_text = &t_str[last_processed..];
process_text_segment( process_text_segment(
remaining_text, remaining_text,
prev_len + last_processed,
bold_depth, bold_depth,
italic_depth, italic_depth,
strikethrough_depth, strikethrough_depth,
@@ -234,11 +308,14 @@ fn render_plain_text_mut(
} }
Tag::CodeBlock(_kind) => { Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack); new_paragraph(text, &mut list_stack);
code_block = true;
} }
Tag::Emphasis => italic_depth += 1, Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1, Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1, Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()), Tag::Link { dest_url, .. } => {
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
}
Tag::List(number) => { Tag::List(number) => {
list_stack.push((number, false)); list_stack.push((number, false));
} }
@@ -264,6 +341,7 @@ fn render_plain_text_mut(
_ => {} _ => {}
}, },
Event::End(tag) => match tag { Event::End(tag) => match tag {
TagEnd::CodeBlock => code_block = false,
TagEnd::Heading(_) => bold_depth -= 1, TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1, TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1, TagEnd::Strong => bold_depth -= 1,
@@ -272,6 +350,11 @@ fn render_plain_text_mut(
TagEnd::List(_) => drop(list_stack.pop()), TagEnd::List(_) => drop(list_stack.pop()),
_ => {} _ => {}
}, },
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
Event::Rule => {
new_paragraph(text, &mut list_stack);
text.push_str("────────\n");
}
Event::HardBreak => text.push('\n'), Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'), Event::SoftBreak => text.push('\n'),
_ => {} _ => {}
@@ -282,7 +365,6 @@ fn render_plain_text_mut(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn process_text_segment( fn process_text_segment(
segment: &str, segment: &str,
segment_start: usize,
bold_depth: i32, bold_depth: i32,
italic_depth: i32, italic_depth: i32,
strikethrough_depth: i32, strikethrough_depth: i32,
@@ -307,7 +389,8 @@ fn process_text_segment(
}); });
} }
// Add the text // Ranges always refer to the rendered text, including replaced mentions.
let segment_start = text.len();
text.push_str(segment); text.push_str(segment);
let text_end = text.len(); let text_end = text.len();
@@ -330,7 +413,10 @@ fn process_text_segment(
finder.kinds(&[linkify::LinkKind::Url]); finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0; let mut last_link_pos = 0;
for link in finder.links(segment) { for link in finder
.links(segment)
.filter(|link| WEB_URL.is_match(link.as_str()))
{
let start = link.start(); let start = link.start();
let end = link.end(); let end = link.end();
@@ -375,6 +461,7 @@ fn process_text_segment(
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) { fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false; let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() { if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content { if *has_content {
is_subsequent_paragraph_of_list = true; is_subsequent_paragraph_of_list = true;
@@ -390,9 +477,11 @@ fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
} }
text.push('\n'); text.push('\n');
} }
for _ in 0..list_stack.len().saturating_sub(1) { for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" "); text.push_str(" ");
} }
if is_subsequent_paragraph_of_list { if is_subsequent_paragraph_of_list {
text.push_str(" "); text.push_str(" ");
} }
-135
View File
@@ -1,135 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use futures::FutureExt;
use gpui::{
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
};
pub fn coop_cache(id: impl Into<ElementId>, max_items: usize) -> CoopImageCacheProvider {
CoopImageCacheProvider {
id: id.into(),
max_items,
}
}
pub struct CoopImageCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for CoopImageCacheProvider {
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |id, window| {
window.with_element_state(id, |cache, _| {
let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx));
(cache.clone(), cache)
})
})
.into()
}
}
pub struct CoopImageCache {
max_items: usize,
usage_list: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl CoopImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
log::info!("Creating CoopImageCache");
cx.on_release(|this: &mut Self, cx| {
for (ix, (mut image, resource)) in take(&mut this.cache) {
if let Some(Ok(image)) = image.get() {
log::info!("Dropping image {ix}");
cx.drop_image(image, None);
}
ImageSource::Resource(resource).remove_asset(cx);
}
})
.detach();
CoopImageCache {
max_items,
usage_list: VecDeque::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
})
}
}
impl ImageCache for CoopImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_idx = self
.usage_list
.iter()
.position(|item| *item == hash)
.expect("cache has an item usage_list doesn't");
self.usage_list.remove(current_idx);
self.usage_list.push_front(hash);
return item.0.get();
}
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(load_future).shared();
if self.usage_list.len() >= self.max_items {
log::info!("Image cache is full, evicting oldest item");
if let Some(oldest) = self.usage_list.pop_back() {
let mut image = self
.cache
.remove(&oldest)
.expect("usage_list has an item cache doesn't");
if let Some(Ok(image)) = image.0.get() {
log::info!("requesting image to be dropped");
cx.drop_image(image, Some(window));
}
ImageSource::Resource(image.1).remove_asset(cx);
}
}
self.cache.insert(
hash,
(
gpui::ImageCacheItem::Loading(task.clone()),
resource.clone(),
),
);
self.usage_list.push_front(hash);
let entity = window.current_view();
window
.spawn(cx, async move |cx| {
let result = task.await;
if let Err(err) = result {
log::error!("error loading image into cache: {:?}", err);
}
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
})
.detach();
None
}
}
-2
View File
@@ -1,4 +1,3 @@
pub use caching::*;
pub use debounced_delay::*; pub use debounced_delay::*;
pub use display::*; pub use display::*;
pub use event::*; pub use event::*;
@@ -7,7 +6,6 @@ pub use parser::*;
pub use paths::*; pub use paths::*;
pub use range::*; pub use range::*;
mod caching;
mod debounced_delay; mod debounced_delay;
mod display; mod display;
mod event; mod event;
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "concord"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr.workspace = true
nostr-sdk.workspace = true
hkdf.workspace = true
sha2.workspace = true
data-encoding.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+854
View File
@@ -0,0 +1,854 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::derive::channel_group_key;
use crate::edition::canonical_decimal;
use crate::stream::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
wrap_seal,
};
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
pub const KIND_MESSAGE: u16 = 9;
pub const KIND_COMMENT: u16 = 1111;
pub const KIND_REACTION: u16 = 7;
pub const KIND_DELETE: u16 = 5;
pub const KIND_EDIT: u16 = 3302;
pub const KIND_FILE: u16 = 15;
pub const KIND_WEBXDC: u16 = 3310;
pub const KIND_TYPING: u16 = 23311;
const TAG_QUOTE: &str = "q";
const TAG_TARGET: &str = "e";
const TAG_TARGET_KIND: &str = "k";
const TAG_ROOT: &str = "E";
const TAG_ROOT_KIND: &str = "K";
const TAG_ROOT_AUTHOR: &str = "P";
const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChatError::Stream(error) => write!(f, "stream: {error}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(error: StreamError) -> Self {
ChatError::Stream(error)
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef {
pub id: EventId,
pub author: Option<PublicKey>,
}
/// A reference that also names the referenced event's kind, which a comment
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub reply: ReplyRef,
pub kind: u16,
}
#[derive(Debug, Clone)]
pub enum ChatAction {
Message {
reply_to: Option<ReplyRef>,
thread_root: Option<ReplyRef>,
},
Reaction {
target: EventId,
emoji: String,
},
Edit {
target: EventId,
content: String,
},
Delete {
target: EventId,
target_kind: Option<u16>,
},
Typing,
Opaque,
}
#[derive(Debug, Clone)]
pub struct ChatRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub channel: ChannelId,
pub epoch: Epoch,
pub at_ms: u64,
pub content: String,
pub expiration: Option<Timestamp>,
pub action: ChatAction,
}
/// A channel's timeline row, with every edit, delete and reaction folded in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatMessage {
pub id: EventId,
pub author: PublicKey,
pub channel: ChannelId,
pub epoch: Epoch,
pub kind: Kind,
pub content: String,
pub reply_to: Option<EventId>,
pub thread_root: Option<EventId>,
pub at_ms: u64,
pub expiration: Option<Timestamp>,
pub edited_at: Option<u64>,
pub deleted: bool,
pub reactions: BTreeMap<PublicKey, String>,
}
pub fn build_message(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
content: &str,
quote: Option<&ReplyRef>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
if let Some(quote) = quote {
tags.push(reply_tag(TAG_QUOTE, quote));
}
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
}
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
/// immutable root; `None` means the parent is itself the root.
pub fn build_comment(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
content: &str,
parent: &Target,
root: Option<&Target>,
at_ms: u64,
) -> UnsignedEvent {
let root = root.unwrap_or(parent);
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
tags.push(reply_tag(TAG_ROOT, &root.reply));
if let Some(root_author) = root.reply.author {
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
}
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
tags.push(reply_tag(TAG_TARGET, &parent.reply));
if let Some(parent_author) = parent.reply.author {
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
}
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
}
pub fn build_reaction(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: &Target,
emoji: &str,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()]));
if let Some(target_author) = target.reply.author {
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()]));
}
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
}
pub fn build_edit(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: EventId,
content: &str,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
}
pub fn build_delete(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: EventId,
target_kind: Option<u16>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
if let Some(target_kind) = target_kind {
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
}
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
}
pub fn build_typing(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
at_ms: u64,
) -> UnsignedEvent {
build_rumor_ms(
KIND_TYPING,
author,
"",
channel_binding_tags(channel, epoch),
at_ms,
)
}
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
/// the 21059 wrap, which relays must not store.
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL
} else {
KIND_WRAP
};
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
// stored event on schedule; the inner copy is what drives a local purge.
let expiration: Vec<Tag> = rumor
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION))
.cloned()
.collect();
Ok(wrap_seal(
&seal,
group,
wrap_kind,
rumor.created_at,
&expiration,
)?)
}
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
/// rumor claims must both be the ones that opened it, so a keyholder of two
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
pub fn open(
wrap: &Event,
group: &GroupKey,
channel: &ChannelId,
epoch: Epoch,
) -> Result<(OpenedStream, ChatRumor), ChatError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(ChatError::NotEncryptedSealed);
}
check_channel_binding(&opened.rumor, channel, epoch)?;
let chat = typed(&opened.rumor, channel, epoch)?;
Ok((opened, chat))
}
/// Every epoch's group key for one channel. `secret` is whatever feeds the
/// channel at that epoch: the `community_root` for a public one, its own key
/// for a private one.
pub fn plane_keys(
held: &[(Epoch, [u8; 32])],
channel: &ChannelId,
) -> Result<Vec<(Epoch, GroupKey)>> {
held.iter()
.map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?)))
.collect()
}
/// Folds the chat plane into timeline rows, newest first. A delete is honored
/// only from the message's own author, and a deletion is terminal: an edit or a
/// reaction arriving later never revives it.
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
let mut messages: Vec<ChatMessage> = Vec::new();
let mut slot: BTreeMap<EventId, usize> = BTreeMap::new();
for index in order {
let rumor = &rumors[index];
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
continue;
};
slot.insert(rumor.id, messages.len());
messages.push(ChatMessage {
id: rumor.id,
author: rumor.author,
channel: rumor.channel,
epoch: rumor.epoch,
kind: rumor.kind,
content: rumor.content.clone(),
reply_to: reply_to.map(|reply| reply.id),
thread_root: thread_root.map(|reply| reply.id),
at_ms: rumor.at_ms,
expiration: rumor.expiration,
edited_at: None,
deleted: false,
reactions: BTreeMap::new(),
});
}
// Mutations replay so the last one applied is the winner: the highest
// `at_ms` and, between equal ones, the lower inner rumor id.
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
for index in mutations {
let rumor = &rumors[index];
match &rumor.action {
ChatAction::Edit { target, content } => {
let Some(&slot) = slot.get(target) else {
continue;
};
let message = &mut messages[slot];
if message.deleted || message.author != rumor.author {
continue;
}
message.content = content.clone();
message.edited_at = Some(rumor.at_ms);
}
ChatAction::Delete { target, .. } => {
let Some(&slot) = slot.get(target) else {
continue;
};
if messages[slot].author == rumor.author {
messages[slot].deleted = true;
}
}
ChatAction::Reaction { target, emoji } => {
let Some(&slot) = slot.get(target) else {
continue;
};
messages[slot].reactions.insert(rumor.author, emoji.clone());
}
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
}
}
messages.sort_by_key(|message| (Reverse(message.at_ms), message.id));
messages
}
fn is_chat_kind(kind: u16) -> bool {
matches!(
kind,
KIND_MESSAGE
| KIND_COMMENT
| KIND_REACTION
| KIND_DELETE
| KIND_EDIT
| KIND_FILE
| KIND_WEBXDC
| KIND_TYPING
)
}
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
Ok(ChatRumor {
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
author: rumor.pubkey,
kind: rumor.kind,
channel: *channel,
epoch,
at_ms: resolve_ms_strict(rumor)?,
content: rumor.content.clone(),
expiration: expiration_of(rumor)?,
action: action_of(rumor)?,
})
}
fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
let kind = rumor.kind.as_u16();
match kind {
KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message {
reply_to: optional_reply(rumor, TAG_QUOTE)?,
thread_root: None,
}),
KIND_COMMENT => Ok(ChatAction::Message {
reply_to: optional_reply(rumor, TAG_TARGET)?,
thread_root: optional_reply(rumor, TAG_ROOT)?,
}),
KIND_REACTION => Ok(ChatAction::Reaction {
target: required_id(rumor, TAG_TARGET)?,
emoji: rumor.content.clone(),
}),
KIND_EDIT => Ok(ChatAction::Edit {
target: required_id(rumor, TAG_TARGET)?,
content: rumor.content.clone(),
}),
KIND_DELETE => Ok(ChatAction::Delete {
target: required_id(rumor, TAG_TARGET)?,
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_WEBXDC => Ok(ChatAction::Opaque),
other => Err(ChatError::UnknownKind(other)),
}
}
fn optional_reply(
rumor: &UnsignedEvent,
name: &'static str,
) -> Result<Option<ReplyRef>, ChatError> {
let Some(fields) = tag(rumor, name)? else {
return Ok(None);
};
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the
// referenced author at index 3, which is a SHOULD, so absent reads as unknown.
let author = match fields.get(3).map(String::as_str) {
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
_ => None,
};
Ok(Some(ReplyRef {
id: hex_id(fields, name)?,
author,
}))
}
fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result<EventId, ChatError> {
let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
hex_id(fields, name)
}
fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16>, ChatError> {
let Some(fields) = tag(rumor, name)? else {
return Ok(None);
};
let raw = value(fields, name)?;
let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?;
u16::try_from(kind)
.map(Some)
.map_err(|_| ChatError::BadTag(name))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
};
let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?)
.ok_or(ChatError::BadTag(TAG_EXPIRATION))?;
Ok(Some(Timestamp::from_secs(seconds)))
}
fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
Tag::custom(
name,
[
reply.id.to_hex(),
String::new(),
reply
.author
.map(|author| author.to_hex())
.unwrap_or_default(),
],
)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
fields
.get(1)
.map(String::as_str)
.ok_or(ChatError::BadTag(name))
}
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
fn channel() -> ChannelId {
ChannelId::from_bytes([0x9cu8; 32])
}
fn group() -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
}
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0
}
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
open(&sealed(rumor, group, author), group, &channel(), epoch)
.expect("opens")
.1
}
fn target(id: EventId, author: &Keys) -> Target {
Target {
reply: ReplyRef {
id,
author: Some(author.public_key()),
},
kind: KIND_MESSAGE,
}
}
#[test]
fn a_second_holder_folds_edits_reactions_and_a_self_delete() {
let alice = Keys::generate();
let carol = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let id = message.compute_id();
let rumors = vec![
read(&message, &group, &alice, Epoch(0)),
read(
&build_reaction(
carol.public_key(),
&channel(),
Epoch(0),
&target(id, &alice),
"🔥",
AT + 1_000,
),
&group,
&carol,
Epoch(0),
),
read(
&build_edit(
alice.public_key(),
&channel(),
Epoch(0),
id,
"hello (fixed)",
AT + 2_000,
),
&group,
&alice,
Epoch(0),
),
read(
&build_delete(
alice.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
AT + 3_000,
),
&group,
&alice,
Epoch(0),
),
];
let folded = fold(&rumors);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
assert_eq!(folded[0].content, "hello (fixed)");
assert_eq!(folded[0].edited_at, Some(AT + 2_000));
assert_eq!(
folded[0].reactions.get(&carol.public_key()),
Some(&"🔥".to_owned())
);
assert!(folded[0].deleted);
}
#[test]
fn an_edit_or_delete_from_another_author_is_ignored() {
let alice = Keys::generate();
let bob = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let id = message.compute_id();
let rumors = vec![
read(&message, &group, &alice, Epoch(0)),
read(
&build_edit(
bob.public_key(),
&channel(),
Epoch(0),
id,
"mine now",
AT + 1_000,
),
&group,
&bob,
Epoch(0),
),
read(
&build_delete(
bob.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
AT + 2_000,
),
&group,
&bob,
Epoch(0),
),
];
let folded = fold(&rumors);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
assert_eq!(folded[0].edited_at, None);
assert!(!folded[0].deleted);
}
#[test]
fn a_comment_carries_its_root_and_its_parent() {
let alice = Keys::generate();
let bob = Keys::generate();
let group = group();
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
let root_id = root.compute_id();
let parent = build_message(
bob.public_key(),
&channel(),
Epoch(0),
"parent",
None,
AT + 1_000,
);
let parent_id = parent.compute_id();
let comment = build_comment(
alice.public_key(),
&channel(),
Epoch(0),
"deep",
&target(parent_id, &bob),
Some(&target(root_id, &alice)),
AT + 2_000,
);
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
assert!(
comment
.tags
.iter()
.any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() })
);
assert!(
comment
.tags
.iter()
.any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() })
);
let rumor = read(&comment, &group, &alice, Epoch(0));
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
panic!("a comment is a message row")
};
assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id));
assert_eq!(thread_root.map(|root| root.id), Some(root_id));
}
#[test]
fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() {
let alice = Keys::generate();
let group = group();
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
assert!(
open(
&sealed(&plain, &group, &alice),
&group,
&channel(),
Epoch(0)
)
.is_ok()
);
// The keyholder re-addresses their own rumor: the binding is judged
// against the plane whose key opened the wrap, never the rumor's claim.
let elsewhere = ChannelId::from_bytes([0xeeu8; 32]);
assert!(matches!(
open(
&sealed(&plain, &group, &alice),
&group,
&elsewhere,
Epoch(0)
),
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
assert!(matches!(
open(
&sealed(&stale, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::Stream(StreamError::EpochMismatch))
));
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(AT / 1000),
&[],
)
.expect("wraps");
assert!(matches!(
open(&wrap, &group, &channel(), Epoch(0)),
Err(ChatError::NotEncryptedSealed)
));
let ghost = build_rumor_ms(
3300,
alice.public_key(),
"v1 ghost",
channel_binding_tags(&channel(), Epoch(0)),
AT,
);
assert!(matches!(
seal_rumor(&ghost, &group, &alice, false),
Err(ChatError::UnknownKind(3300))
));
let mut tags = channel_binding_tags(&channel(), Epoch(0));
tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)]));
let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT);
assert!(matches!(
open(
&sealed(&ambiguous, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::DuplicateTag(TAG_TARGET))
));
}
}
+814
View File
@@ -0,0 +1,814 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use crate::derive::{
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
pub const MAX_RELAYS: usize = 5;
pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0);
const GENESIS_VERSION: u64 = 1;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ImageRef {
pub url: String,
pub key: String,
pub nonce: String,
pub hash: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityMetadata {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChannelMetadata {
pub name: String,
pub private: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityIdentity {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
}
impl CommunityIdentity {
pub fn verify(&self) -> bool {
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
}
}
#[derive(Debug, Clone)]
pub struct CommunityGenesis {
pub identity: CommunityIdentity,
pub community_root: [u8; 32],
pub control_root: [u8; 32],
pub channel_id: ChannelId,
pub wraps: Vec<Event>,
}
pub fn genesis(
owner: &Keys,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
owner_salt,
};
let community_root = random_32()?;
let control_root = random_32()?;
let channel_id = ChannelId::from_bytes(random_32()?);
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
let channel_content = serde_json::to_string(&ChannelMetadata {
name: GENERAL_CHANNEL.to_owned(),
private: false,
..ChannelMetadata::default()
})?;
let editions = [
build_edition(EditionFields {
author: identity.owner,
subkind: vsk::COMMUNITY_METADATA,
entity: *identity.community_id.as_bytes(),
version: GENESIS_VERSION,
prev: None,
citation: None,
content: &metadata_content,
at_secs,
}),
build_edition(EditionFields {
author: identity.owner,
subkind: vsk::CHANNEL_METADATA,
entity: *channel_id.as_bytes(),
version: GENESIS_VERSION,
prev: None,
citation: None,
content: &channel_content,
at_secs,
}),
];
let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
}
Ok(CommunityGenesis {
identity,
community_root,
control_root,
channel_id,
wraps,
})
}
/// Opens a Control Plane wrap from its reading key alone.
pub fn open_edition(
wrap: &Event,
read: &GroupKey,
address: &PublicKey,
verify_wrap_signature: bool,
) -> Result<ParsedEdition> {
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
if opened.seal_form != SealForm::Plaintext {
bail!("control editions require a plaintext seal");
}
Ok(parse_edition(&opened.rumor)?)
}
/// Appends editions to entity chains.
pub struct ControlWriter {
pub author: PublicKey,
pub read: GroupKey,
pub signer: GroupKey,
}
pub struct Edition<'a> {
pub subkind: &'a str,
pub entity: [u8; 32],
pub content: &'a str,
/// The head this edition supersedes.
///
/// `None` starts the chain.
pub head: Option<&'a EntityHead>,
pub citation: Option<AuthorityCitation>,
}
impl ControlWriter {
pub fn publish(
&self,
keys: &Keys,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
entity: edition.entity,
version: edition
.head
.map_or(GENESIS_VERSION, |head| head.version + 1),
prev: edition.head.map(|head| head.self_hash),
citation: edition.citation,
content: edition.content,
at_secs,
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
&self,
keys: &Keys,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
pub fn set_channel_metadata(
&self,
keys: &Keys,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::CHANNEL_METADATA,
entity: *channel.as_bytes(),
content: &content,
head,
citation: None,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
Ok(serde_json::to_string(&metadata)?)
}
#[derive(Debug, Clone, Default)]
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
floors: &Floors,
held_bans: &BTreeSet<PublicKey>,
) -> ControlFold {
let authority: Vec<AuthorityEdition> = editions
.iter()
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
.collect();
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
let mut floors = roster.floors;
floors.extend(metadata.floors);
ControlFold {
roles: roster.roles,
banned: roster.banned,
community: metadata.community,
channels: metadata.channels,
floors,
gapped: roster.gapped || metadata.gapped,
}
}
#[derive(Debug, Default)]
struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
floors: Floors,
gapped: bool,
}
fn fold_metadata(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
roster: &Roster,
floors: &Floors,
) -> MetadataFold {
let judge = Judge {
owner,
community_id,
roster,
floors,
};
let community_entity = *community_id.as_bytes();
let mut community: Vec<&ParsedEdition> = Vec::new();
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
match edition.subkind.as_str() {
// A channel addressed at the community's own coordinate would share, and
// corrupt, the metadata chain's floor.
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
community.push(edition)
}
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
channels.entry(edition.entity).or_default().push(edition);
}
_ => {}
}
}
let mut fold = MetadataFold::default();
if let Some(head) = authorized_head(
&judge,
community_entity,
&community,
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.floors.insert(head.entity, EntityHead::from(head));
}
for (entity, candidates) in &channels {
let Some(head) = authorized_head(
&judge,
*entity,
candidates,
Permissions::MANAGE_CHANNELS,
&mut fold.gapped,
) else {
continue;
};
fold.floors.insert(*entity, EntityHead::from(head));
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
fold.channels
.insert(ChannelId::from_bytes(*entity), metadata);
}
}
fold
}
struct Judge<'a> {
owner: &'a PublicKey,
community_id: &'a CommunityId,
roster: &'a Roster,
floors: &'a Floors,
}
fn authorized_head<'a>(
judge: &Judge<'_>,
entity: [u8; 32],
candidates: &[&'a ParsedEdition],
permission: u64,
gapped: &mut bool,
) -> Option<&'a ParsedEdition> {
let authorized: Vec<&ParsedEdition> = candidates
.iter()
.copied()
.filter(|edition| {
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
!judge.roster.banned.contains(&edition.author)
&& judge
.roster
.roles
.is_authorized(&edition.author, judge.owner, permission)
&& citation_ok(
judge.owner,
judge.community_id,
&edition.author,
edition.citation.as_ref(),
&judge.roster.floors,
)
})
.collect();
if authorized.is_empty() {
return None;
}
let metas: Vec<EditionMeta> = authorized
.iter()
.map(|edition| EditionMeta::from(*edition))
.collect();
let selection = fold_head(&metas, judge.floors.get(&entity));
*gapped |= selection.gap;
selection.head.map(|index| authorized[index])
}
fn seal_edition(
edition: &UnsignedEvent,
owner: &Keys,
read: &GroupKey,
signer: &GroupKey,
at_secs: u64,
) -> Result<Event> {
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
let (wrap, _) = wrap_seal_with(
&seal,
read.conversation(),
signer.keys(),
KIND_WRAP,
Timestamp::from_secs(at_secs),
&[],
)?;
Ok(wrap)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::derive::grant_locator;
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
let community_id = minted.identity.community_id;
(
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
.expect("derives"),
)
}
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
wraps
.iter()
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
.collect()
}
fn metadata(name: &str) -> CommunityMetadata {
CommunityMetadata {
name: name.to_owned(),
..CommunityMetadata::default()
}
}
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
let community = &editions[0];
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
assert_eq!(community.author, owner.public_key());
assert_eq!((community.version, community.prev), (1, None));
assert_eq!(
serde_json::from_str::<CommunityMetadata>(&community.content)
.expect("parses")
.name,
"coop"
);
let channel = &editions[1];
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
for edition in &editions {
let folded = fold(&[EditionMeta::from(edition)], 0, None);
assert_eq!(folded.head, Some(0));
assert!(
folded.anchored && !folded.gap,
"genesis anchors at its floor"
);
}
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
save_state(&database, &state).await.expect("saves");
let loaded = load_state(&database, &minted.identity.community_id)
.await
.expect("loads")
.expect("present");
assert_eq!(loaded.community_root, minted.community_root);
assert_eq!(loaded.control_root, Some(minted.control_root));
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.heads.len(), 2);
});
}
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
let roster = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
roster.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
let channel_head = roster
.floors
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
AT + 2,
)
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
&[community_wrap, channel_wrap],
&read,
&signer.pk(),
));
let folded = fold_control(
&owner_pk,
&community_id,
&edited,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop two")
);
assert_eq!(
folded
.channels
.get(&minted.channel_id)
.map(|channel| channel.name.as_str()),
Some("lobby")
);
// A relay serving only the editions a client already folded past must not walk
// the community backwards.
let stale = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&folded.floors,
&BTreeSet::new(),
);
assert!(stale.community.is_none());
assert!(stale.channels.is_empty());
let mut state =
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
state.apply_fold(&folded);
assert_eq!(state.channels.len(), 1);
assert_eq!(state.channels[0].name, "lobby");
assert_eq!(state.relays.len(), 1);
}
#[test]
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let role_id = RoleId::from_bytes([0x07; 32]);
let role = Role {
role_id,
name: "Mod".to_owned(),
position: 1,
permissions: Permissions(Permissions::MANAGE_METADATA),
scope: RoleScope::Server,
color: 0,
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
content: &Grant {
member: member.public_key(),
role_ids: vec![role_id],
control_wrap: None,
extra: Extra::default(),
}
.to_content()
.expect("serializes"),
head: None,
citation: None,
},
AT + 2,
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
let roster = fold_control(
&owner_pk,
&community_id,
&base,
&Floors::new(),
&BTreeSet::new(),
);
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
let grant = roster
.floors
.get(&grant_locator(
&community_id,
&member.public_key().to_bytes(),
))
.expect("the member's grant folded");
let head = roster.floors.get(community_id.as_bytes()).expect("head");
// The member seals with their own keys and wraps with the staff write key.
let member_writer = ControlWriter {
author: member.public_key(),
read,
signer: signer.clone(),
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&forged,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let mut edited_editions = base;
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&edited_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop by mod")
);
}
}
+451
View File
@@ -0,0 +1,451 @@
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use anyhow::{Result, bail};
use hkdf::Hkdf;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
use sha2::{Digest, Sha256};
use crate::{ChannelId, CommunityId, Epoch};
pub const TOKEN_LEN: usize = 16;
const LABEL_CHANNEL: &str = "concord/channel";
const LABEL_CONTROL: &str = "concord/control";
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
const LABEL_GUESTBOOK: &str = "concord/guestbook";
const LABEL_DISSOLVED: &str = "concord/dissolved";
const LABEL_GRANT: &str = "concord/grant";
const LABEL_BANLIST: &str = "concord/banlist";
const LABEL_PINS: &str = "concord/pins";
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
const LABEL_INVITE_KEY: &str = "concord/invite-key";
const LABEL_COMMUNITY: &str = "concord/community";
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
const ZERO32: [u8; 32] = [0u8; 32];
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
info.extend_from_slice(label.as_bytes());
info.push(0x00);
info.extend_from_slice(id32);
if let Some(epoch) = epoch {
info.extend_from_slice(&epoch.to_be_bytes());
}
info
}
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let mut okm = [0u8; 32];
Hkdf::<Sha256>::new(None, ikm)
.expand(info, &mut okm)
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
okm
}
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
return Ok(secret_key);
}
for counter in 0u8..=u8::MAX {
let mut info = Vec::with_capacity(base_info.len() + 1);
info.extend_from_slice(base_info);
info.push(counter);
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
return Ok(secret_key);
}
}
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
}
#[derive(Clone)]
pub struct GroupKey {
keys: Keys,
conversation: ConversationKey,
}
impl GroupKey {
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
let key = memo_key(label, secret, id32, epoch);
if let Some(hit) = lock_memo().get(&key) {
return Ok(hit.clone());
}
let info = build_info(label, id32, epoch);
let secret_key = hkdf_to_secret_key(secret, &info)?;
let keys = Keys::new(secret_key);
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
let group_key = Self { keys, conversation };
let mut memo = lock_memo();
if memo.len() >= 1024 {
memo.clear();
}
memo.insert(key, group_key.clone());
Ok(group_key)
}
pub fn pk(&self) -> PublicKey {
self.keys.public_key()
}
pub fn pk_hex(&self) -> String {
self.keys.public_key().to_hex()
}
pub fn keys(&self) -> &Keys {
&self.keys
}
pub fn conversation(&self) -> &ConversationKey {
&self.conversation
}
}
impl std::fmt::Debug for GroupKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupKey")
.field("pk", &self.pk_hex())
.finish()
}
}
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn clear_memo() {
lock_memo().clear()
}
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(label.as_bytes());
hasher.update([0x00]);
hasher.update(secret);
hasher.update(id32);
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
hasher.update([epoch.is_some() as u8]);
hasher.finalize().into()
}
/// `secret` is the `community_root` for a public channel.
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
}
/// The plane's read key: its conversation key encrypts the wraps for every member.
pub fn control_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// The plane's address and wrap signer, held only by staff.
/// Wraps still encrypt under [`control_group_key`].
pub fn control_signer_group_key(
control_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL_SIGNER,
control_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Member-writable, unlike the Control Plane:
///
/// - A join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_GUESTBOOK,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Keyed by the prior `community_root` rather than the channel key,
/// so any retained member recovers any epoch's rekey without a ratchet.
pub fn channel_rekey_group_key(
prior_root: &[u8; 32],
channel: &ChannelId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_REKEY_PSEUDONYM,
prior_root,
channel.as_bytes(),
Some(new_epoch.0),
)
}
pub fn base_rekey_group_key(
prior_root: &[u8; 32],
community_id: &CommunityId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_BASE_REKEY_PSEUDONYM,
prior_root,
community_id.as_bytes(),
Some(new_epoch.0),
)
}
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
}
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
let mut hasher = Sha256::new();
hasher.update(LABEL_COMMUNITY.as_bytes());
hasher.update(owner_xonly);
hasher.update(owner_salt);
CommunityId::from_bytes(hasher.finalize().into())
}
pub fn verify_community_id(
community_id: &CommunityId,
owner_xonly: &[u8; 32],
owner_salt: &[u8; 32],
) -> bool {
community_id_of(owner_xonly, owner_salt) == *community_id
}
/// The continuity a rekey blob must satisfy against the key currently held.
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
hasher.update(previous_epoch.0.to_be_bytes());
hasher.update(previous_key);
hasher.finalize().into()
}
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_GRANT, member_xonly, None),
)
}
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_BANLIST, &ZERO32, None),
)
}
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_PINS, channel.as_bytes(), None),
)
}
/// Bound to the creator, so each creator owns exactly their own registry.
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
)
}
/// Built from public inputs only, so a locator match proves nothing about authenticity
pub fn recipient_locator(
rotator_xonly: &[u8; 32],
recipient_xonly: &[u8; 32],
scope_id: &[u8; 32],
new_epoch: Epoch,
) -> [u8; 32] {
let mut ikm = [0u8; 64];
ikm[..32].copy_from_slice(rotator_xonly);
ikm[32..].copy_from_slice(recipient_xonly);
hkdf32(
&ikm,
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
)
}
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
}
#[cfg(test)]
mod tests {
use super::*;
const CHANNEL_E0_SEED: &str =
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
const CHANNEL_EMULTI_PK: &str =
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
const CONTROL_SIGNER_E0_SEED: &str =
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
const CONTROL_SIGNER_E0_PK: &str =
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
const CONTROL_SIGNER_EMULTI_PK: &str =
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
const GUESTBOOK_E0_PK: &str =
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
const CHANNEL_REKEY_E1_PK: &str =
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
const BASE_REKEY_E1_PK: &str =
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
const BANLIST_LOCATOR: &str =
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
const INVITE_LINKS_LOCATOR: &str =
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
const RECIPIENT_LOCATOR: &str =
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
const EPOCH_COMMITMENT: &str =
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
const EPOCH_MULTI: u64 = 0x0102030405060708;
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
fn secret() -> [u8; 32] {
let mut key = [0u8; 32];
for (index, byte) in key.iter_mut().enumerate() {
*byte = index as u8;
}
key
}
fn id32() -> [u8; 32] {
let mut id = [0u8; 32];
for (index, byte) in id.iter_mut().enumerate() {
*byte = 255 - index as u8;
}
id
}
fn hex(bytes: &[u8]) -> String {
data_encoding::HEXLOWER.encode(bytes)
}
#[test]
fn golden_vectors() {
let secret = secret();
let id = id32();
let alt = [0x11u8; 32];
let community_id = CommunityId::from_bytes(id);
let channel = ChannelId::from_bytes(id);
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
assert_eq!(
hex(channel_e0.keys().secret_key().as_secret_bytes()),
CHANNEL_E0_SEED
);
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
assert_eq!(
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CHANNEL_EMULTI_PK
);
assert_eq!(
control_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
CONTROL_E0_PK
);
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
assert_eq!(
hex(signer.keys().secret_key().as_secret_bytes()),
CONTROL_SIGNER_E0_SEED
);
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
assert_eq!(
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CONTROL_SIGNER_EMULTI_PK
);
assert_eq!(
guestbook_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
GUESTBOOK_E0_PK
);
assert_eq!(
channel_rekey_group_key(&secret, &channel, Epoch(1))
.expect("derives")
.pk_hex(),
CHANNEL_REKEY_E1_PK
);
assert_eq!(
base_rekey_group_key(&secret, &community_id, Epoch(1))
.expect("derives")
.pk_hex(),
BASE_REKEY_E1_PK
);
assert_eq!(
dissolved_group_key(&community_id)
.expect("derives")
.pk_hex(),
DISSOLVED_PK
);
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
assert_eq!(
hex(&invite_links_locator(&community_id, &alt)),
INVITE_LINKS_LOCATOR
);
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
assert_eq!(
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
RECIPIENT_LOCATOR
);
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
assert_eq!(
hex(&epoch_key_commitment(Epoch(2), &secret)),
EPOCH_COMMITMENT
);
}
}
+559
View File
@@ -0,0 +1,559 @@
use std::collections::BTreeMap;
use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decode_hex_32;
use crate::stream::build_rumor_secs;
pub const KIND_CONTROL: u16 = 3308;
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
/// Entity types an edition can address (CORD-02 Appendix B).
pub mod vsk {
pub const COMMUNITY_METADATA: &str = "0";
pub const ROLE: &str = "1";
pub const CHANNEL_METADATA: &str = "2";
pub const GRANT: &str = "3";
pub const BANLIST: &str = "4";
pub const INVITE_LIVE: &str = "6";
pub const INVITE_LINKS: &str = "8";
pub const INVITE_REVOKED: &str = "9";
pub const DISSOLVED: &str = "10";
pub const PINS: &str = "11";
}
const TAG_SUBKIND: &str = "vsk";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
const TAG_CITATION: &str = "vac";
#[derive(Debug)]
pub enum EditionError {
BadKind(u16),
BadField(&'static str),
Duplicate(&'static str),
Missing(&'static str),
}
impl fmt::Display for EditionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
}
}
}
impl std::error::Error for EditionError {}
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
/// coordinate, version and hash. It is a sync floor, not the verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
pub version: u64,
pub hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct ParsedEdition {
pub author: PublicKey,
pub subkind: String,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: String,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
pub struct EditionFields<'a> {
pub author: PublicKey,
pub subkind: &'a str,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: &'a str,
pub at_secs: u64,
}
fn signing_bytes(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> Vec<u8> {
let mut bytes =
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
bytes.extend_from_slice(EDITION_LABEL);
bytes.extend_from_slice(entity);
bytes.extend_from_slice(&version.to_be_bytes());
match prev {
Some(prev) => {
bytes.push(1);
bytes.extend_from_slice(prev);
}
None => {
bytes.push(0);
bytes.extend_from_slice(&[0u8; 32]);
}
}
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
bytes.extend_from_slice(content);
bytes
}
pub fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
];
if let Some(prev) = fields.prev {
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
}
if let Some(citation) = fields.citation {
tags.push(Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
));
}
build_rumor_secs(
KIND_CONTROL,
fields.author,
fields.content,
tags,
fields.at_secs,
)
}
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
let kind = rumor.kind.as_u16();
if kind != KIND_CONTROL {
return Err(EditionError::BadKind(kind));
}
let subkind = value(rumor, TAG_SUBKIND)?
.ok_or(EditionError::Missing(TAG_SUBKIND))?
.to_owned();
if canonical_decimal(&subkind).is_none() {
return Err(EditionError::BadField(TAG_SUBKIND));
}
let entity = hex32(
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
TAG_ENTITY,
)?;
let version =
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
.ok_or(EditionError::BadField(TAG_VERSION))?;
let prev = match value(rumor, TAG_PREV)? {
Some(raw) => Some(hex32(raw, TAG_PREV)?),
None => None,
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION)?,
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
hash: hex32(&fields[3], TAG_CITATION)?,
}),
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
None => None,
};
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
Ok(ParsedEdition {
author: rumor.pubkey,
subkind,
entity,
version,
prev,
citation,
content: rumor.content.clone(),
self_hash,
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionMeta {
pub version: u64,
pub self_hash: [u8; 32],
pub prev: Option<[u8; 32]>,
pub tiebreak_id: EventId,
}
impl From<&ParsedEdition> for EditionMeta {
fn from(edition: &ParsedEdition) -> Self {
Self {
version: edition.version,
self_hash: edition.self_hash,
prev: edition.prev,
tiebreak_id: edition.rumor_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.reduce(|(best_index, best), (index, candidate)| {
let supersedes = candidate.version > best.version
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
if supersedes {
(index, candidate)
} else {
(best_index, best)
}
})
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
if raw.len() > 1 && raw.starts_with('0') {
return None;
}
raw.parse().ok()
}
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
}
fn fields<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, EditionError> {
let mut found: Option<&[String]> = None;
for tag in rumor.tags.iter() {
let tag_fields = tag.as_slice();
if tag_fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(EditionError::Duplicate(name));
}
found = Some(tag_fields);
}
Ok(found)
}
fn value<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a str>, EditionError> {
match fields(rumor, name)? {
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
Some(_) => Err(EditionError::BadField(name)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];
assert_eq!(
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
);
// The golden vector only exercises the absent-prev encoding; pin the
// present-prev branch structurally so a swapped flag stays visible.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(
bytes.len(),
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
);
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
assert_eq!(
bytes[8 + EDITION_LABEL.len() + 32..][..8],
1u64.to_be_bytes()
);
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}
+150
View File
@@ -0,0 +1,150 @@
pub mod chat;
pub mod control;
pub mod derive;
pub mod edition;
pub mod roles;
pub mod store;
pub mod stream;
use std::fmt;
use std::str::FromStr;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
pub use derive::GroupKey;
use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
HEXLOWER.encode(&self.0)
}
}
impl From<[u8; 32]> for $name {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl FromStr for $name {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
Ok(Self(decode_hex_32(value)?))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
};
}
hex_id! {
/// A self-certifying commitment to the owner's key, carried inside invites and
/// never on the wire.
CommunityId
}
hex_id! {
ChannelId
}
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
pub struct Epoch(pub u64);
impl From<u64> for Epoch {
fn from(value: u64) -> Self {
Self(value)
}
}
impl From<Epoch> for u64 {
fn from(value: Epoch) -> Self {
value.0
}
}
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
}
Ok(decoded)
}
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
SysRng
.try_fill_bytes(bytes)
.map_err(|error| anyhow!("os rng: {error}"))
}
pub(crate) fn random_32() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
fill_random(&mut bytes)?;
Ok(bytes)
}
File diff suppressed because it is too large Load Diff
+501
View File
@@ -0,0 +1,501 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::chat::{self, ChatRumor, plane_keys};
use crate::control::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::derive::control_signer_group_key;
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
use crate::stream::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const MAX_PAGES: usize = 8;
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<()> {
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where
D: NostrDatabase,
{
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match database.query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
pub async fn backfill(
client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<ChatRumor>> {
let planes = plane_keys(held, channel)?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
let mut cursor = until;
let mut seen: BTreeSet<EventId> = BTreeSet::new();
let mut found: Vec<ChatRumor> = Vec::new();
for _ in 0..MAX_PAGES {
let page = fetch_page(client, &authors, cursor, limit).await?;
if page.is_empty() {
break;
}
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
cache_rumor(database, channel, &opened).await?;
found.push(rumor);
}
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
found.truncate(limit);
Ok(found)
}
fn advance(
page: &BTreeSet<Event>,
planes: &[(Epoch, GroupKey)],
channel: &ChannelId,
cursor: Option<Timestamp>,
limit: usize,
seen: &mut BTreeSet<EventId>,
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
let mut fresh = Vec::new();
for wrap in page {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((opened, rumor)) = chat::open(wrap, group, channel, *epoch) else {
continue;
};
if seen.insert(rumor.id) {
fresh.push((opened, rumor));
}
}
if fresh.is_empty() || page.len() < limit {
return (fresh, None);
}
let oldest = page.iter().map(|event| event.created_at).min();
match oldest {
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
_ => (fresh, None),
}
}
async fn fetch_page(
client: &Client,
authors: &[PublicKey],
until: Option<Timestamp>,
limit: usize,
) -> Result<BTreeSet<Event>> {
let mut filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors.iter().copied())
.limit(limit);
if let Some(until) = until {
filter = filter.until(until);
}
Ok(client.fetch_events(filter).await?)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::Epoch;
use crate::chat::{build_message, seal_rumor};
use crate::derive::channel_group_key;
use crate::stream::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
/// What a relay does with an inclusive `until` and a `limit`.
fn serve_page(
relay: &BTreeSet<Event>,
cursor: Option<Timestamp>,
limit: usize,
) -> BTreeSet<Event> {
let mut events: Vec<Event> = relay
.iter()
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
.cloned()
.collect();
events.sort_by_key(|event| Reverse(event.created_at));
events.truncate(limit);
events.into_iter().collect()
}
#[test]
fn history_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
let planes = plane_keys(&held, &channel).expect("derives");
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
let mut relay: BTreeSet<Event> = BTreeSet::new();
for (content, secret, epoch, at_ms) in [
("before the rekey", &SECRET, Epoch(0), base),
("still before", &SECRET, Epoch(0), base + 1_000),
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(author.public_key(), &channel, epoch, content, None, at_ms);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
}
let mut seen = BTreeSet::new();
let mut found = Vec::new();
let mut cursor = None;
for _ in 0..3 {
let page = serve_page(&relay, cursor, 2);
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
assert_eq!(
contents,
["after the rekey", "still before", "before the rekey"]
);
}
#[test]
fn rumors_read_back_after_a_restart() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0xabu8; 32]);
let author = Keys::generate();
smol::block_on(async {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens");
cache_rumor(&database, &channel, &opened)
.await
.expect("caches");
}
// The group key is gone; only the local cache stands in for it.
let rumors = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(rumors.len(), 2, "both messages come back");
assert_eq!(rumors[0].content, "second", "newest first");
assert_eq!(rumors[1].content, "first");
// A page boundary in message time, not in cache time.
let until = Timestamp::from_secs(1_500);
let page = query_rumors(&database, &channel, Some(until), 10)
.await
.expect("queries");
assert_eq!(page.len(), 1);
assert_eq!(page[0].content, "first");
let capped = query_rumors(&database, &channel, None, 1)
.await
.expect("queries");
assert_eq!(capped.len(), 1);
assert_eq!(capped[0].content, "second");
});
}
}
+673
View File
@@ -0,0 +1,673 @@
use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
UnsignedEvent,
};
use crate::derive::GroupKey;
use crate::{ChannelId, Epoch};
pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms";
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm {
Encrypted,
Plaintext,
}
impl SealForm {
pub fn kind(self) -> u16 {
match self {
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
}
}
fn from_kind(kind: u16) -> Option<Self> {
match kind {
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
_ => None,
}
}
}
#[derive(Debug)]
pub enum StreamError {
Sign(String),
Encrypt(String),
Decrypt(String),
Parse(String),
Oversize(usize),
BadWrapKind(u16),
WrongStream,
BadWrapSignature,
BadSealKind(u16),
BadSealSignature,
AuthorMismatch,
BadRumorId,
BadMs,
ChannelMismatch,
EpochMismatch,
MissingTag(&'static str),
DuplicateTag(&'static str),
NotRewrappable,
}
impl fmt::Display for StreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StreamError::Sign(error) => write!(f, "sign: {error}"),
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
StreamError::Parse(error) => write!(f, "parse: {error}"),
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
}
}
}
impl std::error::Error for StreamError {}
#[derive(Debug, Clone)]
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
pub rumor: UnsignedEvent,
}
pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
/// Build a rumor carrying a full epoch-ms time: `created_at`
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
pub fn build_rumor_ms(
kind: u16,
author: PublicKey,
content: &str,
mut tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let (seconds, offset) = split_ms(at_ms);
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
build_rumor_secs(kind, author, content, tags, seconds)
}
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
pub fn build_rumor_secs(
kind: u16,
author: PublicKey,
content: &str,
tags: Vec<Tag>,
at_secs: u64,
) -> UnsignedEvent {
let mut rumor = UnsignedEvent::new(
author,
Timestamp::from_secs(at_secs),
Kind::Custom(kind),
tags,
content,
);
rumor.ensure_id();
rumor
}
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
let mut tag: Option<Option<String>> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) == Some(TAG_MS) {
tag = Some(fields.get(1).cloned());
break;
}
}
let Some(raw) = tag else {
return Ok(seconds);
};
let raw = raw.ok_or(StreamError::BadMs)?;
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(StreamError::BadMs);
}
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
return Err(StreamError::BadMs);
}
Ok(seconds.saturating_add(offset))
}
pub fn seal_content(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
) -> Result<String, StreamError> {
let json = rumor.as_json();
check_plaintext_cap(json.len())?;
match form {
SealForm::Plaintext => Ok(json),
SealForm::Encrypted => Ok(BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?)),
}
}
pub fn build_seal(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.map_err(|error| StreamError::Sign(error.to_string()))
}
pub fn wrap_seal(
seal: &Event,
group: &GroupKey,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
wrap_seal_with(
seal,
group.conversation(),
group.keys(),
wrap_kind,
at,
extra,
)
}
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,
signer: &Keys,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
let json = seal.as_json();
check_plaintext_cap(json.len())?;
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
let ephemeral = Keys::generate();
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
tags.extend_from_slice(extra);
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
.tags(tags)
.custom_created_at(at)
.finalize(signer)
.map_err(|error| StreamError::Sign(error.to_string()))?;
Ok((wrap, ephemeral))
}
pub fn rewrap_seal(
seal: &Event,
new_group: &GroupKey,
at: Timestamp,
) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable);
}
wrap_seal(seal, new_group, KIND_WRAP, at, &[])
}
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
}
/// Open and verify a wrap against a stream read view: the address to check and
/// the conversation key that opens the wraps, with no signing secret required.
pub fn open_wrap_at(
wrap: &Event,
address: &PublicKey,
conversation: &ConversationKey,
verify_wrap_signature: bool,
) -> Result<OpenedStream, StreamError> {
let wrap_kind = wrap.kind.as_u16();
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
if wrap.pubkey != *address {
return Err(StreamError::WrongStream);
}
if verify_wrap_signature && wrap.verify().is_err() {
return Err(StreamError::BadWrapSignature);
}
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
.map_err(|error| StreamError::Parse(error.to_string()))?;
let seal_kind = seal.kind.as_u16();
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
let rumor_json = match seal_form {
SealForm::Plaintext => seal.content.clone(),
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
};
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
.map_err(|error| StreamError::Parse(error.to_string()))?;
if rumor.pubkey != seal.pubkey {
return Err(StreamError::AuthorMismatch);
}
let computed = rumor.compute_id();
if let Some(claimed) = rumor.id
&& claimed != computed
{
return Err(StreamError::BadRumorId);
}
rumor.id = Some(computed);
let at_ms = resolve_ms_strict(&rumor)?;
Ok(OpenedStream {
rumor_id: computed,
author: seal.pubkey,
seal_form,
seal,
wrapper_id: wrap.id,
at_ms,
rumor,
})
}
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
vec![
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
]
}
pub fn check_channel_binding(
rumor: &UnsignedEvent,
channel: &ChannelId,
epoch: Epoch,
) -> Result<(), StreamError> {
match unique_tag(rumor, TAG_CHANNEL)? {
Some(value) if value == channel.to_hex() => {}
Some(_) => return Err(StreamError::ChannelMismatch),
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
}
match unique_tag(rumor, TAG_EPOCH)? {
Some(value) if value == epoch.0.to_string() => {}
Some(_) => return Err(StreamError::EpochMismatch),
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
}
Ok(())
}
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
let mut nonce = [0u8; 32];
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
let payload = BASE64
.decode(content.as_bytes())
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
let plaintext = decrypt_to_bytes(conversation, &payload)
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
}
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
if len > NIP44_MAX_PLAINTEXT {
return Err(StreamError::Oversize(len));
}
Ok(())
}
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for tag in rumor.tags.iter() {
let fields = tag.as_slice();
if fields.len() >= 2 && fields[0] == name {
if found.is_some() {
return Err(StreamError::DuplicateTag(name));
}
found = Some(fields[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group(epoch: u64) -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
}
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
wrap.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.and_then(|tag| tag.as_slice().get(1).cloned())
}
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(
9,
author,
content,
channel_binding_tags(&channel(), Epoch(0)),
at_ms,
)
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
.expect("wraps")
.0
}
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
let rumor = bound_rumor(content, author.public_key(), at_ms);
wrapped(
&sealed(&rumor, SealForm::Encrypted, author),
kind,
at_ms / 1000,
)
}
#[test]
fn both_seal_forms_round_trip() {
let author = Keys::generate();
let at_ms = 1_686_840_217_417;
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
let opened = open_wrap(&wrap, &group(0)).expect("opens");
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "Hey chat!");
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
assert_eq!(opened.wrapper_id, wrap.id);
assert_eq!(opened.at_ms, at_ms);
assert_eq!(opened.seal_form, SealForm::Encrypted);
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
// The wrap's `p` tag must identify neither the stream nor the author.
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
assert_ne!(p, group(0).pk_hex());
assert_ne!(p, author.public_key().to_hex());
// Ephemeral actions ride the same structure at a kind relays must drop.
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
assert_eq!(typing.kind.as_u16(), 21059);
assert_eq!(
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
"typing"
);
// The plaintext form carries the rumor's bytes verbatim, which is what
// lets a compaction re-wrap the signed edition into a later epoch.
let edition = build_rumor_secs(
3308,
author.public_key(),
"an edition",
vec![],
1_700_000_000,
);
let seal = sealed(&edition, SealForm::Plaintext, &author);
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), Timestamp::from_secs(2)).expect("rewraps");
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
assert_eq!(reopened.author, author.public_key());
assert_eq!(
reopened.seal.sig, opened.seal.sig,
"the signature rides whole"
);
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
assert!(matches!(
rewrap_seal(
&sealed(&edition, SealForm::Encrypted, &author),
&group(1),
Timestamp::from_secs(2)
),
Err(StreamError::NotRewrappable)
));
}
#[test]
fn hostile_wraps_are_dropped_in_order() {
let author = Keys::generate();
let impostor = Keys::generate();
// Kind and address are settled before any decryption is attempted.
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
wrong_kind.kind = Kind::Custom(1058);
assert!(matches!(
open_wrap(&wrong_kind, &group(0)),
Err(StreamError::BadWrapKind(1058))
));
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
assert!(matches!(
open_wrap(&wrap, &foreign),
Err(StreamError::WrongStream)
));
// A flipped ciphertext byte fails the NIP-44 MAC.
let mut payload = BASE64
.decode(wrap.content.as_bytes())
.expect("content is base64");
payload[40] ^= 0x01;
let mut tampered = wrap.clone();
tampered.content = BASE64.encode(&payload);
assert!(matches!(
open_wrap(&tampered, &group(0)),
Err(StreamError::Decrypt(_))
));
// A seal claiming an author it holds no signature for.
let seal = sealed(
&bound_rumor("spoof", author.public_key(), 1_000),
SealForm::Encrypted,
&impostor,
);
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadSealSignature)
));
// A seal that does not vouch for the rumor's author.
let seal = sealed(
&bound_rumor("spoof", impostor.public_key(), 1_000),
SealForm::Encrypted,
&author,
);
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::AuthorMismatch)
));
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
// smuggles the forgery through verbatim.
let rumor = bound_rumor("real", author.public_key(), 1_000);
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
forged["id"] = serde_json::Value::String("00".repeat(32));
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
.custom_created_at(rumor.created_at)
.finalize(&author)
.expect("seals");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadRumorId)
));
// Binding splices: another channel, another epoch, a duplicate or none.
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
let rumor = bound_rumor("x", author.public_key(), 1_000);
assert!(matches!(
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
Err(StreamError::ChannelMismatch)
));
assert!(matches!(
check_channel_binding(&rumor, &channel(), Epoch(1)),
Err(StreamError::EpochMismatch)
));
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
assert!(matches!(
check_channel_binding(&duplicate, &channel(), Epoch(0)),
Err(StreamError::DuplicateTag(_))
));
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
assert!(matches!(
check_channel_binding(&unbound, &channel(), Epoch(0)),
Err(StreamError::MissingTag(_))
));
let oversize = build_rumor_ms(
9,
author.public_key(),
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
vec![],
1_000,
);
assert!(matches!(
seal_content(&oversize, SealForm::Encrypted, &group(0)),
Err(StreamError::Oversize(_))
));
}
#[test]
fn ms_is_a_drop_gate() {
let author = Keys::generate();
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
for malformed in ["1000", "007", "abc", "+5", ""] {
let rumor = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
1_000,
);
assert!(
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
"{malformed:?} must be malformed"
);
}
// Present but valueless is malformed, not an offset-0 default.
let valueless = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
1_000,
);
assert!(matches!(
resolve_ms_strict(&valueless),
Err(StreamError::BadMs)
));
// A valued duplicate takes the first, matching Armada.
let repeated = build_rumor_secs(
9,
author.public_key(),
"x",
vec![
Tag::custom(TAG_MS, ["1".to_string()]),
Tag::custom(TAG_MS, ["2".to_string()]),
],
1_000,
);
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
}
}
+10 -2
View File
@@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
} }
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
macro_rules! setting_accessors { macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => { ($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings { impl AppSettings {
@@ -138,7 +141,7 @@ impl Default for Settings {
screening: true, screening: true,
nip4e: false, nip4e: false,
trusted_relays: vec![], trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(), file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
} }
} }
} }
@@ -217,7 +220,12 @@ impl AppSettings {
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default()); let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
}
// Update settings // Update settings
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
+4
View File
@@ -24,6 +24,10 @@ serde_json.workspace = true
mime_guess = "2.0.4" mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true nostr-memory.workspace = true
+32 -10
View File
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
use gpui::AsyncApp; use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path; use mime_guess::from_path;
use nostr_blossom::prelude::*; use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> { use crate::file::sha256_hex;
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(path).await?;
let keys = Keys::generate();
// Construct the blossom client /// Upload a blob to a blossom server and return its URL
let client = BlossomClient::new(server); #[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn upload_blob(
server: &Url,
data: Vec<u8>,
content_type: &str,
sha256: &str,
cx: &AsyncApp,
) -> Result<Url, Error> {
let client = BlossomClient::new(server.clone());
let keys = Keys::generate();
let content_type = content_type.to_string();
let base = server.clone();
let hash = sha256.to_string();
Tokio::spawn(cx, async move { Tokio::spawn(cx, async move {
let blob = client match client
.upload_blob(data, Some(content_type), None, Some(&keys)) .upload_blob(data, Some(content_type), None, Some(&keys))
.await?; .await
{
Ok(blob.url) Ok(blob) => Ok(blob.url),
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
Err(e) => Err(anyhow!(e.to_string())),
}
}) })
.await .await
.map_err(|e| anyhow!("Upload error: {e}"))? .map_err(|e| anyhow!("Upload error: {e}"))?
} }
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(&path).await?;
let sha256 = sha256_hex(&data);
upload_blob(&server, data, &content_type, &sha256, cx).await
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> { pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
Err(anyhow!("File upload not supported on web")) Err(anyhow!("File upload not supported on web"))
-3
View File
@@ -14,9 +14,6 @@ pub const USER_KEYRING: &str = "Coop User Credential";
/// Default timeout for subscription /// Default timeout for subscription
pub const TIMEOUT: u64 = 2; pub const TIMEOUT: u64 = 2;
/// Default image cache size
pub const IMAGE_CACHE_SIZE: usize = 20;
/// Default delay for searching /// Default delay for searching
pub const FIND_DELAY: u64 = 600; pub const FIND_DELAY: u64 = 600;
+337
View File
@@ -0,0 +1,337 @@
use std::path::PathBuf;
use aes_gcm::aead::consts::{U12, U16, U32};
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::aes::Aes256;
use aes_gcm::{Aes256Gcm, AesGcm, Nonce};
use anyhow::{Error, anyhow, bail};
use data_encoding::HEXLOWER;
use futures::AsyncReadExt;
use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString};
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr::nips::nip94::Sha256Hash;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
pub const ALGORITHM: &str = "aes-gcm";
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
const TAG_SHA256: &str = "x";
const TAG_ORIGINAL_SHA256: &str = "ox";
const TAG_FILE_TYPE: &str = "file-type";
const TAG_ALGORITHM: &str = "encryption-algorithm";
const TAG_KEY: &str = "decryption-key";
const TAG_NONCE: &str = "decryption-nonce";
const TAG_SIZE: &str = "size";
const TAG_DIM: &str = "dim";
const TAG_ALT: &str = "alt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedFile {
pub data: Vec<u8>,
pub key: String,
pub nonce: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAttachment {
pub url: Url,
pub mime: String,
pub key: String,
pub nonce: String,
pub sha256: Option<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
pub name: Option<String>,
}
impl FileAttachment {
pub fn tags(&self) -> Vec<Tag> {
let mut tags = vec![
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
Tag::custom(TAG_KEY, [self.key.clone()]),
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
];
if let Some(sha256) = &self.sha256 {
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
}
if let Some(original_sha256) = &self.original_sha256 {
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
}
if let Some(size) = self.size {
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
}
if let Some((width, height)) = self.dim {
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
}
if let Some(name) = &self.name {
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
}
tags
}
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
return None;
}
Some(Self {
url: Url::parse(content).ok()?,
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
key: tag_value(tags, TAG_KEY)?.to_string(),
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
name: tag_value(tags, TAG_ALT).map(str::to_string),
})
}
pub fn is_image(&self) -> bool {
self.mime.starts_with("image/")
}
pub fn display_name(&self) -> SharedString {
if let Some(name) = &self.name {
return name.clone().into();
}
match self.size {
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
None => self.mime.clone().into(),
}
}
}
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
let cipher = AesGcm::<Aes256, U16>::new(&key);
let data = cipher
.encrypt(&nonce, data)
.map_err(|_| anyhow!("Failed to encrypt file"))?;
Ok(EncryptedFile {
data,
key: HEXLOWER.encode(key.as_slice()),
nonce: HEXLOWER.encode(nonce.as_slice()),
})
}
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
let key = decode(key, "decryption key")?;
let nonce = decode(nonce, "decryption nonce")?;
if key.len() != 32 {
bail!(
"Invalid decryption key length: expected 32 bytes, got {}",
key.len()
);
}
match nonce.len() {
12 => Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
len => bail!("Unsupported decryption nonce length: {len} bytes"),
}
}
pub fn sha256_hex(data: &[u8]) -> String {
let hash: [u8; 32] = Sha256::digest(data).into();
Sha256Hash::from_byte_array(hash).to_hex()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload_encrypted(
server: Url,
path: PathBuf,
cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
let mime = from_path(&path).first_or_octet_stream().to_string();
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
let data = smol::fs::read(&path).await?;
let encrypted = encrypt(&data)?;
let sha256 = sha256_hex(&encrypted.data);
let original_sha256 = sha256_hex(&data);
let size = encrypted.data.len() as u64;
let base_url = server.to_string();
let url = crate::blossom::upload_blob(
&server,
encrypted.data,
"application/octet-stream",
&sha256,
cx,
)
.await
.map_err(|e| {
let message = e.to_string();
if !message.contains("415") {
return anyhow!(message);
}
anyhow!(
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
opaque data, which this file server does not accept. Choose a different file
server in the settings."
)
})?;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256: Some(sha256),
original_sha256: Some(original_sha256),
size: Some(size),
dim: None,
name,
})
}
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
Err(anyhow!("File upload not supported on web"))
}
pub async fn download_and_decrypt(
url: &Url,
key: &str,
nonce: &str,
expected_sha256: Option<&str>,
cx: &AsyncApp,
) -> Result<Vec<u8>, Error> {
let client = cx.update(|app| app.http_client());
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
if !response.status().is_success() {
bail!("Failed to download file: HTTP {}", response.status());
}
let mut data = Vec::new();
response
.into_body()
.take(MAX_FILE_SIZE as u64 + 1)
.read_to_end(&mut data)
.await?;
if data.len() > MAX_FILE_SIZE {
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
}
if let Some(expected) = expected_sha256
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
{
bail!("File hash mismatch");
}
decrypt(&data, key, nonce)
}
/// Download and decrypt a file attachment into a temporary file.
///
/// The same attachment always maps to the same path, so callers can render the
/// result directly (e.g. with `img`) without downloading it more than once.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_file(
file: &FileAttachment,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let name = file
.sha256
.clone()
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
let extension = mime_guess::get_mime_extensions_str(&file.mime)
.and_then(|extensions| extensions.first())
.copied()
.unwrap_or("bin");
let path = std::env::temp_dir()
.join("coop-files")
.join(format!("{name}.{extension}"));
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(
&file.url,
&file.key,
&file.nonce,
file.sha256.as_deref(),
cx,
)
.await?;
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_file(
_file: &FileAttachment,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("File download not supported on web"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
}
fn parse_dim(value: &str) -> Option<(u32, u32)> {
let (width, height) = value.split_once('x')?;
Some((width.parse().ok()?, height.parse().ok()?))
}
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
HEXLOWER
.decode(value.to_ascii_lowercase().as_bytes())
.map_err(|_| anyhow!("Invalid {label} encoding"))
}
+2
View File
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
mod blossom; mod blossom;
mod constants; mod constants;
mod file;
mod nip05; mod nip05;
mod nip4e; mod nip4e;
mod signer; mod signer;
pub use blossom::*; pub use blossom::*;
pub use constants::*; pub use constants::*;
pub use file::*;
pub use nip4e::*; pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub use signer::{CoopAuthUrlHandler, UniversalSigner};
+2 -2
View File
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
id: "coop".into(), id: "coop".into(),
name: "Coop Default Theme".into(), name: "Coop Default Theme".into(),
author: "Coop".into(), author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(), url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(), light: ThemeColors::light(),
dark: ThemeColors::dark(), dark: ThemeColors::dark(),
} }
@@ -186,7 +186,7 @@ mod tests {
"id": "test-theme", "id": "test-theme",
"name": "Test Theme", "name": "Test Theme",
"author": "Coop", "author": "Coop",
"url": "https://github.com/lumehq/coop", "url": "https://github.com/reyakov/coop",
"light": { "light": {
"background": "#ffffff", "background": "#ffffff",
"surface_background": "#fafafa", "surface_background": "#fafafa",
+2
View File
@@ -46,6 +46,7 @@ pub enum IconName {
InboxFill, InboxFill,
Link, Link,
Loader, Loader,
Lock,
Moon, Moon,
Plus, Plus,
PlusCircle, PlusCircle,
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg", Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg", Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg", Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",
+37 -26
View File
@@ -4,18 +4,18 @@ use ::settings::AppSettings;
use anyhow::Error; use anyhow::Error;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
use common::{CoopImageCache, download_dir}; use common::download_dir;
use device::{DeviceEvent, DeviceRegistry}; use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, image_cache, px, Render, SharedString, Styled, Subscription, Task, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey}; use person::{PersonRegistry, shorten_pubkey};
use serde::Deserialize; use serde::Deserialize;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -64,9 +64,6 @@ pub struct Workspace {
/// App's Dock Area /// App's Dock Area
dock: Entity<DockArea>, dock: Entity<DockArea>,
/// App's Image Cache
image_cache: Entity<CoopImageCache>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -82,7 +79,6 @@ impl Workspace {
let sidebar = cx.new(|cx| Sidebar::new(window, cx)); let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = cx.new(|cx| DockArea::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx));
let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -233,7 +229,6 @@ impl Workspace {
Self { Self {
sidebar, sidebar,
dock, dock,
image_cache,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -380,12 +375,9 @@ impl Workspace {
self.import_encryption(window, cx); self.import_encryption(window, cx);
} }
Command::Update => { Command::Update => {
let auto_updater = AutoUpdater::global(cx); if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| { auto_updater.update(cx, |this, cx| this.check(cx));
this.updater.update(cx, |updater, cx| { }
updater.check(cx);
});
});
} }
} }
} }
@@ -559,7 +551,7 @@ impl Workspace {
.caret() .caret()
.compact() .compact()
.transparent() .transparent()
.dropdown_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone(); let avatar = avatar.clone();
let name = name.clone(); let name = name.clone();
@@ -593,12 +585,15 @@ impl Workspace {
IconName::Sun, IconName::Sun,
Box::new(Command::ToggleTheme), Box::new(Command::ToggleTheme),
) )
.separator() // Only offer in-app updates when auto-update is
.menu_with_icon( // enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates", "Check for Updates",
IconName::Device, IconName::Device,
Box::new(Command::Update), Box::new(Command::Update),
) )
})
.menu_with_icon( .menu_with_icon(
"Settings", "Settings",
IconName::Settings, IconName::Settings,
@@ -610,7 +605,7 @@ impl Workspace {
} }
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement { fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let updater = AutoUpdater::global(cx); let auto_updater = AutoUpdater::try_global(cx);
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx); let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -622,15 +617,36 @@ impl Workspace {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx); let profile = persons.read(cx).get(&public_key, cx);
let announcement = profile.announcement(); let announcement = profile.announcement();
let updater_idle = updater.read(cx).idle(cx);
let updater_status = auto_updater.as_ref().and_then(|updater| {
let updater = updater.read(cx);
(!updater.idle()).then(|| updater.status())
});
let staged_update = auto_updater
.as_ref()
.is_some_and(|updater| updater.read(cx).staged());
h_flex() h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2()) .when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_2() .gap_2()
.when(!updater_idle, |this| { .when_some(updater_status, |this, status| {
let status = updater.read(cx).status(cx);
this.child(div().text_xs().italic().child(status)) this.child(div().text_xs().italic().child(status))
}) })
.when(staged_update, |this| {
this.child(
Button::new("restart-to-update")
.label("Restart to Update")
.tooltip("Quit and relaunch into the installed update")
.small()
.ghost()
.on_click(cx.listener(|_this, _event, _window, cx| {
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.restart(cx));
}
})),
)
})
.when(nip4e_enabled, |this| { .when(nip4e_enabled, |this| {
this.child( this.child(
Button::new("key") Button::new("key")
@@ -764,10 +780,6 @@ impl Render for Workspace {
div() div()
.id("workspace") .id("workspace")
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.relative()
.size_full()
.child(
image_cache(self.image_cache.clone())
.relative() .relative()
.size_full() .size_full()
.child( .child(
@@ -792,7 +804,6 @@ impl Render for Workspace {
) )
.child(self.dock.clone()), .child(self.dock.clone()),
), ),
),
) )
// Notifications // Notifications
.children(notification_layer) .children(notification_layer)
+2 -1
View File
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, TextAlign, Window, div, rems, Task, TextAlign, Window, div, rems, retain_all,
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -297,6 +297,7 @@ impl Focusable for ContactListPanel {
impl Render for ContactListPanel { impl Render for ContactListPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex() v_flex()
.image_cache(retain_all("contact-list-panel"))
.p_3() .p_3()
.gap_3() .gap_3()
.w_full() .w_full()
+8 -5
View File
@@ -1,10 +1,10 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::Error;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window, div, Window, div, retain_all,
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -167,13 +167,15 @@ impl ProfilePanel {
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_uploading(true, cx); this.set_uploading(true, cx);
})?; })?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
// Upload via blossom client // Upload via blossom client
match upload(server, path, cx).await { match upload(server, path, cx).await {
Ok(url) => { Ok(url) => {
@@ -319,6 +321,7 @@ impl Render for ProfilePanel {
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
v_flex() v_flex()
.image_cache(retain_all("profile-panel"))
.p_3() .p_3()
.gap_3() .gap_3()
.w_full() .w_full()
+4 -4
View File
@@ -3,19 +3,19 @@ use std::ops::Range;
use anyhow::Error; use anyhow::Error;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::{DebouncedDelay, TimestampExt, coop_cache}; use common::{DebouncedDelay, TimestampExt};
use entry::RoomEntry; use entry::RoomEntry;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle, ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle,
Window, div, uniform_list, Window, div, retain_all, uniform_list,
}; };
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry}; use state::{FIND_DELAY, NostrRegistry};
use theme::{ActiveTheme, SIDEBAR_WIDTH}; use theme::{ActiveTheme, SIDEBAR_WIDTH};
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
@@ -521,7 +521,7 @@ impl Render for Sidebar {
}; };
v_flex() v_flex()
.image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE)) .image_cache(retain_all("sidebar"))
.size_full() .size_full()
.gap_2() .gap_2()
.child( .child(
+1 -1
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr" description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop" identifier = "su.reya.coop"
category = "SocialNetworking" category = "SocialNetworking"
version = "1.0.0" version = "1.0.2"
out-dir = "../dist" out-dir = "../dist"
before-packaging-command = "cargo build --release" before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"] resources = ["Cargo.toml", "src"]
@@ -35,13 +35,13 @@
<content_attribute id="social-audio">intense</content_attribute> <content_attribute id="social-audio">intense</content_attribute>
</content_rating> </content_rating>
<url type="homepage">https://reya.su/coop</url> <url type="homepage">https://coopchat.xyz</url>
<url type="bugtracker">https://github.com/lumehq/coop/issues</url> <url type="bugtracker">https://github.com/reyakov/coop/issues</url>
<url type="faq">https://github.com/lumehq/coop</url> <url type="faq">https://github.com/reyakov/coop</url>
<url type="help">https://github.com/lumehq/coop/issues</url> <url type="help">https://github.com/reyakov/coop/issues</url>
<url type="contact">https://reya.su/</url> <url type="contact">reyakov@proton.me</url>
<url type="vcs-browser">https://github.com/lumehq/coop</url> <url type="vcs-browser">https://github.com/reykov/coop</url>
<url type="contribute">https://github.com/lumehq/coop/blob/main/CONTRIBUTING.md</url> <url type="contribute">https://github.com/reyakov/coop/blob/main/CONTRIBUTING.md</url>
<supports> <supports>
<internet>yes</internet> <internet>yes</internet>
+8 -4
View File
@@ -1,3 +1,7 @@
# Snaps built by snapcraft without Snap Store credentials are unsigned and
# cannot be installed without bypassing signature checks. Use:
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
# For signed installs (`snap install coop`), publish via the Snap Store.
name: coop name: coop
title: Coop title: Coop
base: core24 base: core24
@@ -10,10 +14,10 @@ description: |
grade: stable grade: stable
confinement: classic confinement: classic
compression: lzo compression: lzo
website: https://reya.su/coop website: https://reya.info/coop
source-code: https://github.com/lumehq/coop source-code: https://git.reya.info/reya/coop
issues: https://github.com/lumehq/coop/issues issues: https://github.com/reyakov/coop/issues
contact: https://reya.su contact: https://coopchat.xyz
parts: parts:
coop: coop:
+4
View File
@@ -35,3 +35,7 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
snapcraft --destructive-mode --output "$SNAP_NAME" snapcraft --destructive-mode --output "$SNAP_NAME"
echo "Created snap package: $SNAP_NAME" echo "Created snap package: $SNAP_NAME"
echo ""
echo "This snap is unsigned (built without Snap Store credentials)."
echo "To install it locally, use:"
echo " sudo snap install --dangerous ./$SNAP_NAME"
+2 -2
View File
@@ -14,7 +14,7 @@ cd "$(dirname "$0")/.."
# Configuration # Configuration
APP_ID="su.reya.coop" APP_ID="su.reya.coop"
APP_NAME="Coop" APP_NAME="Coop"
REPO_URL="https://git.reya.su/reya/coop" REPO_URL="https://git.reya.info/reya/coop"
BRANDING_LIGHT="#FFE629" BRANDING_LIGHT="#FFE629"
BRANDING_DARK="#FFE629" BRANDING_DARK="#FFE629"
@@ -173,7 +173,7 @@ modules:
sources: sources:
# Main source code - specific commit # Main source code - specific commit
- type: git - type: git
url: https://git.reya.su/reya/coop.git url: https://git.reya.info/reya/coop.git
commit: "@COMMIT@" commit: "@COMMIT@"
tag: "v@VERSION@" tag: "v@VERSION@"
+43 -25
View File
@@ -29,23 +29,29 @@ fi
# Function to update version in a Cargo.toml file # Function to update version in a Cargo.toml file
update_version() { update_version() {
local file="$1" local file="$1"
local backup="${file}.bak" local tmp="${file}.tmp"
# Backup the original file # Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
cp "$file" "$backup" # and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
# suffix instead of the extended-regex flag, leaving a stray
# More flexible regex that handles various version formats and whitespace # `Cargo.toml-E` behind and never updating the file.
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then # Writing to a temp file and moving it over works on both implementations.
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
&& mv "$tmp" "$file"; then
echo "✓ Updated version to $NEW_VERSION in $file" echo "✓ Updated version to $NEW_VERSION in $file"
else else
echo "Error: Failed to update version in $file" echo "Error: Failed to update version in $file"
# Restore original backup # Remove any partial temp file; the original file is untouched
mv "$backup" "$file" rm -f "$tmp"
exit 1 exit 1
fi fi
# Remove the backup file # The substitution can silently match nothing (e.g. a `version.workspace` key),
rm -f "$backup" # so verify the new version actually landed before moving on.
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
echo "Error: Version line not found/updated in $file"
exit 1
fi
} }
# Update both Cargo.toml files # Update both Cargo.toml files
@@ -53,9 +59,17 @@ echo "Updating versions..."
update_version "$WORKSPACE_CARGO" update_version "$WORKSPACE_CARGO"
update_version "$CRATE_CARGO" update_version "$CRATE_CARGO"
# Check git status before committing COMMIT_MSG="chore: release version $NEW_VERSION"
echo "Checking git status..."
if git status --porcelain | grep -q .; then # When the requested version is already set there is nothing to bump or commit,
# so the current commit is tagged as-is.
if git diff --quiet -- "$WORKSPACE_CARGO" "$CRATE_CARGO"; then
echo "Version is already $NEW_VERSION, tagging the current commit"
else
# Check git status before committing
echo "Checking git status..."
# The version files are always modified at this point, so only ask about other changes.
if [ -n "$(git status --porcelain -- . ":(exclude,top)$WORKSPACE_CARGO" ":(exclude,top)$CRATE_CARGO")" ]; then
echo "Current uncommitted changes:" echo "Current uncommitted changes:"
git status --short git status --short
@@ -85,34 +99,38 @@ if git status --porcelain | grep -q .; then
exit 1 exit 1
;; ;;
esac esac
else else
# Only version files were modified, add them specifically # Only version files were modified, add them specifically
echo "Only version files were modified, adding them for commit..." echo "Only version files were modified, adding them for commit..."
git add "$WORKSPACE_CARGO" "$CRATE_CARGO" git add "$WORKSPACE_CARGO" "$CRATE_CARGO"
fi fi
# Commit the changes # Commit the changes
COMMIT_MSG="chore: release version $NEW_VERSION" if git commit -m "$COMMIT_MSG"; then
if git commit -m "$COMMIT_MSG"; then
echo "✓ Committed version changes" echo "✓ Committed version changes"
else else
echo "Error: Failed to commit version changes" echo "Error: Failed to commit version changes"
exit 1 exit 1
fi fi
# Push version changes to origin # Push version changes to origin
echo "Pushing version changes to origin..." echo "Pushing version changes to origin..."
if git push origin master; then if git push origin master; then
echo "✓ Successfully pushed version changes to origin" echo "✓ Successfully pushed version changes to origin"
else else
echo "Error: Failed to push version changes to origin" echo "Error: Failed to push version changes to origin"
exit 1 exit 1
fi
fi fi
# Create git tag # Create git tag
TAG_NAME="v$NEW_VERSION" TAG_NAME="v$NEW_VERSION"
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
echo "Error: tag $TAG_NAME already exists"
exit 1
fi
if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then if git tag -a "$TAG_NAME" -m "$COMMIT_MSG"; then
echo "✓ Created git tag: $TAG_NAME" echo "✓ Created git tag: $TAG_NAME"
else else
+16 -1
View File
@@ -15,11 +15,26 @@ if [ "$#" -ne 1 ]; then
exit 1 exit 1
fi fi
# Get system architecture (same mapping as script/bundle-snap)
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_SUFFIX="x86_64" ;;
aarch64) ARCH_SUFFIX="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
if [ ! -f "$snap_file" ]; then
echo "Snap file not found: $snap_file"
echo "Build it first with: script/bundle-snap $1"
exit 1
fi
# Rerun as root # Rerun as root
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@" [ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
snap remove coop || true snap remove coop || true
mkdir -p snap mkdir -p snap
rm -rf snap/unpacked rm -rf snap/unpacked
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap" unsquashfs -dest snap/unpacked "$snap_file"
snap try --classic snap/unpacked snap try --classic snap/unpacked