// Who a replica will talk to — connection authorization (design §18, plan phase 3.4). // // This file is about **connections, not the index**, and the two vocabularies stay separate on // purpose. Nothing here is a gate. Gate 1 admits envelopes and gate 2 folds them, and both run in // full on everything a peer hands over — an authorized peer that offers a forged envelope gets // exactly as far as a stranger would. // // So what is this for? Two things a transport genuinely needs and cannot get anywhere else: // // - **Whom to dial.** A replica catching up has to turn "the space" into endpoints, and the // addresses in the public directory are the only answer. `connectablePeers()` is that list. // - **Whom to serve.** A private corpus must not be enumerable by anyone who computed the topic. // A topic is derived from a public URI and confers nothing (`wire.ts`), so serving on topic // knowledge alone would hand the space's shape — how many records, whose, how often — to anyone // who knows it exists. `authorizeEndpoint()` is the check that runs before catch-up is served. // // **Retirement is the absence of an address, not an inference about age.** Design §18's rotation is // "publish a new `device` record; the old key is retired — excluded from new connections at the // transport layer — while its old binding and history keep counting". That leaves open which key // is "the old one", and the tempting answer — newest `createdAt` per DID wins — is wrong: a member // with a laptop and a phone has two current devices of the same kind, and retiring one by date would // stop the older one connecting while it is still in daily use. The mechanical answer is the one the // owner controls: a device is dialable while it publishes a `deviceAddress`, and retiring a key is // ceasing to publish an address for it. Rotation stays fold-neutral either way, because addresses // are excluded from the device fold by construction. // // Everything here is a pure function of the same record set the fold reads, so two replicas holding // the same directory reach the same conclusions — a property worth having even for a decision that // is deliberately observer-local, because it is what makes "why did that peer refuse me?" answerable // from a directory dump rather than from a log on the other machine. import type { DeviceBinding, DeviceDirectory } from '../devices.js' import { deviceKey, readDeviceDirectory } from '../devices.js' import { everMemberDids } from '../materializer.js' import { compareCodePoints } from '../order.js' import type { StoredRecord } from '../store.js' /** A device a replica can dial, as the public directory currently describes it. */ export interface PeerIdentity { did: string deviceKeyId: string /** The transport's endpoint identity, from the owner's `deviceAddress`. Discovery, never trust. */ endpointId: string relays?: string[] } /** * Why an endpoint was refused, as something to branch on rather than a string to read. * * `unknown` is the one that means "ask again": an endpoint absent from this replica's directory is * usually a device published since the last poll, so a caller may refresh the directory *boundedly* * A **retired** device has no status of its own, and that is the honest shape rather than a missing * one: retirement is the absence of a published address (`retireDeviceAddress`), so a retired * endpoint is one the directory does not name — `unknown`, arrived at by the same path as a typo. * What distinguishes it is that the refresh a caller does on `unknown` finds nothing, which is * precisely what "excluded from new connections" means. It is also why that retry has to be bounded. * * Reporting it as its own refusal was considered and rejected. The peer being refused is, in the * case this exists for, the one holding a stolen key: a status telling them *why* they are refused * is a status telling them whether the theft has been noticed, and it would buy this replica * nothing, since `unknown` and a retirement are already the same instruction to the dialler. */ export type PeerRefusal = 'unknown' export type PeerAuthorization = | { ok: true; peer: PeerIdentity } | { ok: false; status: PeerRefusal; reason: string } /** * The directory as CONNECTION decisions must read it (ADR §30.2). * * `readDeviceDirectory`, then — once this record set holds a space record to fold membership * against — kept to the devices of DIDs that have ever been members here. `authorizeEndpoint` reads * this result for every inbound frame. Outbound catch-up uses `catchUpDirectory` below so a partial * replica can recover genesis without serving or describing what it already holds. * * The three states are the three footings a replica can be on: * * - **No private projections — bootstrapping.** The directory passes through unfiltered, because * a replica holding no corpus has nothing to leak by dialling or serving whoever its directory * names (ADR §30). * - **Private projections but no space record — partial catch-up.** Nobody is served. Envelope * storage is atomic with EACH projection, not with the genesis projection, so gossip or a * bounded catch-up may put private records here before the space record that lets membership * fold. `catchUpDirectory` below still lets the replica dial for the missing genesis, while the * ingestor withholds summaries and wants until it arrives (ADR §30.3). * - **Space record present — a corpus.** Only ever-members remain. "Ever" and not "active", * because the non-member `authorizeEndpoint` is written for is a REMOVED member: they already * hold the corpus, so refusing their connection would disclose the removal without protecting * anything. A DID with a directory entry and no grant — a stranger polled off a claim while * this replica was cold — is exactly what this exists to exclude. * * A pure function of the record set, like everything else in this file, so two replicas holding the * same records refuse the same peers. */ export function connectionDirectory( records: readonly StoredRecord[], spaceUri: string, ignored: Array<{ uri: string; reason: string }>, ): DeviceDirectory { const directory = readDeviceDirectory(records, ignored) const members = everMemberDids(records, spaceUri) if (!members) { // A projection carrying signers proves this replica holds at least one private envelope. Without // genesis there is no membership answer, so the only safe serve roster is nobody. Do not confuse // this with a truly cold replica: per-envelope atomicity does not make genesis arrive first. if (!records.some((record) => record.deviceKeyIds !== undefined)) return directory for (const binding of directory.bindings.values()) { ignored.push({ uri: binding.uri, reason: 'space membership is unavailable while private envelopes are present', }) } return { bindings: new Map(), addresses: new Map(), retired: new Map() } } return memberDirectory(directory, members, ignored) } /** * The directory used only to dial OUT for catch-up. * * A partial replica must refuse inbound frames (`connectionDirectory`) but must keep a route to the * peer that can supply its missing genesis. Until genesis arrives this therefore returns the raw * public directory. The caller must reveal no local inventory on those bootstrap requests; * `PrivateSpaceIngestor.sync()` sends empty summaries and wants on exactly this footing (ADR §30.3). * Once membership can fold, dialing uses the same ever-member filter as serving, plus only the * endpoints whose bounded inventory-free scan still has a cursor. `WirePrivateBus` exposes those * routes and withholds gossip, blobs and inventory from them until the cursor clears. */ export function catchUpDirectory( records: readonly StoredRecord[], spaceUri: string, ignored: Array<{ uri: string; reason: string }>, bootstrapEndpoints: ReadonlySet = new Set(), ): DeviceDirectory { const directory = readDeviceDirectory(records, ignored) const members = everMemberDids(records, spaceUri) return members ? memberDirectory(directory, members, ignored, bootstrapEndpoints) : directory } const memberDirectory = ( directory: DeviceDirectory, members: ReadonlySet, ignored: Array<{ uri: string; reason: string }>, bootstrapEndpoints: ReadonlySet = new Set(), ): DeviceDirectory => { const bindings = new Map() for (const [key, binding] of directory.bindings) { const address = directory.addresses.get(key) if (members.has(binding.did) || (address && bootstrapEndpoints.has(address.endpointId))) { bindings.set(key, binding) continue } ignored.push({ uri: binding.uri, reason: 'device owner has never been a member of this space' }) } return { bindings, addresses: new Map([...directory.addresses].filter(([key]) => bindings.has(key))), retired: new Map([...directory.retired].filter(([key]) => bindings.has(key))), } } /** * Every device this replica could dial: bound and currently addressed. * * Sorted by `(did, deviceKeyId)` so a caller's dial order is a function of the directory rather than * of map insertion, which is what makes a partial catch-up reproducible. */ export function connectablePeers( directory: DeviceDirectory, ): PeerIdentity[] { const peers: PeerIdentity[] = [] for (const [key, binding] of directory.bindings) { const address = directory.addresses.get(key) if (!address) continue peers.push({ did: binding.did, deviceKeyId: binding.deviceKeyId, endpointId: address.endpointId, ...(address.relays ? { relays: [...address.relays] } : {}), }) } return peers.sort( (left, right) => compareCodePoints(left.did, right.did) || compareCodePoints(left.deviceKeyId, right.deviceKeyId), ) } /** * May this endpoint be served? * * The endpoint id is matched against the addresses the directory holds, and the device behind the * matching address must still be bound. An endpoint that matches nothing is `unknown` rather than * refused outright, because that is what a device published five minutes ago looks like. * * Two properties this deliberately does NOT have. It is not a membership check — a non-member's * device is connectable, and their envelopes are stored and then ignored by the fold, exactly as a * removed member's records are on the public path. And it is not sufficient: whatever an authorized * peer sends still goes through `admit()`, which is why an out-of-date directory here costs a * reconnect and never a wrong record. * * **What makes the first of those safe is a premise about the directory this is handed, not about * this function.** The non-member this is written for is a *removed* member: serving somebody who * already holds the corpus discloses nothing. A DID whose only relationship to the space is a * directory entry — a stranger polled off a claim while the replica was cold (ADR §30) — must * therefore never reach this scan, and `connectionDirectory` above is where that is enforced: it * serves nobody on a partial corpus and, once a space record folds, keeps only ever-members * (ADR §30.2–30.3). Hand this function a raw `readDeviceDirectory` only where the bootstrap footing * is the point. */ export function authorizeEndpoint( endpointId: string, directory: DeviceDirectory, ): PeerAuthorization { // Scanned rather than indexed: one address per device, and a directory large enough for this to // matter is a directory this replica is already folding on every sync. for (const [key, address] of directory.addresses) { if (address.endpointId !== endpointId) continue const binding = directory.bindings.get(key) if (!binding) { // An address for a key no `device` record publishes. The owner's repo says where to find a // device it has never claimed, which is not a device. continue } return { ok: true, peer: { did: binding.did, deviceKeyId: binding.deviceKeyId, endpointId, ...(address.relays ? { relays: [...address.relays] } : {}), }, } } // Nothing matched. A retired device lands here too — `readDeviceDirectory` keeps its address out // of the map its owner retired it from, so there is nothing to match it against — and that is the // whole of how a withdrawn key is excluded from new connections while its history keeps counting. return { ok: false, status: 'unknown', reason: `no published device address names endpoint ${endpointId}`, } } /** The endpoint ids `connectablePeers` would dial, for a diagnostic that fits on one line. */ export const peerEndpoints = (peers: readonly PeerIdentity[]): string[] => peers.map((peer) => peer.endpointId) /** * `deviceKey`, re-exported under the name the transport layer uses it by. * * The transport talks about `(did, deviceKeyId)` pairs constantly — a peer's identity, an * authorization result, a refusal in a log — and having it reach into the fold's module for the * spelling is how the two ends of one string drift apart. */ export const peerKey = deviceKey