update concord backend
This commit is contained in:
@@ -205,7 +205,7 @@ if (rumor != null && concord.onInboxRumor(rumor)) continue
|
||||
|
||||
### 5.4 Tests — **none are kept**
|
||||
|
||||
Decision: no test files are maintained for this feature. M1 and M2 were verified during development and the checks were then discarded, so the tree carries no `commonTest` or `iosTest` sources for Concord.
|
||||
Decision: no test files are maintained for this feature. M1–M4 were verified during development and the checks were then discarded, so the tree carries no Concord sources under `commonTest` or `iosTest`.
|
||||
|
||||
The consequence to plan around is in §14 and risk 13.
|
||||
|
||||
@@ -544,6 +544,28 @@ then `UnsignedEvent.fromJson(it.content())`. The Control plane uses the same mec
|
||||
|
||||
**Not implemented in v1:** authority resolution against the Roster (`vsk 1` / `vsk 3`), `vac` citation validation, and `vsk 4` banlist filtering. See [Risks](#13-risks-and-open-decisions).
|
||||
|
||||
### 9.4 Unread badges → memory only
|
||||
|
||||
Deliberately the same shape as the chat layer: `Room.unreadCount` is counted in memory, incremented on
|
||||
an incoming rumor and zeroed by `markAsRead`, and `ChatRepository.refreshChatRooms` explicitly carries
|
||||
the in-memory value across a reload rather than recomputing it. Concord mirrors that exactly.
|
||||
|
||||
- `ConcordChannel.unreadCount` is the model the UI reads, and `ConcordManager.refreshPlanes` merges it
|
||||
from an in-memory `Map<channelIdHex, Int>` so a Control edition cannot silently clear every badge.
|
||||
- A message is counted only when it is a `kind 9` on a plane we hold, authored by someone other than us
|
||||
— our own wraps come back through the same subscription, and a re-fetch arrives several times.
|
||||
- It does **not** survive a restart. Neither does a DM's. Persisting it would mean a read-marker store
|
||||
(a new key, a new write path) for a badge, which is not worth it in v1.
|
||||
|
||||
### 9.5 Message freshness → `revision`
|
||||
|
||||
`channelMessages` is an on-demand LMDB query, and nothing in the routing branch told a screen that a
|
||||
message had arrived. `ConcordManager.revision` is that signal: a `StateFlow<Long>` counter bumped on
|
||||
every cached rumor and every local send, which a screen combines with its Channel id to re-query. A
|
||||
counter rather than a set of changed scope ids, because two messages in one Channel must both be
|
||||
observable. It is one value for the whole manager rather than a flow per Channel, so nothing has to be
|
||||
created, subscribed and disposed per room.
|
||||
|
||||
---
|
||||
|
||||
## 10. Invites
|
||||
@@ -735,15 +757,30 @@ 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)
|
||||
4. a message sent from Coop appears there too — the M4 half of the same gate
|
||||
|
||||
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
|
||||
If step 4 fails while step 3 passes, the read path is right and the fault is in the publish: the relay set, or the relay dropping these wraps (risk 4).
|
||||
|
||||
### M4 — Write
|
||||
### M4 — Write — **done**
|
||||
|
||||
`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.)
|
||||
`ConcordManager.sendChannelMessage`, `unreadCount` / `markChannelRead`, and the `revision` signal a Channel screen re-queries on.
|
||||
|
||||
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
|
||||
What shipped:
|
||||
|
||||
- `sendChannelMessage(channelIdHex, content)` — the send *is* `PlaneKey.rumor` + `PlaneKey.wrap`, so there is no second encrypter to keep in sync with the read path. The rumor is cached locally **before** publishing, so the message is visible even with every relay down, and the subscription's echo of the wrap lands on the same `d` slot instead of duplicating the message. Throws when the Channel is one we hold no key for — a Private Channel is listable without being writable.
|
||||
- `unreadCount` / `markChannelRead` and `ConcordChannel.unreadCount` (§9.4), counted from the routing branch rather than from a re-read, so a badge costs no LMDB query per message.
|
||||
- `revision: StateFlow<Long>` (§9.5), so a Channel screen can tell that something arrived.
|
||||
|
||||
**Verified** with a throwaway check on the iOS target (2 cases, all passing), then deleted:
|
||||
|
||||
- a message delivered on a Channel plane is routed and raises that Channel's badge — in the map and in `ConcordChannel.unreadCount` — while our own message and a `kind 7` reaction raise nothing ✅
|
||||
- `markChannelRead` clears the badge and republishes it, and a re-index carries the badge across rather than resetting it ✅
|
||||
- a sent rumor carries exactly `channel` / `epoch` / `ms` at `kind 9`, and the Guestbook and a different Channel both refuse to read it ✅
|
||||
|
||||
**Not covered, and not coverable offline:** `sendChannelMessage` needs a live `client`, so the publish itself — relay selection, the ack policy, the relay's echo landing on the same `d` — is exercised only by the interop smoke test. The check above used a `MemoryStorage` fake, so the real Android Keystore path is untouched by it too.
|
||||
|
||||
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop. **This is a network criterion and has not been run** — see §14.
|
||||
|
||||
### M4.5 — Community creation (optional)
|
||||
|
||||
@@ -791,6 +828,8 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
|
||||
| 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. |
|
||||
| 15 | **Unread badges live in memory only**, so a restart clears every one of them (§9.4). | This is the DM path's behaviour too (`Room.unreadCount` is likewise in-memory). Persisting it means a read-marker store, a new key and a new write path, all for a badge — worth doing only when a user asks for it. |
|
||||
| 16 | **The write path has no offline check.** `sendChannelMessage` needs a live `client`, so nothing kept in the repo exercises a publish, and the `MemoryStorage` fake used by the M4 check never touches Android Keystore. | Acceptance is interop smoke test step 4. Read and write share `PlaneKey.wrap`, and reading is verified independently (M2/M3), so a send-only failure localises to the publish: the relay set, or a relay dropping the wrap (risk 4). |
|
||||
|
||||
### 13.3 Open decision
|
||||
|
||||
@@ -800,8 +839,8 @@ 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–M3,
|
||||
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
|
||||
**No test files are kept** (decision, M2). The checks below were written and run during M1–M4,
|
||||
then discarded; the tree carries no Concord sources under `commonTest` or `iosTest`. They are
|
||||
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.
|
||||
|
||||
@@ -840,8 +879,11 @@ there is no regression net, so:
|
||||
| `channel` / `epoch` mismatch and missing binding tags | negative | iOS ✅ M2 |
|
||||
| `20013` vs `20014` discipline, both directions | negative | iOS ✅ M2 |
|
||||
| Control readable from the read key + writers' pubkey alone; refuses to wrap; drops a non-staff wrap | integration | iOS ✅ M2 |
|
||||
| 32 bytes → base64url = 43 chars, unpadded | unit | ⏳ M3 |
|
||||
| `expires_at` ms/s conversion | unit | ⏳ M3 |
|
||||
| 32 bytes → base64url = 43 chars, unpadded | unit | iOS ✅ M3 |
|
||||
| `expires_at` ms/s conversion | unit | iOS ✅ M3 |
|
||||
| a delivered Channel message raises the badge; our own message and a non-message raise nothing | property | iOS ✅ M4 |
|
||||
| `markChannelRead` clears and republishes; a re-index carries the badge across | property | iOS ✅ M4 |
|
||||
| a sent rumor is exactly `channel`/`epoch`/`ms` at `kind 9`, refused by the Guestbook and another Channel | unit | iOS ✅ M4 |
|
||||
|
||||
**RFC 5869 Test Case 1** (for reference):
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import rust.nostr.sdk.SecretKey
|
||||
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
|
||||
*
|
||||
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
|
||||
* rather than loudly — so each function quotes the CORD section that governs it, and
|
||||
* `ConcordCryptoTest` pins the output against RFC 5869 vectors and independently computed
|
||||
* digests. Concord ships no test vectors of its own ("Examples are illustrative, not
|
||||
* verifiable test vectors").
|
||||
* rather than loudly — so each function quotes the CORD section that governs it, and each was
|
||||
* checked against RFC 5869 vectors and independently computed digests before it was written.
|
||||
* Concord ships no test vectors of its own ("Examples are illustrative, not verifiable test
|
||||
* vectors"), and no test file is kept — see PLAN.md §14.
|
||||
*
|
||||
* Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that
|
||||
* are available on every target this module builds for, so no new crypto dependency is
|
||||
@@ -27,7 +27,7 @@ import rust.nostr.sdk.SecretKey
|
||||
*
|
||||
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
|
||||
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
|
||||
* vectors — which do use a salt — can be used as tests; Concord publishes none.
|
||||
* vectors — which do use a salt — can be re-checked by hand; Concord publishes none.
|
||||
*
|
||||
* @param length output length in octets, `1..255 * 32` per RFC 5869.
|
||||
*/
|
||||
@@ -43,7 +43,7 @@ fun hkdfSha256(
|
||||
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
|
||||
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
|
||||
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
|
||||
// zero-length-salt vector is asserted against it in ConcordCryptoTest.
|
||||
// zero-length-salt vector was checked against it.
|
||||
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
|
||||
val prk = ikm.toByteString().hmacSha256(saltKey)
|
||||
|
||||
|
||||
@@ -56,6 +56,21 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
@Volatile
|
||||
private var folds: Map<String, ControlFold> = emptyMap()
|
||||
|
||||
/**
|
||||
* Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
|
||||
* `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
|
||||
*/
|
||||
@Volatile
|
||||
private var unread: Map<String, Int> = emptyMap()
|
||||
|
||||
/**
|
||||
* Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
|
||||
* has something to re-query on. A counter rather than a set of changed scopes, because two
|
||||
* messages to the same Channel must both be observable.
|
||||
*/
|
||||
private val _revision = MutableStateFlow(0L)
|
||||
val revision: StateFlow<Long> = _revision.asStateFlow()
|
||||
|
||||
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
|
||||
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
||||
|
||||
@@ -99,6 +114,12 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
val store = store ?: return
|
||||
|
||||
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
|
||||
_revision.update { it + 1 }
|
||||
|
||||
// A message from someone else is the only thing a badge counts. Our own wraps come back
|
||||
// through the same subscription and so does a re-fetch, so the check is on the rumor's
|
||||
// author rather than on the arrival.
|
||||
if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, 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.
|
||||
@@ -259,6 +280,91 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
.mapNotNull { it.toConcordMessage() }
|
||||
.sortedBy { it.timestampMs }
|
||||
|
||||
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
|
||||
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
|
||||
|
||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
||||
fun markChannelRead(channelIdHex: String) {
|
||||
if ((unread[channelIdHex] ?: 0) == 0) return
|
||||
unread = unread - channelIdHex
|
||||
publishUnread(channelIdHex, 0)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sends a message to a Channel (CORD-03 §3).
|
||||
*
|
||||
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries
|
||||
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
|
||||
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
|
||||
*
|
||||
* The message is cached locally *before* it is published, so it is visible even if every relay is
|
||||
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
|
||||
* duplicating it.
|
||||
*
|
||||
* Returns the message as a reader will see it. Throws when the Channel is hidden behind a key we
|
||||
* were never granted — a Private Channel is listable without being writable.
|
||||
*/
|
||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
|
||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
||||
val plane = planes.values.firstOrNull {
|
||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val rumor = plane.key.rumor(
|
||||
author = author,
|
||||
kind = ConcordKind.MESSAGE.toUShort(),
|
||||
content = content,
|
||||
createdAt = Clock.System.now(),
|
||||
)
|
||||
val wrap = plane.key.wrap(rumor, nostr.signer)
|
||||
|
||||
store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
|
||||
_revision.update { it + 1 }
|
||||
|
||||
// Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
|
||||
val relays = _memberships.value
|
||||
.firstOrNull { it.communityId == plane.communityIdHex }
|
||||
?.relays
|
||||
.orEmpty()
|
||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
||||
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
|
||||
|
||||
client.sendEvent(event = wrap, target = SendEventTarget.to(relays), ackPolicy = AckPolicy.none())
|
||||
.failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
|
||||
|
||||
return rumor.toConcordMessage()
|
||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
||||
}
|
||||
|
||||
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
|
||||
private fun publishUnread(channelIdHex: String, count: Int) {
|
||||
_communities.update { states ->
|
||||
if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
|
||||
states.map { state ->
|
||||
state.copy(
|
||||
channels = state.channels.map {
|
||||
if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
|
||||
private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
|
||||
if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
|
||||
if (rumor.author() == nostr.signer.publicKeyFlow.value) return
|
||||
|
||||
val count = (unread[channelIdHex] ?: 0) + 1
|
||||
unread = unread + (channelIdHex to count)
|
||||
publishUnread(channelIdHex, count)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Planes
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -337,6 +443,9 @@ class ConcordManager(private val nostr: Nostr) {
|
||||
private = meta?.isPrivate ?: stored?.private ?: false,
|
||||
epoch = epoch,
|
||||
hasKey = plane != null,
|
||||
// Carried across the re-index rather than recomputed: a Control edition must not
|
||||
// silently clear every badge, the same way ChatRepository preserves its counters.
|
||||
unreadCount = unread[channelIdHex] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,11 @@ data class ConcordChannel(
|
||||
val epoch: ULong,
|
||||
/** False for a Private Channel we were never granted — listable, but not readable. */
|
||||
val hasKey: Boolean,
|
||||
/**
|
||||
* Messages from others since this Channel was last opened. In memory only, exactly like
|
||||
* `Room.unreadCount`: it survives a Control re-fold, but not a restart.
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
)
|
||||
|
||||
/** A Community with its Control fold applied. */
|
||||
|
||||
Reference in New Issue
Block a user