| … | … | @@ -74,6 +74,18 @@ const REMOVE_DEVICE = '__remove_device__' |
| 74 | 74 | // A BANNED identity is NOT re-admitted by this op — REMOVED_SET stays authoritative, or a ban would be |
| 75 | 75 | // reversible by any member simply re-adding the target. Un-banning is deliberately a separate act. |
| 76 | 76 | const ADD_MEMBER = '__add_member__' |
| 77 | // Room ABI. Bumped when an op changes what APPLY does, because apply must be deterministic across |
| 78 | // every node or their views diverge. ADD_MEMBER is exactly such a change: a node that predates it does |
| 79 | // not treat `__add_member__` as reserved, so it INSERTS it as an ordinary entry while a current node |
| 80 | // inserts nothing — same log, two different views. Keet gates this class of change with an in-band ABI |
| 81 | // stamp and refuses to proceed under an unsupported one (skills/keet-abi-upgrades; pear-git-v3 does the |
| 82 | // same for its org schema). We cannot retrofit old nodes, so the gate is on the AUTHOR: refuse to emit |
| 83 | // an op no current writer can apply identically. |
| 84 | const ROOM_ABI = 1 |
| 85 | // Per-writer capability stamp: `__abi__/<writer key hex>` -> { v }. An ORDINARY entry, deliberately — |
| 86 | // old nodes store it byte-identically and stay convergent; they simply never write one of their own, |
| 87 | // which is precisely the signal we need. |
| 88 | const ABI_PREFIX = '__abi__/' |
| 77 | 89 | // Durable set of banned device identities (hex → { ts }). The admission guard checks it so a |
| 78 | 90 | // removed identity cannot (re)admit even with a valid attestation. |
| 79 | 91 | const REMOVED_SET = '__removed__' |
| … | … | @@ -298,6 +310,11 @@ export class StateBaseV3 { |
| 298 | 310 | // Awaited so the roster is in the view before ready() returns, hence before any consumer can wire up |
| 299 | 311 | // connections. A no-op when nothing was retained, when not writable, or when already projected. |
| 300 | 312 | await this._ensureMembersProjected().catch(() => {}) |
| 313 | // NO ABI announce at boot. Writing during _open is the fork hazard this file warns about throughout |
| 314 | // (the resync/joiner rule is to write NOTHING until replication converges) — and it reproduced here |
| 315 | // immediately: `[hypercore] conflict detected ... (writable=false,quorum=1)` followed by |
| 316 | // SESSION_CLOSED inside ApplyState._boot. The stamp is only ever needed when another writer is |
| 317 | // present to read it, so it is announced on connection and on admission instead. |
| 301 | 318 | // One-time in-place re-adopt migration for the namespace rename: if this is the writable creator |
| 302 | 319 | // and the (renamed) base is empty but a legacy-namespace base is present in the same store, copy |
| 303 | 320 | // its durable entries across. Best-effort + awaited so the room is seeded before consumers read. |
| … | … | @@ -485,6 +502,7 @@ export class StateBaseV3 { |
| 485 | 502 | // creator and haven't projected the roster yet, (re)project now so its admission verifies — |
| 486 | 503 | // instead of waiting on the slow promote-timer tick. Self-terminating + idempotent. |
| 487 | 504 | this._ensureMembersProjected().catch(() => {}) |
| 505 | this._announceAbi().catch(() => {}) // a peer arrived: stamp our ABI if we have not yet |
| 488 | 506 | }) |
| 489 | 507 | } |
| 490 | 508 | this.swarm.join(this.base.discoveryKey) |
| … | … | @@ -655,6 +673,7 @@ export class StateBaseV3 { |
| 655 | 673 | } |
| 656 | 674 | await this._updateBounded() |
| 657 | 675 | } |
| 676 | if (this.base.writable) this._announceAbi().catch(() => {}) // just admitted: we can stamp now |
| 658 | 677 | return { writable: this.base.writable, indexer: this._isLocalIndexer() } |
| 659 | 678 | } |
| 660 | 679 | |
| … | … | @@ -892,8 +911,18 @@ export class StateBaseV3 { |
| 892 | 911 | // it; apply authorizes by THIS caller's role. That is the whole point: membership becomes a fact of |
| 893 | 912 | // the ROOM, replicated to every device, instead of a roster in one machine's config file that only |
| 894 | 913 | // the creator could project (hyper-social #40 §6). |
| 895 | | async addMember (identityHex, role = 1) { |
| 914 | async addMember (identityHex, role = 1, { force = false } = {}) { |
| 896 | 915 | if (!this._ready) throw new Error('StateBaseV3 not ready') |
| 916 | // ABI GATE. Emitting this op into a room containing a writer that predates it makes the two apply |
| 917 | // it differently — the old node inserts `__add_member__` as an entry, we insert nothing — and a |
| 918 | // divergent apply is a forked view, which is far worse than the problem this op solves. Refuse and |
| 919 | // name the laggards. `force` exists for an operator who has verified the room out of band; it is |
| 920 | // never the default, and the message says who is missing so the check can be answered rather than |
| 921 | // bypassed blindly. |
| 922 | if (!force) { |
| 923 | const below = await this._writersBelowAbi(ROOM_ABI) |
| 924 | if (below.length) throw new Error('addMember: room ABI ' + ROOM_ABI + ' not supported by writer(s) ' + below.join(', ') + ' — upgrade them first (or pass force after verifying)') |
| 925 | } |
| 897 | 926 | const admit = await this._buildAdmissionProof() |
| 898 | 927 | if (!admit) throw new Error('addMember: no identity to authorize the admission') |
| 899 | 928 | await this.base.append(encode('@state/set-entry', { key: ADD_MEMBER, value: this._enc({ id: String(identityHex), role: Number(role) || 1 }), admit, ts: Date.now() }), { optimistic: true }) |
| … | … | @@ -975,6 +1004,41 @@ export class StateBaseV3 { |
| 975 | 1004 | } catch { return null } |
| 976 | 1005 | } |
| 977 | 1006 | |
| 1007 | // Announce THIS writer's room ABI. Written once per writer key, on the events that can make it true |
| 1008 | // (open-while-writable, and admission succeeding) — never on a timer. Idempotent: a re-announce of an |
| 1009 | // unchanged value is skipped, so this cannot become an append loop of its own. |
| 1010 | async _announceAbi () { |
| 1011 | try { |
| 1012 | if (!this._ready || !this.base || !this.base.writable) return false |
| 1013 | const localHex = this.base.local && this.base.local.key ? this.base.local.key.toString('hex') : null |
| 1014 | if (!localHex) return false |
| 1015 | const key = ABI_PREFIX + localHex |
| 1016 | const cur = await this.getNow(key).catch(() => null) |
| 1017 | if (cur && Number(cur.v) === ROOM_ABI) return false |
| 1018 | await this.set(key, { v: ROOM_ABI, ts: Date.now() }) |
| 1019 | return true |
| 1020 | } catch { return false } |
| 1021 | } |
| 1022 | |
| 1023 | // Which active writers have NOT announced support for `abi`? Returns an array of short keys; empty |
| 1024 | // means every writer in the room can apply the op identically. Fails CLOSED: a writer we cannot read |
| 1025 | // a stamp for counts as unsupported, because "no stamp" is exactly what a pre-ABI node looks like. |
| 1026 | async _writersBelowAbi (abi = ROOM_ABI) { |
| 1027 | const below = [] |
| 1028 | try { |
| 1029 | await this._updateBounded() |
| 1030 | const writers = (this.base && this.base.activeWriters) ? [...this.base.activeWriters] : [] |
| 1031 | for (const w of writers) { |
| 1032 | if (!w || w.isRemoved) continue // a removed writer applies nothing that matters |
| 1033 | const key = w.core && w.core.key ? w.core.key.toString('hex') : null |
| 1034 | if (!key) continue |
| 1035 | const stamp = await this.getNow(ABI_PREFIX + key).catch(() => null) |
| 1036 | if (!stamp || Number(stamp.v) < abi) below.push(key.slice(0, 12)) |
| 1037 | } |
| 1038 | } catch { /* fail closed via whatever was collected */ } |
| 1039 | return below |
| 1040 | } |
| 1041 | |
| 978 | 1042 | // ── One-time in-place re-adopt migration (namespace rename) ───────────────── |
| 979 | 1043 | // The 'hyper-tps/state/v3' -> 'atproto-room/state/v3' rename changes the derived base key, so a |
| 980 | 1044 | // room created under the old namespace opens here as a FRESH, empty base that shares the same |