aboutsummaryrefslogtreecommitdiffstats
path: root/src/concord.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 19:03:06 -0700
committerAdam Soltys <[email protected]>2026-09-14 19:03:06 -0700
commit718d7f2bf077ea27ced17c94f643592003e6bacc (patch)
treea4bae2678468de7d4f6a23ae16a869bea3fe7811 /src/concord.js
parent3bf5557d8ea3d45779f6643d40c18629362d78e3 (diff)
downloadcoinosv3-718d7f2bf077ea27ced17c94f643592003e6bacc.tar.gz
coinosv3-718d7f2bf077ea27ced17c94f643592003e6bacc.zip
A community that has been refounded reads and writes at its current epoch.
Vector communities (Nostr Talk, FOSS Apps) sit at root epoch 1 after a refounding. coinos derived every channel stream at epoch 0, so it read and posted to an address nobody else used: five messages from coinos users where Vector showed months of history. Public channels now follow the base epoch (CORD-03), private ones their own key and epoch from the join material; the epoch tag on every rumor matches the stream that carried it. The Control Plane is read at the invite's control_pk (CORD-02 §5 split) and decrypted under the root-derived key, so metadata, icon and channel definitions fold for a post-split community; the legacy derivation is kept for the built-in coinos community, which still writes through it. Guestbook snapshots (3312) from the owner seed members after a refounding, so the count matches Vector's. Join material carries control_pk and private channel keys through the Community List and direct invites, and the list merge heals an entry that predates them. A pasted invite previews under the Communities heading where the paste box was, instead of at the top of a page the user had scrolled past. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/concord.js')
-rw-r--r--src/concord.js22
1 files changed, 20 insertions, 2 deletions
diff --git a/src/concord.js b/src/concord.js
index 74c1313..63a83b9 100644
--- a/src/concord.js
+++ b/src/concord.js
@@ -77,6 +77,15 @@ export const guestbookKey = (root, cid, epoch = 0) =>
groupKey("concord/guestbook", root, hexToBytes(cid), epoch);
export const channelKey = (secret, channelId, epoch = 0) =>
groupKey("concord/channel", secret, hexToBytes(channelId), epoch);
+
+// CORD-03: a Public Channel derives from the community_root at the BASE
+// epoch (it rotates with every Refounding); a Private one carries its own
+// key and epoch in the join material. A channel entry with no key, or whose
+// key is the root itself, is public.
+export const channelIsPrivate = (jm, ch) => !!(ch && ch.key && ch.key !== jm.community_root);
+export const channelEpoch = (jm, ch) => (channelIsPrivate(jm, ch) ? ch.epoch || 0 : jm.root_epoch || 0);
+export const channelStream = (jm, ch) =>
+ channelKey(hexToBytes(channelIsPrivate(jm, ch) ? ch.key : jm.community_root), ch.id, channelEpoch(jm, ch));
export const grantEid = (cid, memberHex) =>
bytesToHex(hkdf32(hexToBytes(cid), "concord/grant", hexToBytes(memberHex)));
export const banlistEid = (cid) => bytesToHex(hkdf32(hexToBytes(cid), "concord/banlist", ZERO32));
@@ -218,7 +227,7 @@ export const foldControl = (entries, { ownerHex, cid }) => {
// ---- CORD-02 §5 guestbook fold ----
// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t })
-export const foldGuestbook = (entries, { nowMs, banned = new Set() } = {}) => {
+export const foldGuestbook = (entries, { nowMs, banned = new Set(), ownerHex } = {}) => {
let members = new Map();
let consider = (npub, state, t, id) => {
let cur = members.get(npub);
@@ -229,7 +238,16 @@ export const foldGuestbook = (entries, { nowMs, banned = new Set() } = {}) => {
if (t === null || (nowMs && t > nowMs + 3600_000)) continue;
if (rumor.kind === 3306 && (rumor.content === "join" || rumor.content === "leave"))
consider(author, rumor.content, t, rumor.id);
- // kicks (3309) and snapshots (3312): honored later with roster ranks; skipped for now
+ // A snapshot (3312) seeds the new epoch's Guestbook after a Refounding:
+ // the refounder's secondhand list of present members, dated at the
+ // snapshot, so any self-signed entry newer than it still wins. Honored
+ // from the owner only (the sole authority coinos folds today).
+ else if (rumor.kind === 3312 && ownerHex && author === ownerHex) {
+ let list; try { list = JSON.parse(rumor.content); } catch { continue; }
+ if (!Array.isArray(list)) continue;
+ for (let npub of list.slice(0, 400)) if (/^[0-9a-f]{64}$/.test(npub)) consider(npub, "join", t, rumor.id);
+ }
+ // kicks (3309): honored later with roster ranks; skipped for now
}
for (let b of banned) members.delete(b);
return members;