import type { MeshClient } from "./client.js"; /** * Background poller that watches each Owner's `mesh_versions` envelope on * the relay and self-revokes this Pi from any Owner that no longer lists * it as a member. * * Behavior per sweep (one entry per unique Owner in peers.json): * 1. Compute `hash = sha256(ownerPk)` (lowercase hex) — the URL slug. * MUST match the format the relay stores and the app publishes; * mismatch results in silent 404 forever. * 2. GET /mesh/?since= * 3. `null` (304/404) → skip silently (no update, or owner never published) * 4. Verify Ed25519 signature against the embedded `owner_pk` * 5. Defense-in-depth: confirm the blob's `owner_pk` matches what we * expected (otherwise a malicious relay could swap blobs across slots) * 6. Anti-rollback: drop versions < our last-seen for this Owner * 7. Membership check: decode every `members[].remote_epk` to bytes and * compare against this Pi's pubkey bytes. Critical: comparing the * base64 strings directly would falsely revoke when the app emits * url-safe (`-`/`_`, no padding) and the Pi emits standard * (`+`/`/`, padded) — same 32 bytes, different strings. See * `encoding.ts` for the helpers and `plan/24` Wave 3 fix history. * 8. If not a member → `storage.removePeer(ownerEpk)` and fire * `onRevoke(ownerEpk)` so the caller can tear down any live WS * sessions for that Owner. * * Backward-compat: an Owner who never published a mesh blob returns 404 * forever, which we treat as "no update". Old clients keep working * untouched until they upgrade. * * Spec: plan/24-mesh-membership.md Wave 3. */ /** Minimum storage surface the poller needs. Concrete impl lives in * `src/pairing/storage.ts` — injecting it via constructor keeps the class * testable without filesystem mocking. */ export interface SelfRevokeStorage { listOwnerPubkeys(): Promise; removePeer(remoteEpk: string): Promise; } export interface SelfRevokeOptions { client: MeshClient; storage: SelfRevokeStorage; /** This Pi's long-term Ed25519 pubkey, raw 32 bytes. */ myPubkey: Uint8Array; /** Polling cadence. Default 60s — matches the app side (plan/24 Q1). */ intervalMs?: number; /** Fired after `storage.removePeer` succeeds, so callers can tear down * any active WS channel for the revoked owner. Receives the base64 * (standard) of the Owner pubkey that revoked us. */ onRevoke?: (ownerEpk: string) => void | Promise; /** Plan/25 Wave D: fired whenever the set of Pi-pubkeys present in any * Owner's mesh_versions changes (membership added, removed, or * relabeled). The callback receives the **union** of all current * Pi-pubkeys across every known Owner, minus this Pi's own pubkey, * so callers can keep `broker_remote.setSiblings()` in sync without * re-running discovery themselves. Fires once per `checkOnce()` sweep * only when the set genuinely differs from the previous sweep. */ onMembersChanged?: (siblings: SiblingInfo[]) => void | Promise; /** Logging surface — defaults to `console.*`. Tests inject a fake. */ log?: { info(msg: string): void; warn(msg: string): void; error(msg: string): void; }; } /** Sibling info surfaced by `onMembersChanged`. Stays bit-identical to the * shape `BrokerRemote.setSiblings` accepts so callers can pass through. */ export interface SiblingInfo { pcLabel: string; pcPubkey: string; } export declare class SelfRevoke { private readonly client; private readonly storage; /** Raw Ed25519 pubkey bytes (32 B). Membership checks decode each * `members[].remote_epk` and compare byte-wise — avoids the base64 * encoding-variant trap (standard vs url-safe). */ private readonly myPubkey; private readonly intervalMs; private readonly onRevoke?; private readonly onMembersChanged?; private readonly log; /** Anti-rollback floor: never accept a version <= lastSeen per Owner. */ private readonly lastSeenVersion; /** Plan/25 Wave D: snapshot of the sibling union from the previous * sweep, used to detect changes without re-firing `onMembersChanged` * on every poll. Keyed by `pcPubkey`. */ private prevSiblings; /** Latest member raw data per owner, captured during `_checkOwner`. * Stored as `(pcPubkey, nickname?)` (NOT pre-resolved label) so * `_computeSiblingUnion` can pick the best nickname across owners * — nickname always wins over fallback, regardless of owner iteration * order. See `siblings.ts::discoverSiblings` for the same rule. */ private readonly membersByOwner; private timer; constructor(opts: SelfRevokeOptions); /** Starts the periodic sweep. Idempotent — a second call is a no-op. * Fires one sweep immediately so we don't wait `intervalMs` for the * first check. */ start(): void; /** Stops the periodic sweep. In-flight `checkOnce()` calls complete * normally — only the timer is cleared. */ stop(): void; /** One sweep across all known Owners. Per-Owner errors are logged but * do not stop iteration — we want to keep checking other Owners even * if one relay times out or one envelope is malformed. */ checkOnce(): Promise; private _computeSiblingUnion; private _siblingSetChanged; private _checkOwner; }