| … | … | @@ -421,6 +421,13 @@ export class StateBaseV3 { |
| 421 | 421 | const wantIndexer = claimIndexer || startIndexing |
| 422 | 422 | const done = () => this.base.writable && (!wantIndexer || this._isLocalIndexer() === true) |
| 423 | 423 | if (done()) return { writable: true, already: true, indexer: this._isLocalIndexer() } |
| 424 | // FAIL FAST if this device has been BANNED. Its identity is in the durable, replicated |
| 425 | // __removed__ set, so the admission guard (_verifyAdmissionInApply) will reject every admit |
| 426 | // proof it ever sends — ringing the doorbell for the full timeoutMs is pointless and makes every |
| 427 | // write a removed device attempts stall ~timeoutMs before throwing. Throw immediately instead. |
| 428 | // Best-effort (see _isSelfRemoved): false on any uncertainty, so a non-banned device is never |
| 429 | // wrongly rejected — it just falls back to the normal doorbell. |
| 430 | if (await this._isSelfRemoved()) throw new Error('StateBaseV3: this device has been removed (banned) — cannot become a writer') |
| 424 | 431 | const admit = await this._buildAdmissionProof() |
| 425 | 432 | if (!admit) throw new Error('StateBaseV3 cannot request writer: no membership/identity to build an admission proof') |
| 426 | 433 | // key MUST be a string (encoder always writes it; undefined crashes compact- |
| … | … | @@ -622,6 +629,23 @@ export class StateBaseV3 { |
| 622 | 629 | return false |
| 623 | 630 | } |
| 624 | 631 | |
| 632 | // Out-of-apply self-ban check: is THIS device's own identity in the durable __removed__ set (a ban |
| 633 | // that has replicated to us)? Read against the linearized view exactly like getProjectedRole. Used |
| 634 | // by requestWriter to fail fast instead of ringing a doorbell the admission guard will never |
| 635 | // answer. Best-effort: returns false on any uncertainty (no selfKey / view not ready / parse |
| 636 | // error), so it can never wrongly reject a non-banned device — worst case falls back to the |
| 637 | // normal doorbell timeout. |
| 638 | async _isSelfRemoved () { |
| 639 | try { |
| 640 | if (!this._ready || !this.base || !this._selfKey) return false |
| 641 | await this.base.update() |
| 642 | const rrec = await this.base.view.get(ENTRIES, { key: REMOVED_SET }) |
| 643 | const removed = rrec && rrec.value ? JSON.parse(rrec.value) : {} |
| 644 | const hex = this._selfKey.toString('hex') |
| 645 | return !!(removed[hex] || removed[normKeyHex(hex)]) |
| 646 | } catch { return false } |
| 647 | } |
| 648 | |
| 625 | 649 | // ── Membership projection (cross-base sync) ──────────────── |
| 626 | 650 | // Daemon's out-of-apply watcher copies keet-room membership in so apply can |
| 627 | 651 | // gate admission deterministically. Stored under the reserved key __members__ |