add concord backend
This commit is contained in:
@@ -209,11 +209,12 @@ Decision: no test files are maintained for this feature. M1 and M2 were verified
|
||||
|
||||
The consequence to plan around is in §14 and risk 13.
|
||||
|
||||
### 5.5 Modified (6 files, all small)
|
||||
### 5.5 Modified (7 files, all small)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `shared/.../nostr/Nostr.kt` | add `val concord`, 2 routing branches |
|
||||
| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches |
|
||||
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
|
||||
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params |
|
||||
| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector |
|
||||
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
|
||||
@@ -702,13 +703,45 @@ Each milestone ends with something runnable.
|
||||
|
||||
### M3 — Join + read
|
||||
|
||||
`ConcordStore.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordManager` read path, `Nostr.kt` routing.
|
||||
Delivered. `ConcordModels.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordStore.kt`, the `ConcordManager` read path, and the two routing branches in `Nostr.kt`.
|
||||
|
||||
**Done when:** pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community.
|
||||
|
||||
What shipped:
|
||||
|
||||
- `ConcordInvite.kt` — `parseInviteLink`, `decodeInviteFragment` (CORD-05 §3), the stock relay dictionary, `inviteBundleKey`, `decryptInviteBundle`, `problems()` validation, `relaySet`, `toMembership`
|
||||
- `ConcordControl.kt` — `editionOf` + `fold`, with the `ep` chain walk, highest-version-wins and the lower-rumor-id tie-break
|
||||
- `ConcordStore.kt` — memberships through `AppStorage.setSecret`, plane rumors through LMDB indexed on `d`/`r`/`k`
|
||||
- `ConcordManager.kt` — plane index, `restore()`/`sync()`, `isPlaneAddress`, `handlePlaneEvent`, `onInboxRumor`, `previewInvite`, `join`, `channelMessages`
|
||||
- `Nostr.kt` — the plane-author routing branch, the Concord queue, the Direct Invite interception, and `concord.attach(storage)` from `init`
|
||||
|
||||
**Verified** with a throwaway check on the iOS target (20 cases, all passing), then deleted:
|
||||
- the fragment decodes for the stock flag, explicit dictionary ids, `wss://`-implied hosts and verbatim URLs; unknown ids, short tokens and a wrong version are refused; explicit entries cap at 3 while the stock flag yields the whole dictionary ✅
|
||||
- a link round-trips through a real `naddr` (kind `33301`, empty identifier) ✅
|
||||
- a valid bundle passes `problems()`; a tampered owner, a malformed salt and 257 channels are refused ✅
|
||||
- `bundle_key` round-trips a bundle through NIP-44, and a different token cannot open it ✅
|
||||
- `expires_at` converts ms→s and an absent expiry never expires ✅
|
||||
- an intact Control chain folds to its highest version; a broken link truncates to the last good one; a missing predecessor disqualifies that version alone; same-version ties break on the lower rumor id; entities fold independently; metadata for another `community_id` is ignored; `deleted` is projected ✅
|
||||
- `editionOf` parses a real rumor and refuses `vsk 10` and a missing `ev` ✅
|
||||
|
||||
**One bug the check caught:** `inviteBundleKey` first used the 32-byte hex guard, but the token is 16 bytes — every link would have failed to open.
|
||||
|
||||
**Not covered by that check:** anything needing the network — fetching a bundle, the subscription, and publishing the Join. Those are the interop smoke test below.
|
||||
|
||||
### M3 interop smoke test — the acceptance gate
|
||||
|
||||
Against a real Community, with a real invite link:
|
||||
|
||||
1. paste the link → the preview shows the right name and channel count, and no `problems`
|
||||
2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
|
||||
3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
|
||||
4. a message sent from Coop appears there too (once M4 lands)
|
||||
|
||||
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
|
||||
|
||||
### M4 — Write
|
||||
|
||||
`sendChannelMessage`, Guestbook `join` (`3306`, `content: "join"`), `unreadCount`.
|
||||
`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.)
|
||||
|
||||
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
|
||||
|
||||
@@ -741,6 +774,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| 1 | **`community_id` owner proof.** CORD-05 §1 writes `sha256(owner ‖ salt)`. `examples.md` §6.1 writes `sha256("concord/community" ‖ owner ‖ salt)`. CORD-02 A.4 (marked *frozen*, normative) writes `sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32])`. | **Implement A.4.** It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. |
|
||||
| 2 | **`expires_at` units differ in three places** — bundle = unix **ms**, Invite List entry = unix **s**, NIP-40 tag = **s**. | Keep all conversions in one function. |
|
||||
| 3 | **`vac` on `3303`.** CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor `examples.md` shows the tag. | Deferring rekeys sidesteps this. |
|
||||
| 4 | **What `concord/invite-key` yields.** CORD-05 §2 writes `bundle_key = hkdf(token, "concord/invite-key")` and then `nip44_encrypt(bundle_key, …)`, which reads as a raw conversation key. A.6 lists the label in the *derivation* registry, where every row is fed through `group_key` → `scalar_normalize` → keypair, and elsewhere CORD-01 always writes `conv_key` for the self-ECDH value it feeds `nip44_encrypt`. | **Modelled as `group_key("concord/invite-key", token, 0…0)`.** This is the only reading the existing SDK can implement — it exposes NIP-44 by keypair only, never by conversation key — and it is consistent with every other row in A.6. It is a hard-fail interop check: verify at the smoke test, and if a bundle refuses to open, the raw-conversation-key reading is the alternative and would need NIP-44 hand-rolled. |
|
||||
|
||||
### 13.2 Design risks
|
||||
|
||||
@@ -756,6 +790,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| 11 | **Spec is young** (71 commits, no reference implementation in-repo, examples explicitly non-normative). | Keep every frozen constant in `ConcordKind.kt` so a spec revision is a one-file change. |
|
||||
| 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. |
|
||||
| 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. |
|
||||
| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
|
||||
|
||||
### 13.3 Open decision
|
||||
|
||||
@@ -765,10 +800,10 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
|
||||
## 14. Verification plan
|
||||
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1 and M2,
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M3,
|
||||
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
|
||||
recorded here because they are what establishes the wire format is right, and because whoever
|
||||
next touches the crypto should re-run the same checks.
|
||||
recorded here (and per-milestone in §12) because they are what establishes the wire format is
|
||||
right, and because whoever next touches this code should re-run the same checks.
|
||||
|
||||
The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with
|
||||
**no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a
|
||||
|
||||
@@ -63,7 +63,7 @@ class NostrForegroundService : Service() {
|
||||
|
||||
// Initialize Nostr client
|
||||
try {
|
||||
nostr.init(dbDir.absolutePath)
|
||||
nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Folding the Control Plane (CORD-04 §1).
|
||||
*
|
||||
* The Control Plane carries the Community's authoritative state as **editions**: each is a
|
||||
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
|
||||
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
|
||||
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
|
||||
*
|
||||
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
|
||||
*
|
||||
* ## What v1 does not do
|
||||
*
|
||||
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
|
||||
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
|
||||
* publish a forged metadata or Channel edition and v1 would display it.
|
||||
*
|
||||
* That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2),
|
||||
* and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the
|
||||
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
|
||||
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
|
||||
* outranks the entity it edits.
|
||||
*/
|
||||
object ConcordControl {
|
||||
|
||||
/**
|
||||
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
|
||||
*
|
||||
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
|
||||
* does not implement dissolution, so treating it as an ordinary edition would misread it.
|
||||
*/
|
||||
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
|
||||
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
||||
|
||||
val tags = rumor.tags().toVec()
|
||||
val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null
|
||||
if (vsk == ConcordVsk.DISSOLVED) return null
|
||||
|
||||
val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null
|
||||
if (eidHex.hex32() == null) return null
|
||||
|
||||
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
|
||||
// CORD-04: versions climb from 1. A zero version has no place in the chain.
|
||||
if (version == 0uL) return null
|
||||
|
||||
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
|
||||
if (prev != null && prev.hex32() == null) return null
|
||||
|
||||
return ControlEdition(
|
||||
vsk = vsk,
|
||||
eidHex = eidHex.lowercase(),
|
||||
version = version,
|
||||
prevHashHex = prev?.lowercase(),
|
||||
content = rumor.content(),
|
||||
actorHex = rumor.author().toHex(),
|
||||
rumorIdHex = rumor.id()?.toHex() ?: return null,
|
||||
createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the editions into the metadata and Channel list v1 renders.
|
||||
*
|
||||
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
|
||||
* broken chain on one Channel never takes the Community's metadata down with it.
|
||||
*/
|
||||
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
|
||||
var meta: CommunityMeta? = null
|
||||
val channels = mutableMapOf<String, ChannelMeta>()
|
||||
|
||||
editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) ->
|
||||
val (vsk, eidHex) = entity
|
||||
if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach
|
||||
// The metadata entity *is* the Community, so its coordinate must be the community_id;
|
||||
// an edition naming anything else is not this Community's metadata.
|
||||
if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach
|
||||
|
||||
val winner = winner(group) ?: return@forEach
|
||||
when (vsk) {
|
||||
ConcordVsk.METADATA -> meta = decode(winner.content)
|
||||
else -> decode<ChannelMeta>(winner.content)?.let { channels[eidHex] = it }
|
||||
}
|
||||
}
|
||||
|
||||
return ControlFold(meta = meta, channels = channels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
|
||||
*
|
||||
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
|
||||
* from the top and take the first version we can actually verify. A missing predecessor or a
|
||||
* broken link disqualifies that version, not the entity.
|
||||
*
|
||||
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
|
||||
* clients folding the same two candidates always agree.
|
||||
*/
|
||||
private fun winner(group: List<ControlEdition>): ControlEdition? {
|
||||
val byVersion = group
|
||||
.groupBy { it.version }
|
||||
.mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null }
|
||||
|
||||
val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null
|
||||
|
||||
for (top in byVersion.keys.sortedDescending()) {
|
||||
var prev: ByteArray? = null
|
||||
var candidate: ControlEdition? = null
|
||||
var intact = true
|
||||
|
||||
for (version in 1uL..top) {
|
||||
val edition = byVersion[version]
|
||||
if (edition == null) {
|
||||
intact = false
|
||||
break
|
||||
}
|
||||
// The first edition has no predecessor; every later one must chain to the last.
|
||||
val expected = if (version == 1uL) null else prev?.toHex()
|
||||
if (edition.prevHashHex != expected) {
|
||||
intact = false
|
||||
break
|
||||
}
|
||||
prev = editionHash(eid, version, prev, edition.content.encodeToByteArray())
|
||||
candidate = edition
|
||||
}
|
||||
|
||||
if (intact) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private inline fun <reified T> decode(content: String): T? =
|
||||
runCatching { concordJson.decodeFromString<T>(content) }.getOrNull()
|
||||
}
|
||||
@@ -237,3 +237,14 @@ internal fun ByteArray.toHex(): String = toByteString().hex()
|
||||
|
||||
/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
|
||||
internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
|
||||
|
||||
/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */
|
||||
internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull()
|
||||
|
||||
/**
|
||||
* A 32-byte value read from hex, or null when it is not exactly 32 bytes.
|
||||
*
|
||||
* The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long
|
||||
* value must be rejected before it reaches a derivation that assumes a fixed width.
|
||||
*/
|
||||
internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlin.io.encoding.Base64
|
||||
import rust.nostr.sdk.Nip19Coordinate
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.nip44Decrypt
|
||||
|
||||
/**
|
||||
* CORD-05: redeeming an invite.
|
||||
*
|
||||
* Two ways to be handed the keys, one bundle:
|
||||
*
|
||||
* - **Public link** — `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
|
||||
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
|
||||
* server. The bundle is fetched, then decrypted with a key derived from the token.
|
||||
* - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
|
||||
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
|
||||
*
|
||||
* Minting is out of v1's scope, so this file only decodes.
|
||||
*
|
||||
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
|
||||
* strength of what the bundle claims (CORD-05 §1).
|
||||
*/
|
||||
|
||||
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
|
||||
internal object ConcordRelayDictionary {
|
||||
val STOCK = listOf(
|
||||
"wss://jskitty.com/nostr",
|
||||
"wss://asia.vectorapp.io/nostr",
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.dreamith.to",
|
||||
)
|
||||
}
|
||||
|
||||
/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */
|
||||
data class InviteFragment(
|
||||
val version: Int,
|
||||
val relays: List<String>,
|
||||
/** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */
|
||||
val tokenHex: String,
|
||||
)
|
||||
|
||||
/** A parsed invite link: the public locator plus the secret fragment. */
|
||||
data class ParsedInvite(
|
||||
val signerHex: String,
|
||||
val identifier: String,
|
||||
/** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */
|
||||
val naddrRelays: List<RelayUrl>,
|
||||
val fragment: InviteFragment,
|
||||
)
|
||||
|
||||
private const val FRAGMENT_VERSION = 4
|
||||
|
||||
/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */
|
||||
private const val FLAG_STOCK_RELAYS = 1
|
||||
private const val TOKEN_BYTES = 16
|
||||
|
||||
/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */
|
||||
private const val MAX_BOOTSTRAP_RELAYS = 3
|
||||
|
||||
/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */
|
||||
private const val MAX_INVITE_CHANNELS = 256
|
||||
|
||||
/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */
|
||||
private const val MAX_COMMUNITY_RELAYS = 5
|
||||
|
||||
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
|
||||
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
||||
|
||||
/**
|
||||
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
|
||||
*
|
||||
* The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any
|
||||
* client recognizing an invite must respect the naddr and fragment verbatim — so only the last path
|
||||
* segment and the `#` fragment are read.
|
||||
*/
|
||||
fun parseInviteLink(text: String): ParsedInvite? {
|
||||
val trimmed = text.trim()
|
||||
val hash = trimmed.indexOf('#')
|
||||
if (hash <= 0 || hash == trimmed.lastIndex) return null
|
||||
|
||||
val naddr = trimmed.substring(0, hash).substringAfterLast('/')
|
||||
val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null
|
||||
|
||||
val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null
|
||||
if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null
|
||||
|
||||
return ParsedInvite(
|
||||
signerHex = coordinate.coordinate().publicKey().toHex(),
|
||||
identifier = coordinate.coordinate().identifier(),
|
||||
naddrRelays = coordinate.relays(),
|
||||
fragment = fragment,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-05 §3's fragment layout:
|
||||
*
|
||||
* ```
|
||||
* [version=4][flags][relays?][token:16]
|
||||
* ```
|
||||
*
|
||||
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
|
||||
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
|
||||
*/
|
||||
fun decodeInviteFragment(fragment: String): InviteFragment? {
|
||||
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
||||
// Two header bytes plus the token at minimum.
|
||||
if (bytes.size < 2 + TOKEN_BYTES) return null
|
||||
|
||||
val reader = FragmentReader(bytes)
|
||||
if (reader.byte() != FRAGMENT_VERSION) return null
|
||||
|
||||
val flags = reader.byte() ?: return null
|
||||
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
|
||||
// explicit entries does not apply to it.
|
||||
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
|
||||
ConcordRelayDictionary.STOCK
|
||||
} else {
|
||||
val count = reader.byte() ?: return null
|
||||
buildList {
|
||||
repeat(count) {
|
||||
val lead = reader.byte() ?: return null
|
||||
val relay = when (lead) {
|
||||
0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" }
|
||||
255 -> reader.slice(reader.byte() ?: return null)
|
||||
else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1)
|
||||
} ?: return null
|
||||
add(relay)
|
||||
}
|
||||
}.take(MAX_BOOTSTRAP_RELAYS)
|
||||
}
|
||||
|
||||
// Whatever is left must be exactly the token: a short one silently weakens the link.
|
||||
val token = bytes.copyOfRange(reader.index, bytes.size)
|
||||
if (token.size != TOKEN_BYTES) return null
|
||||
|
||||
return InviteFragment(
|
||||
version = FRAGMENT_VERSION,
|
||||
relays = relays,
|
||||
tokenHex = token.toHex(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
|
||||
* thing — decrypting the bundle — and nothing else.
|
||||
*
|
||||
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
|
||||
* `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord
|
||||
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
|
||||
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
|
||||
*/
|
||||
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
||||
|
||||
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
|
||||
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
|
||||
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
||||
?: return null
|
||||
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
|
||||
}
|
||||
|
||||
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
|
||||
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
|
||||
|
||||
/**
|
||||
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
|
||||
* the preview can explain itself rather than silently refusing.
|
||||
*
|
||||
* The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖
|
||||
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
|
||||
*/
|
||||
fun CommunityInvite.problems(): List<String> {
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
val ownerBytes = owner.hex32()
|
||||
val saltBytes = ownerSalt.hex32()
|
||||
val rootBytes = communityRoot.hex32()
|
||||
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
||||
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
||||
if (rootBytes == null) problems += "The invite's community key is malformed"
|
||||
|
||||
if (ownerBytes != null && saltBytes != null) {
|
||||
if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) {
|
||||
problems += "The invite does not prove its community id"
|
||||
}
|
||||
}
|
||||
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
|
||||
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
|
||||
|
||||
if (channels.size > MAX_INVITE_CHANNELS) {
|
||||
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
||||
}
|
||||
channels.forEachIndexed { index, channel ->
|
||||
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
||||
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
|
||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
||||
|
||||
/**
|
||||
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
|
||||
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
|
||||
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
|
||||
*/
|
||||
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
|
||||
val source = relays.ifEmpty { fallback }
|
||||
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
|
||||
}
|
||||
|
||||
/** Turns a validated bundle into the membership we persist. */
|
||||
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
||||
communityId = communityId.lowercase(),
|
||||
owner = owner.lowercase(),
|
||||
ownerSalt = ownerSalt.lowercase(),
|
||||
communityRoot = communityRoot.lowercase(),
|
||||
rootEpoch = rootEpoch,
|
||||
controlPk = controlPk?.lowercase(),
|
||||
controlRoot = null,
|
||||
relays = relays,
|
||||
name = name,
|
||||
channels = channels.map { channel ->
|
||||
StoredChannelKey(
|
||||
id = channel.id.lowercase(),
|
||||
key = channel.key.lowercase(),
|
||||
epoch = channel.epoch,
|
||||
name = channel.name,
|
||||
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
|
||||
// only reading the bundle supports. The Control fold overrides this for display.
|
||||
private = !channel.key.equals(communityRoot, ignoreCase = true),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
|
||||
private class FragmentReader(private val bytes: ByteArray) {
|
||||
var index = 0
|
||||
private set
|
||||
|
||||
fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null
|
||||
|
||||
fun slice(length: Int): String? {
|
||||
if (length < 0 || index + length > bytes.size) return null
|
||||
val value = bytes.decodeToString(index, index + length)
|
||||
index += length
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite
|
||||
* turns into membership.
|
||||
*
|
||||
* ## Why this lives on [Nostr]
|
||||
*
|
||||
* The client has exactly **one** notification pump. `client.notifications()` is called once, inside
|
||||
* [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord
|
||||
* routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]).
|
||||
*
|
||||
* ## The two phases
|
||||
*
|
||||
* [restore] is local and fast: it reads memberships from storage and folds whatever the Control
|
||||
* plane has already cached, so routing is meaningful before anything hits the network. [sync] is
|
||||
* the network half: connect the Community's relays and subscribe to its plane addresses. The pump
|
||||
* calls them in that order, so the first event that arrives already knows where it belongs.
|
||||
*/
|
||||
class ConcordManager(private val nostr: Nostr) {
|
||||
|
||||
/**
|
||||
* Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a
|
||||
* Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17
|
||||
* path, which assumes an ephemeral author and a `p`-tagged recipient.
|
||||
*
|
||||
* Replaced wholesale rather than mutated, and read from the notification pump's thread while a
|
||||
* worker coroutine rewrites it, so the reference is volatile.
|
||||
*/
|
||||
@Volatile
|
||||
private var planes: Map<String, Plane> = emptyMap()
|
||||
|
||||
/** Latest Control fold per community id. Replaced wholesale; see [planes]. */
|
||||
@Volatile
|
||||
private var folds: Map<String, ControlFold> = emptyMap()
|
||||
|
||||
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
|
||||
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
||||
|
||||
private val _communities = MutableStateFlow<List<CommunityState>>(emptyList())
|
||||
val communities: StateFlow<List<CommunityState>> = _communities.asStateFlow()
|
||||
|
||||
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
|
||||
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
|
||||
|
||||
private var store: ConcordStore? = null
|
||||
|
||||
/**
|
||||
* Hands over the encrypted storage [Nostr] cannot reach on its own.
|
||||
*
|
||||
* [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through
|
||||
* [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager
|
||||
* simply routes nothing.
|
||||
*/
|
||||
internal fun attach(storage: AppStorage) {
|
||||
store = ConcordStore(storage, nostr)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Routing
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** True when a wrap's author is one of our planes' stream keys. */
|
||||
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
|
||||
|
||||
/**
|
||||
* Handles one stream wrap from a plane we hold a key for.
|
||||
*
|
||||
* [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a
|
||||
* forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being
|
||||
* rendered. What survives is stored under its logical scope and, for Control, re-folded.
|
||||
*/
|
||||
suspend fun handlePlaneEvent(event: Event) {
|
||||
val plane = planes[event.author().toHex()] ?: return
|
||||
val rumor = plane.key.unwrap(event) ?: return
|
||||
val store = store ?: return
|
||||
|
||||
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
|
||||
|
||||
// Control carries consensus state, so every edition changes what we can read: a new
|
||||
// Channel means a new plane address, and therefore a new subscription.
|
||||
if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to
|
||||
* Concord instead.
|
||||
*
|
||||
* Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard*
|
||||
* NIP-59 wrap, so it unwraps fine — but it is a key handoff, not a message, and letting it
|
||||
* through would have the chat layer build a room out of it. Returning true here keeps all
|
||||
* Concord kind knowledge inside this package and leaves the chat code untouched.
|
||||
*/
|
||||
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
|
||||
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
|
||||
|
||||
val invite = runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
|
||||
if (invite == null) {
|
||||
println("Concord: a direct invite arrived but its bundle could not be read")
|
||||
return true
|
||||
}
|
||||
if (invite.problems().isNotEmpty()) {
|
||||
println("Concord: refusing a direct invite that does not prove its community id")
|
||||
return true
|
||||
}
|
||||
|
||||
_directInvites.update { current ->
|
||||
if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current
|
||||
else current + invite
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
|
||||
suspend fun restore() {
|
||||
val store = store ?: return
|
||||
val memberships = store.loadMemberships()
|
||||
_memberships.value = memberships
|
||||
|
||||
// Fold what is already on disk so Channels and metadata render immediately. Live editions
|
||||
// arrive later through handlePlaneEvent and re-fold.
|
||||
folds = memberships.associate { membership ->
|
||||
membership.communityId to ConcordControl.fold(
|
||||
editions = store.cachedRumors(membership.communityId)
|
||||
.mapNotNull { ConcordControl.editionOf(it) },
|
||||
communityIdHex = membership.communityId,
|
||||
)
|
||||
}
|
||||
refreshPlanes()
|
||||
}
|
||||
|
||||
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
|
||||
suspend fun sync() {
|
||||
for (membership in _memberships.value) {
|
||||
try {
|
||||
subscribeCommunity(membership)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: could not subscribe to ${membership.communityId}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Invites
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
|
||||
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
|
||||
* (CORD-05 §1: a bundle is passive until the user accepts).
|
||||
*/
|
||||
suspend fun previewInvite(link: String): InvitePreview {
|
||||
val parsed = parseInviteLink(link) ?: throw IllegalArgumentException("That is not a Concord invite link")
|
||||
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
|
||||
?: throw IllegalArgumentException("The invite link's token is malformed")
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
|
||||
// The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries
|
||||
// the Community's real relay set (CORD-05 §3).
|
||||
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
|
||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
.distinct()
|
||||
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
|
||||
|
||||
relays.forEach { relay ->
|
||||
client.addRelay(relay)
|
||||
client.connectRelay(relay)
|
||||
}
|
||||
|
||||
val filter = Filter()
|
||||
.kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort()))
|
||||
.author(PublicKey.fromBytes(parsed.signerHex.hexToBytes()))
|
||||
.identifier(parsed.identifier)
|
||||
|
||||
val bundle = client
|
||||
.fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds)
|
||||
.toVec()
|
||||
.firstOrNull()
|
||||
?: throw IllegalStateException("No invite bundle was found at that link")
|
||||
|
||||
if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) {
|
||||
throw IllegalStateException("This invite link has been revoked")
|
||||
}
|
||||
|
||||
val invite = decryptInviteBundle(bundle.content(), bundleKey)
|
||||
?: throw IllegalStateException("The invite bundle could not be opened")
|
||||
|
||||
return preview(invite, link, parsed.fragment.relays)
|
||||
}
|
||||
|
||||
/** The same preview for a Direct Invite, which arrived with no link to fetch. */
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview = preview(invite, link = null, fallbackRelays = emptyList())
|
||||
|
||||
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
|
||||
suspend fun join(preview: InvitePreview): CommunityState {
|
||||
preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) }
|
||||
if (preview.expired) throw IllegalArgumentException("That invite has expired")
|
||||
|
||||
val store = store ?: throw IllegalStateException("Concord storage is not ready")
|
||||
val membership = preview.invite.toMembership(preview.relays)
|
||||
|
||||
val updated = _memberships.value.filterNot { it.communityId == membership.communityId } + membership
|
||||
store.saveMemberships(updated)
|
||||
_memberships.value = updated
|
||||
|
||||
refreshPlanes()
|
||||
subscribeCommunity(membership)
|
||||
|
||||
// CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no
|
||||
// Guestbook fold in v1 — the Control plane is what drives the UI.
|
||||
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
|
||||
|
||||
_directInvites.update { invites ->
|
||||
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
|
||||
}
|
||||
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
|
||||
?: CommunityState(membership, null, emptyList())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Reading
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
||||
* keeps it current.
|
||||
*/
|
||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
||||
store?.cachedRumors(channelIdHex).orEmpty()
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Planes
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rebuilds the plane index and the Community read model from the memberships and folds we hold.
|
||||
*
|
||||
* Returns the communities whose set of plane addresses changed, because those are exactly the
|
||||
* ones whose subscription is now stale — a Control edition that adds a Channel adds an address.
|
||||
*/
|
||||
private fun refreshPlanes(): Set<String> {
|
||||
val previous = planes
|
||||
val next = mutableMapOf<String, Plane>()
|
||||
val states = mutableListOf<CommunityState>()
|
||||
|
||||
for (membership in _memberships.value) {
|
||||
val communityId = membership.communityId.hex32()
|
||||
val root = membership.communityRoot.hex32()
|
||||
val fold = folds[membership.communityId]
|
||||
val granted = membership.channels.associateBy { it.id }
|
||||
|
||||
if (communityId != null && root != null) {
|
||||
// Control is write-restricted: we hold the read key plus the writers' pubkey, which
|
||||
// is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a
|
||||
// legacy, pre-split Community, whose plane was addressed by the `concord/control`
|
||||
// derivation itself; v1 does not read those, so such a Community shows no metadata.
|
||||
// CORD-02 §5 requires that legacy reading and the first base rotation upgrades it.
|
||||
membership.controlPk?.let { controlPkHex ->
|
||||
if (controlPkHex.hex32() != null) {
|
||||
val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
||||
next[key.streamPublicKeyHex] = Plane(
|
||||
communityIdHex = membership.communityId,
|
||||
role = PlaneRole.Control,
|
||||
key = key,
|
||||
scopeIdHex = membership.communityId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch)
|
||||
next[guestbook.streamPublicKeyHex] = Plane(
|
||||
communityIdHex = membership.communityId,
|
||||
role = PlaneRole.Guestbook,
|
||||
key = guestbook,
|
||||
scopeIdHex = membership.communityId,
|
||||
)
|
||||
}
|
||||
|
||||
val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct()
|
||||
val channels = channelIds.mapNotNull { channelIdHex ->
|
||||
val meta = fold?.channels?.get(channelIdHex)
|
||||
if (meta?.deleted == true) return@mapNotNull null
|
||||
|
||||
val stored = granted[channelIdHex]
|
||||
val channelId = channelIdHex.hex32()
|
||||
// A Public Channel's key *is* the community_root, so one we were never granted is
|
||||
// still derivable; a Private one is not (CORD-03 §1).
|
||||
val secret = stored?.key?.hex32()
|
||||
?: if (meta?.isPrivate != true) root else null
|
||||
val epoch = stored?.epoch ?: membership.rootEpoch
|
||||
|
||||
var plane: PlaneKey? = null
|
||||
if (channelId != null && secret != null) {
|
||||
plane = channelPlaneKey(secret, channelId, epoch)
|
||||
next[plane.streamPublicKeyHex] = Plane(
|
||||
communityIdHex = membership.communityId,
|
||||
role = PlaneRole.Channel,
|
||||
key = plane,
|
||||
scopeIdHex = channelIdHex,
|
||||
)
|
||||
}
|
||||
|
||||
ConcordChannel(
|
||||
idHex = channelIdHex,
|
||||
name = meta?.name ?: stored?.name ?: channelIdHex.take(8),
|
||||
private = meta?.isPrivate ?: stored?.private ?: false,
|
||||
epoch = epoch,
|
||||
hasKey = plane != null,
|
||||
)
|
||||
}
|
||||
|
||||
states += CommunityState(
|
||||
membership = membership,
|
||||
meta = fold?.meta,
|
||||
channels = channels.sortedBy { it.name.lowercase() },
|
||||
)
|
||||
}
|
||||
|
||||
planes = next
|
||||
_communities.value = states
|
||||
|
||||
return _memberships.value
|
||||
.filter { membership ->
|
||||
previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId)
|
||||
}
|
||||
.map { it.communityId }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
|
||||
private suspend fun refreshControl(communityIdHex: String) {
|
||||
val store = store ?: return
|
||||
val editions = store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
|
||||
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
|
||||
|
||||
if (communityIdHex in refreshPlanes()) {
|
||||
_memberships.value.firstOrNull { it.communityId == communityIdHex }?.let { subscribeCommunity(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to one Community: connect its relays, then ask for every plane's address.
|
||||
*
|
||||
* `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones,
|
||||
* so plane addresses are the finest granularity available. That is the right level anyway — one
|
||||
* key per plane — and the `channel`/`epoch` binding is a strict check after decryption.
|
||||
*/
|
||||
private suspend fun subscribeCommunity(membership: Membership) {
|
||||
val client = nostr.client ?: return
|
||||
val id = subscriptionId(membership.communityId)
|
||||
client.unsubscribe(id)
|
||||
|
||||
val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct()
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
// Always the Community's own relays, never the app's defaults: Concord reverses NIP-59
|
||||
// (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops
|
||||
// these wraps, and the bootstrap set is tuned for NIP-17.
|
||||
relays.forEach { relay ->
|
||||
client.addRelay(relay)
|
||||
client.connectRelay(relay)
|
||||
}
|
||||
|
||||
val filters = planes.values
|
||||
.filter { it.communityIdHex == membership.communityId }
|
||||
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
|
||||
.distinct()
|
||||
.map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) }
|
||||
if (filters.isEmpty()) return
|
||||
|
||||
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
|
||||
}
|
||||
|
||||
/** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */
|
||||
private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) {
|
||||
val client = nostr.client ?: return
|
||||
val communityId = membership.communityId.hex32() ?: return
|
||||
val root = membership.communityRoot.hex32() ?: return
|
||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
|
||||
// CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what
|
||||
// makes per-link usage counters possible at all.
|
||||
val extraTags = invite?.creatorNpub?.let { creator ->
|
||||
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
||||
}.orEmpty()
|
||||
|
||||
val rumor = plane.rumor(
|
||||
author = author,
|
||||
kind = ConcordKind.JOIN_LEAVE.toUShort(),
|
||||
content = "join",
|
||||
createdAt = Clock.System.now(),
|
||||
extraTags = extraTags,
|
||||
)
|
||||
val wrap = plane.wrap(rumor, nostr.signer)
|
||||
|
||||
val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none())
|
||||
}
|
||||
|
||||
private fun subscriptionId(communityIdHex: String): String = "$SUBSCRIPTION_PREFIX$communityIdHex"
|
||||
|
||||
private fun preview(
|
||||
invite: CommunityInvite,
|
||||
link: String?,
|
||||
fallbackRelays: List<String>,
|
||||
): InvitePreview {
|
||||
val relays = invite.relaySet(fallbackRelays)
|
||||
val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
||||
return InvitePreview(
|
||||
link = link,
|
||||
invite = invite,
|
||||
relays = relays,
|
||||
problems = problems,
|
||||
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
|
||||
alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SUBSCRIPTION_PREFIX = "concord:"
|
||||
}
|
||||
}
|
||||
|
||||
/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */
|
||||
private enum class PlaneRole { Control, Guestbook, Channel }
|
||||
|
||||
/**
|
||||
* One plane address we hold: the key to decrypt it, the role that says how to fold it, and the
|
||||
* [scopeIdHex] it caches under.
|
||||
*/
|
||||
private class Plane(
|
||||
val communityIdHex: String,
|
||||
val role: PlaneRole,
|
||||
val key: PlaneKey,
|
||||
val scopeIdHex: String,
|
||||
)
|
||||
|
||||
private fun Map<String, Plane>.planeKeys(communityIdHex: String): Set<String> =
|
||||
filterValues { it.communityIdHex == communityIdHex }.keys
|
||||
|
||||
/** The first content value of a tag, or null. Used for tags read without their companion fields. */
|
||||
private fun Event.tagValue(kind: String): String? =
|
||||
tags().toVec().firstOrNull { it.kind() == kind }?.content()
|
||||
@@ -0,0 +1,254 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Concord's data model: what we persist about a Community, what an invite carries, and what the
|
||||
* Control fold projects.
|
||||
*
|
||||
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
|
||||
* keep the spec's snake_case field names because those keys are normative. Types that are ours
|
||||
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
|
||||
*
|
||||
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
|
||||
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
|
||||
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
|
||||
* them back must preserve what it did not parse.
|
||||
*/
|
||||
internal val concordJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Persisted
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Community this device has joined, as stored locally. Holding these keys *is* membership
|
||||
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
|
||||
*
|
||||
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
|
||||
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
|
||||
*/
|
||||
@Serializable
|
||||
data class Membership(
|
||||
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
|
||||
val communityId: String,
|
||||
/** Owner x-only pubkey hex, the other half of the `community_id` commitment. */
|
||||
val owner: String,
|
||||
/** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */
|
||||
val ownerSalt: String,
|
||||
/** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */
|
||||
val communityRoot: String,
|
||||
/** Epoch the root was issued at (CORD-02 §3). */
|
||||
val rootEpoch: ULong,
|
||||
/** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */
|
||||
val controlPk: String? = null,
|
||||
/** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */
|
||||
val controlRoot: String? = null,
|
||||
/** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */
|
||||
val relays: List<String> = emptyList(),
|
||||
/** Last known name, cached so a community renders before its Control fold lands. */
|
||||
val name: String? = null,
|
||||
/** Channels this invite actually granted a key for. */
|
||||
val channels: List<StoredChannelKey> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
|
||||
* it here, which is why [private] can only be a hint — the Control fold is the authority.
|
||||
*/
|
||||
@Serializable
|
||||
data class StoredChannelKey(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val epoch: ULong,
|
||||
val name: String? = null,
|
||||
val private: Boolean = false,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Invite bundle (CORD-05 §1)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
|
||||
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
|
||||
*
|
||||
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
|
||||
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
|
||||
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
|
||||
*/
|
||||
@Serializable
|
||||
data class CommunityInvite(
|
||||
@SerialName("community_id") val communityId: String = "",
|
||||
val owner: String = "",
|
||||
@SerialName("owner_salt") val ownerSalt: String = "",
|
||||
@SerialName("community_root") val communityRoot: String = "",
|
||||
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
|
||||
@SerialName("control_pk") val controlPk: String? = null,
|
||||
val channels: List<InviteChannel> = emptyList(),
|
||||
val relays: List<String> = emptyList(),
|
||||
val name: String? = null,
|
||||
val icon: ConcordIcon? = null,
|
||||
/** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */
|
||||
@SerialName("expires_at") val expiresAt: Long? = null,
|
||||
@SerialName("creator_npub") val creatorNpub: String? = null,
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */
|
||||
@Serializable
|
||||
data class InviteChannel(
|
||||
val id: String = "",
|
||||
val key: String = "",
|
||||
val epoch: ULong = 0u,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext
|
||||
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConcordIcon(
|
||||
val url: String? = null,
|
||||
val key: String? = null,
|
||||
val nonce: String? = null,
|
||||
val hash: String? = null,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Control fold
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
||||
@Serializable
|
||||
data class CommunityMeta(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
/** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */
|
||||
val relays: List<String> = emptyList(),
|
||||
val icon: ConcordIcon? = null,
|
||||
val banner: ConcordIcon? = null,
|
||||
/** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */
|
||||
@SerialName("message_expiration") val messageExpiration: Long? = null,
|
||||
)
|
||||
|
||||
/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */
|
||||
@Serializable
|
||||
data class ChannelMeta(
|
||||
val name: String? = null,
|
||||
@SerialName("private") val isPrivate: Boolean = false,
|
||||
/** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
|
||||
*
|
||||
* The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate,
|
||||
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
|
||||
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
|
||||
*/
|
||||
data class ControlEdition(
|
||||
val vsk: Int,
|
||||
val eidHex: String,
|
||||
val version: ULong,
|
||||
val prevHashHex: String?,
|
||||
val content: String,
|
||||
val actorHex: String,
|
||||
val rumorIdHex: String,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
/** The projected Control state v1 uses: the Community's metadata and its Channel list. */
|
||||
data class ControlFold(
|
||||
val meta: CommunityMeta?,
|
||||
val channels: Map<String, ChannelMeta>,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Read surface
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
|
||||
data class ConcordChannel(
|
||||
val idHex: String,
|
||||
val name: String,
|
||||
val private: Boolean,
|
||||
val epoch: ULong,
|
||||
/** False for a Private Channel we were never granted — listable, but not readable. */
|
||||
val hasKey: Boolean,
|
||||
)
|
||||
|
||||
/** A Community with its Control fold applied. */
|
||||
data class CommunityState(
|
||||
val membership: Membership,
|
||||
/** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */
|
||||
val meta: CommunityMeta?,
|
||||
val channels: List<ConcordChannel>,
|
||||
)
|
||||
|
||||
/** A Chat-plane message, already unwrapped and signature-checked. */
|
||||
data class ConcordMessage(
|
||||
val idHex: String,
|
||||
val author: String,
|
||||
val content: String,
|
||||
val createdAt: Instant,
|
||||
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
||||
val timestampMs: Long,
|
||||
)
|
||||
|
||||
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
|
||||
data class InvitePreview(
|
||||
/** Null for a Direct Invite, which arrives with no link. */
|
||||
val link: String?,
|
||||
val invite: CommunityInvite,
|
||||
/** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */
|
||||
val relays: List<String>,
|
||||
/** Non-empty means the bundle is refused — these are the reasons, in plain English. */
|
||||
val problems: List<String>,
|
||||
val expired: Boolean,
|
||||
val alreadyJoined: Boolean,
|
||||
) {
|
||||
val joinable: Boolean get() = problems.isEmpty() && !expired
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix
|
||||
* **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so
|
||||
* every conversion lives here. Returns null when the bundle carries no expiry at all.
|
||||
*/
|
||||
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
|
||||
|
||||
/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */
|
||||
internal fun UnsignedEvent.concordTimestampMs(): Long {
|
||||
val ms = tags().toVec()
|
||||
.firstOrNull { it.kind() == ConcordTag.MS }
|
||||
?.content()
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it in 0..999 }
|
||||
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
|
||||
}
|
||||
|
||||
/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */
|
||||
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
||||
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
|
||||
return ConcordMessage(
|
||||
idHex = id()?.toHex().orEmpty(),
|
||||
author = author().toHex(),
|
||||
content = content(),
|
||||
createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()),
|
||||
timestampMs = concordTimestampMs(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package su.reya.coop.concord
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.nostr.Nostr
|
||||
|
||||
/**
|
||||
* Concord's local persistence, in the two places the rest of the app already keeps things.
|
||||
*
|
||||
* **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
|
||||
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
|
||||
* through plaintext storage, and they never touch LMDB.
|
||||
*
|
||||
* **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way
|
||||
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
|
||||
* Control fold has something to fold before the network answers.
|
||||
*/
|
||||
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
||||
|
||||
suspend fun loadMemberships(): List<Membership> {
|
||||
val raw = try {
|
||||
storage.getSecret(MEMBERSHIPS_KEY)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: could not read memberships: ${e.message}")
|
||||
return emptyList()
|
||||
} ?: return emptyList()
|
||||
|
||||
return try {
|
||||
concordJson.decodeFromString<List<Membership>>(raw)
|
||||
} catch (e: Exception) {
|
||||
println("Concord: stored memberships are unreadable: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveMemberships(memberships: List<Membership>) {
|
||||
try {
|
||||
storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Concord: could not store memberships: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
|
||||
* community id for Control and Guestbook.
|
||||
*
|
||||
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
|
||||
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
|
||||
* message would replace the one before it and history would vanish silently. The wrap id is
|
||||
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it —
|
||||
* and it doubles as the dedupe key when a wrap arrives from several relays.
|
||||
*/
|
||||
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
||||
try {
|
||||
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
||||
.tags(
|
||||
listOf(
|
||||
Tag.identifier(wrapIdHex),
|
||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
||||
)
|
||||
)
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
nostr.client?.database()?.saveEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: failed to cache rumor: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Every cached rumor for one scope, oldest first. */
|
||||
suspend fun cachedRumors(scopeIdHex: String): List<UnsignedEvent> {
|
||||
val events = try {
|
||||
val filter = Filter()
|
||||
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
|
||||
.reference(scopeIdHex)
|
||||
nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Concord: failed to read cached rumors: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return events
|
||||
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
|
||||
.sortedBy { it.createdAt().asSecs() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MEMBERSHIPS_KEY = "concord_memberships"
|
||||
|
||||
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
|
||||
const val INDEX_SCOPE_TAG = "r"
|
||||
const val INDEX_KIND_TAG = "k"
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import rust.nostr.sdk.SleepWhenIdle
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.initLogger
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.concord.ConcordManager
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -50,6 +52,7 @@ class Nostr(
|
||||
val messages = MessageManager(this)
|
||||
val profiles = ProfileManager(this)
|
||||
val relays = RelayManager(this)
|
||||
val concord = ConcordManager(this)
|
||||
|
||||
private val isInitialized = MutableStateFlow(false)
|
||||
|
||||
@@ -64,10 +67,18 @@ class Nostr(
|
||||
_newEvents.tryEmit(event)
|
||||
}
|
||||
|
||||
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
storage: AppStorage,
|
||||
logLevel: LogLevel = LogLevel.WARN,
|
||||
) {
|
||||
try {
|
||||
if (isInitialized.value) return
|
||||
|
||||
// Concord's keys live in the encrypted store, which only the Android layer can build.
|
||||
// Handing it over here keeps it out of the Context-free singleton's constructor.
|
||||
concord.attach(storage)
|
||||
|
||||
// Initialize the logger for nostr client
|
||||
initLogger(logLevel)
|
||||
|
||||
@@ -144,16 +155,35 @@ class Nostr(
|
||||
val notifications = client?.notifications() ?: return@supervisorScope
|
||||
|
||||
val giftWrapQueue = Channel<Event>(1024)
|
||||
val concordQueue = Channel<Event>(1024)
|
||||
|
||||
var processedCount = 0
|
||||
var eoseReceived = false
|
||||
|
||||
try {
|
||||
concord.restore()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to restore Concord state: ${e.message}")
|
||||
}
|
||||
|
||||
launch(defaultDispatcher) { concord.sync() }
|
||||
|
||||
launch(defaultDispatcher) {
|
||||
for (event in concordQueue) {
|
||||
try {
|
||||
concord.handlePlaneEvent(event)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to handle Concord plane event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch(defaultDispatcher) {
|
||||
for (event in giftWrapQueue) {
|
||||
val rumor = messages.extractRumor(event)
|
||||
processedCount++
|
||||
|
||||
// Trigger new message notification
|
||||
if (rumor != null) {
|
||||
if (rumor != null && !concord.onInboxRumor(rumor)) {
|
||||
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
||||
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
||||
|
||||
@@ -214,7 +244,11 @@ class Nostr(
|
||||
}
|
||||
|
||||
KindStandard.GIFT_WRAP -> {
|
||||
giftWrapQueue.send(event)
|
||||
if (concord.isPlaneAddress(event.author().toHex())) {
|
||||
concordQueue.send(event)
|
||||
} else {
|
||||
giftWrapQueue.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
|
||||
Reference in New Issue
Block a user