From c771e2af6d2db44dbd9d4175dd8fd23474327b9e Mon Sep 17 00:00:00 2001 From: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) Date: Fri, 07 Aug 2026 00:28:51 +0000 Subject: [PATCH] Bootstrap private browsers from daemon peers Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- packages/core/test/private-sync.test.mjs | 20 ++++++++++++++++++++ packages/core/test/private-wire.test.mjs | 5 ++++- packages/daemon/src/private-run.ts | 1 + packages/ingest/src/private.ts | 29 +++++++++++++++++++++++++++++ packages/sidecar/src/private-cli.ts | 8 ++++++-- packages/core/src/private/bus.ts | 2 ++ packages/core/src/private/endpoint.ts | 2 ++ packages/core/src/private/sync.ts | 16 ++++++++++++++++ packages/core/src/private/ticket.ts | 7 +++++++ packages/core/src/private/wire.ts | 13 +++++++++++++ packages/ui/src/lib/private-mode.ts | 4 ++++ packages/ui/src/lib/private-space.ts | 11 +++++++---- 12 file(s) changed, 111 insertion(s)(+), 7 deletion(s)(-) diff --git a/packages/core/test/private-sync.test.mjs b/packages/core/test/private-sync.test.mjs --- a/packages/core/test/private-sync.test.mjs +++ b/packages/core/test/private-sync.test.mjs @@ -355,6 +355,26 @@ assert.equal(await right.bus.accept('endpoint-left', errorFrame('unavailable', 'busy')), undefined) }) + it('records claimed DIDs from a refused peer without changing the refusal', async () => { + const bus = new WirePrivateBus({ + spaceUri: SPACE, + envelopes: storeOf([]), + peers: fixedPeers([]), + authorizePeer: () => ({ ok: false, reason: 'unknown endpoint' }), + }) + const answer = await bus.accept('endpoint-agent', { + kind: 'catch-up', + version: WIRE_VERSION, + topic: await spaceTopic(SPACE), + summaries: [], + wants: [], + from: ['did:plc:agent'], + }) + assert.equal(answer.status, 'unavailable') + assert.deepEqual(bus.drainPeerCandidates(), [{ endpointId: 'endpoint-agent', dids: ['did:plc:agent'] }]) + assert.deepEqual(bus.drainPeerCandidates(), []) + }) + it('is one bus for one space', async () => { const { left } = await pair([], []) await assert.rejects( diff --git a/packages/core/test/private-wire.test.mjs b/packages/core/test/private-wire.test.mjs --- a/packages/core/test/private-wire.test.mjs +++ b/packages/core/test/private-wire.test.mjs @@ -66,7 +66,7 @@ const topic = await spaceTopic(SPACE) const envelope = await envelopeFor('hello') const frames = [ - { kind: 'hello', version: WIRE_VERSION, topic, endpointId: 'endpoint-a' }, + { kind: 'hello', version: WIRE_VERSION, topic, endpointId: 'endpoint-a', from: [DID] }, { kind: 'hello', version: WIRE_VERSION, topic }, { kind: 'catch-up', @@ -75,6 +75,7 @@ summaries: [{ did: DID, collection: COLLECTIONS.message, rkey: 'message-1', cids: ['bafy'] }], wants: [{ did: DID, collection: COLLECTIONS.message, rkey: 'message-2', recordCid: 'bafy2' }], cursor: 'a b c d e', + from: [DID], }, { kind: 'envelopes', version: WIRE_VERSION, topic, envelopes: [envelope], cursor: 'x' }, { kind: 'envelopes', version: WIRE_VERSION, topic, envelopes: [] }, @@ -177,6 +178,8 @@ [JSON.stringify({ kind: 'gossip', version: WIRE_VERSION, topic }), /envelope is missing/], [JSON.stringify({ kind: 'catch-up', version: WIRE_VERSION, topic, summaries: {}, wants: [] }), /summaries must be an array/], [JSON.stringify({ kind: 'catch-up', version: WIRE_VERSION, topic, summaries: [], wants: 3 }), /wants must be an array/], + [JSON.stringify({ kind: 'catch-up', version: WIRE_VERSION, topic, summaries: [], wants: [], from: ['not-a-did'] }), /from must contain DIDs/], + [JSON.stringify({ kind: 'hello', version: WIRE_VERSION, topic, from: Array(9).fill(DID) }), /at most 8 DIDs/], [JSON.stringify({ kind: 'envelopes', version: WIRE_VERSION, topic, envelopes: [7] }), /non-object envelope/], [JSON.stringify({ kind: 'envelopes', version: WIRE_VERSION, topic, envelopes: [], cursor: 5 }), /cursor must be a string/], [JSON.stringify({ kind: 'error', version: WIRE_VERSION, status: 'nope', reason: 'x' }), /is not a status/], diff --git a/packages/daemon/src/private-run.ts b/packages/daemon/src/private-run.ts --- a/packages/daemon/src/private-run.ts +++ b/packages/daemon/src/private-run.ts @@ -386,6 +386,7 @@ // Hints order the dial list and never filter it (§16): the operator's bootstrap DIDs and the // founder are the peers most likely to be awake, and everyone else is dialled after them. prefer: bootstrapDids, + selfDids: () => options.ourDids, }).bus, bootstrapDids, ...(spacePin ? { spacePin } : {}), diff --git a/packages/ingest/src/private.ts b/packages/ingest/src/private.ts --- a/packages/ingest/src/private.ts +++ b/packages/ingest/src/private.ts @@ -12,6 +12,7 @@ materialize, missingBlobs, readDeviceDirectory, + authorizeEndpoint, spacePinMismatch, summarize, wantFor, @@ -118,6 +119,8 @@ * is checked, which is the honest behaviour: a pin nobody was handed cannot be invented here. */ spacePin?: SpacePin + /** Wakes connection management after a refused peer's public endpoint binding is verified. */ + onPeerCandidate?: () => void } /** @@ -175,6 +178,8 @@ /** Envelopes that arrived by gossip while a sync was in flight. Drained on the next sync. */ readonly #inbox: PrivateEnvelope[] = [] readonly #onArrival: (() => void) | undefined + readonly #onPeerCandidate: (() => void) | undefined + readonly #candidateCooldown = new Map() constructor( readonly spaceUri: string, @@ -208,6 +213,7 @@ this.#blobs = options.blobs this.#maxBlobsPerSync = Math.max(1, options.maxBlobsPerSync ?? DEFAULT_MAX_BLOBS_PER_SYNC) this.#onArrival = options.onArrival + this.#onPeerCandidate = options.onPeerCandidate } /** The blob store this ingestor fills, for a command that has to read or write bytes directly. */ @@ -345,6 +351,7 @@ async sync(signal?: AbortSignal): Promise { const report = emptyReport() await this.pollDirectory(signal) + await this.#pollPeerCandidates(signal) // Catch-up carries the want list explicitly. A summary can say what this replica HAS; only a // want can say what it knows it lost, and on a bus with no durable middle the difference is the @@ -457,6 +464,28 @@ if (!this.#poller) return for (const did of [...this.knownDids].sort(compareCodePoints)) { await this.#poller.pollDid(did, signal) + } + } + + async #pollPeerCandidates(signal?: AbortSignal): Promise { + if (!this.#poller || !this.bus.drainPeerCandidates) return + const now = Date.now() + for (const candidate of this.bus.drainPeerCandidates().slice(0, 4)) { + const last = this.#candidateCooldown.get(candidate.endpointId) ?? 0 + if (now - last < 60_000) continue + this.#candidateCooldown.set(candidate.endpointId, now) + const verified: string[] = [] + for (const did of [...new Set(candidate.dids)].slice(0, 8)) { + await this.#poller.pollDid(did, signal) + const directory = readDeviceDirectory(this.records.records(), []) + if ([...directory.addresses].some(([key, address]) => key.startsWith(`${did}\u0000`) && address.endpointId === candidate.endpointId)) { + verified.push(did) + } + } + if (verified.length > 0 && authorizeEndpoint(candidate.endpointId, readDeviceDirectory(this.records.records(), [])).ok) { + for (const did of verified) this.knownDids.add(did) + this.#onPeerCandidate?.() + } } } diff --git a/packages/sidecar/src/private-cli.ts b/packages/sidecar/src/private-cli.ts --- a/packages/sidecar/src/private-cli.ts +++ b/packages/sidecar/src/private-cli.ts @@ -377,6 +377,10 @@ // name a repo they do not control, and `assertTicket` refuses it. founder: index.space.did, device: await founderDevice(context, index, meta), + peerHints: [index.space.did, ...index.members.filter((member) => member.active).map((member) => member.did)] + .filter((did, position, all) => all.indexOf(did) === position) + .filter((did) => did === index.space.did || index.devices.some((device) => device.did === did && device.endpointId)) + .slice(0, 16), ...(endpointId !== undefined ? { endpointId } : {}), ...(relays.length > 0 ? { relays } : {}), issuedAt: nowOf(context), @@ -449,7 +453,7 @@ spaceCid: ticket.space.cid, founder: ticket.founder, protocol: ticket.version, - peerHints: peerHints.length > 0 ? peerHints : [ticket.founder], + peerHints: peerHints.length > 0 ? peerHints : (ticket.peerHints ?? [ticket.founder]), ...(ticket.relays ? { relays: ticket.relays } : {}), ...(optional(args, '--label') !== undefined ? { label: optional(args, '--label') as string } : {}), createdAt: now, @@ -459,7 +463,7 @@ spaceUri: meta.spaceUri, actorDid: context.actorDid, transport: context.transport, - bootstrapDids: [ticket.founder, context.actorDid], + bootstrapDids: [ticket.founder, context.actorDid, ...(ticket.peerHints ?? [])], ...pinOption(meta), ...(context.now ? { now: context.now } : {}), }) diff --git a/packages/core/src/private/bus.ts b/packages/core/src/private/bus.ts --- a/packages/core/src/private/bus.ts +++ b/packages/core/src/private/bus.ts @@ -30,6 +30,8 @@ } export interface PrivateBus { + /** Untrusted identities claimed by refused inbound peers; callers must verify them publicly. */ + drainPeerCandidates?(): Array<{ endpointId: string; dids: string[] }> /** Join a space's topic. Idempotent. */ join(spaceUri: string): Promise /** Gossip one envelope to every connected peer in the space. */ diff --git a/packages/core/src/private/endpoint.ts b/packages/core/src/private/endpoint.ts --- a/packages/core/src/private/endpoint.ts +++ b/packages/core/src/private/endpoint.ts @@ -103,6 +103,7 @@ blobs?: Pick /** DIDs to dial first — `join.peerHints`, ordering only and never filtering (ADR §16). */ prefer?: readonly string[] + selfDids?: () => readonly string[] } /** One space served over the shared endpoint: the bus above it, and the links below it. */ @@ -255,6 +256,7 @@ // ago is served without restarting anything. authorizePeer: (endpointId) => authorizeEndpoint(endpointId, directory()), endpointId: this.#transport.endpointId, + ...(options.selfDids ? { selfDids: options.selfDids } : {}), ...(options.blobs ? { blobs: options.blobs } : {}), }) const attached: AttachedSpace = { diff --git a/packages/core/src/private/sync.ts b/packages/core/src/private/sync.ts --- a/packages/core/src/private/sync.ts +++ b/packages/core/src/private/sync.ts @@ -269,6 +269,8 @@ * a `hello` is legible in a packet capture and in a diagnostic, and for nothing else. */ endpointId?: string + /** Public identities this endpoint may claim; the receiver still verifies the endpoint binding. */ + selfDids?: () => readonly string[] /** * The bytes this endpoint can serve, and where a fetched blob is checked against. * @@ -308,12 +310,14 @@ readonly #authorizePeer: (endpointId: string) => PeerAuthorization readonly #maxRounds: number readonly #endpointId: string + readonly #selfDids: () => readonly string[] readonly #blobs: Pick | undefined readonly #blobLimits: BlobLimits readonly #subscribers = new Map void>>() readonly #status = new Map() #topic: string | undefined #closed = false + readonly #candidates = new Map() constructor(options: WirePrivateBusOptions) { this.spaceUri = options.spaceUri @@ -326,6 +330,7 @@ this.#authorizePeer = options.authorizePeer this.#maxRounds = options.maxRounds ?? DEFAULT_MAX_ROUNDS this.#endpointId = options.endpointId ?? '' + this.#selfDids = options.selfDids ?? (() => []) this.#blobs = options.blobs this.#blobLimits = options.blobLimits ?? DEFAULT_BLOB_LIMITS } @@ -423,6 +428,7 @@ topic, summaries: request.summaries, wants: request.wants, + ...(this.#selfDids().length > 0 ? { from: [...new Set(this.#selfDids())].slice(0, 8) } : {}), ...(cursor !== undefined ? { cursor } : {}), }) } catch (error) { @@ -569,6 +575,10 @@ if (frame.kind !== 'error' && frame.kind !== 'envelopes' && frame.kind !== 'blob') { const authorization = this.#authorizePeer(peerId) if (!authorization.ok) { + if ((frame.kind === 'catch-up' || frame.kind === 'hello') && frame.from?.length) { + this.#candidates.set(peerId, [...frame.from]) + while (this.#candidates.size > 32) this.#candidates.delete(this.#candidates.keys().next().value as string) + } return errorFrame('unavailable', authorization.reason) } } @@ -637,6 +647,12 @@ case 'error': return undefined } + } + + drainPeerCandidates(): Array<{ endpointId: string; dids: string[] }> { + const candidates = [...this.#candidates].map(([endpointId, dids]) => ({ endpointId, dids })) + this.#candidates.clear() + return candidates } /** Per-peer delivery and catch-up counters, for diagnostics — never for trust. */ diff --git a/packages/core/src/private/ticket.ts b/packages/core/src/private/ticket.ts --- a/packages/core/src/private/ticket.ts +++ b/packages/core/src/private/ticket.ts @@ -51,6 +51,7 @@ /** Where the founder's device could be reached when the ticket was minted. A hint, never trust. */ endpointId?: string relays?: string[] + peerHints?: string[] issuedAt: string } @@ -82,6 +83,7 @@ }, ...(ticket.endpointId !== undefined ? { endpointId: ticket.endpointId } : {}), ...(ticket.relays !== undefined ? { relays: [...ticket.relays] } : {}), + ...(ticket.peerHints !== undefined ? { peerHints: [...ticket.peerHints] } : {}), issuedAt: ticket.issuedAt, } } @@ -131,6 +133,9 @@ (!Array.isArray(value.relays) || value.relays.some((relay) => typeof relay !== 'string')) ) { throw new TypeError('ticket relays must be an array of strings') + } + if (value.peerHints !== undefined && (!Array.isArray(value.peerHints) || value.peerHints.length > 16 || value.peerHints.some((did) => typeof did !== 'string' || !did.startsWith('did:')))) { + throw new TypeError('ticket peerHints must contain at most 16 DIDs') } } @@ -276,6 +281,7 @@ device: TicketDevice endpointId?: string relays?: string[] + peerHints?: string[] issuedAt: string version?: number }): PrivateTicket { @@ -286,6 +292,7 @@ device: { ...input.device }, ...(input.endpointId !== undefined ? { endpointId: input.endpointId } : {}), ...(input.relays !== undefined ? { relays: [...input.relays] } : {}), + ...(input.peerHints !== undefined ? { peerHints: [...input.peerHints] } : {}), issuedAt: input.issuedAt, } assertTicket(ticket) diff --git a/packages/core/src/private/wire.ts b/packages/core/src/private/wire.ts --- a/packages/core/src/private/wire.ts +++ b/packages/core/src/private/wire.ts @@ -102,6 +102,7 @@ version: number topic: string endpointId?: string + from?: string[] } /** "Here is what I hold and what I know I am missing; send me the difference." */ @@ -118,6 +119,16 @@ wants: WantEntry[] /** Resume point from a previous response's `cursor`. Absent on the first request of a round. */ cursor?: string + /** Claimed public identities, useful only after their repos bind them to the transport endpoint. */ + from?: string[] +} + +const validateFrom = (value: unknown, kind: string): void => { + if (value === undefined) return + if (!Array.isArray(value) || value.length > 8) throw new TypeError(`${kind} frame from must contain at most 8 DIDs`) + if (value.some((did) => typeof did !== 'string' || did.length > 256 || !did.startsWith('did:'))) { + throw new TypeError(`${kind} frame from must contain DIDs`) + } } /** The answer to a `catch-up`: a bounded batch, and where to resume if there is more. */ @@ -319,6 +330,7 @@ } if (parsed.endpointId.length > 200) throw new TypeError('hello frame endpointId is too long') } + validateFrom(parsed.from, 'hello') return parsed as unknown as HelloFrame } case 'catch-up': { @@ -334,6 +346,7 @@ if (parsed.cursor !== undefined && typeof parsed.cursor !== 'string') { throw new TypeError('catch-up frame cursor must be a string') } + validateFrom(parsed.from, 'catch-up') return parsed as unknown as CatchUpFrame } case 'envelopes': { diff --git a/packages/ui/src/lib/private-mode.ts b/packages/ui/src/lib/private-mode.ts --- a/packages/ui/src/lib/private-mode.ts +++ b/packages/ui/src/lib/private-mode.ts @@ -551,6 +551,10 @@ space: { uri: index.space.uri, cid: index.space.cid }, founder, device, + peerHints: [founder, ...index.members.filter((member) => member.active).map((member) => member.did)] + .filter((did, position, all) => all.indexOf(did) === position) + .filter((did) => did === founder || index.devices.some((entry) => entry.did === did && entry.endpointId)) + .slice(0, 16), issuedAt: options.now?.() ?? new Date().toISOString(), }), ) diff --git a/packages/ui/src/lib/private-space.ts b/packages/ui/src/lib/private-space.ts --- a/packages/ui/src/lib/private-space.ts +++ b/packages/ui/src/lib/private-space.ts @@ -348,6 +348,9 @@ } if (options.meta) await writePrivateMeta(stores.database, meta) + const keys = options.keys ?? (await BrowserDeviceKeyStore.open()) + const device = options.actorDid ? await keys.active(options.actorDid) : undefined + // Attached before the ingestor, because the ingestor is built over the bus. A space attaches to // the tab's one endpoint by its wire topic, so opening a second space neither rebinds an // endpoint nor changes the address this tab has published (ADR §20). @@ -360,6 +363,7 @@ // the bookmark named are the peers most likely to be awake, and everyone else the directory // names is dialled after them. prefer: [meta.founder, ...(meta.peerHints ?? [])], + ...(device ? { selfDids: () => [device.did] } : {}), }) const bus: PrivateBus = attached?.bus ?? new LocalOnlyBus() const pin = pinOf(meta) @@ -367,7 +371,8 @@ poller: directoryPoller(options.transport, stores.records, stores.state), wants: stores.wants, blobs: stores.blobs, - bootstrapDids: options.actorDid ? [meta.founder, options.actorDid] : [meta.founder], + bootstrapDids: [...new Set([meta.founder, ...(options.actorDid ? [options.actorDid] : []), ...(meta.peerHints ?? [])])], + ...(attached ? { onPeerCandidate: () => attached?.connections.retryNow() } : {}), ...(pin ? { spacePin: pin } : {}), ...(options.now ? { now: options.now } : {}), }) @@ -380,8 +385,6 @@ // Harmless and idempotent over `LocalOnlyBus`, which joins nothing and delivers nothing. await ingestor.start() - const keys = options.keys ?? (await BrowserDeviceKeyStore.open()) - const device = options.actorDid ? await keys.active(options.actorDid) : undefined const writer = device ? privateWriter(ingestor, await keys.signer(device), { tid: options.tid ?? createTidSource() }) : undefined @@ -556,7 +559,7 @@ now: () => now, }) - const peerHints = options.peerHints?.length ? options.peerHints : [ticket.founder] + const peerHints = options.peerHints?.length ? options.peerHints : (ticket.peerHints ?? [ticket.founder]) const offered: PrivateReplicaMeta = { spaceUri: ticket.space.uri, spaceCid: ticket.space.cid, -- tangled.sh