reploid 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/poolday/config/chat-files.json +27 -0
- package/apps/poolday/config/partition-generation.js +21 -0
- package/apps/poolday/config/partition-policy.json +74 -0
- package/apps/poolday/config/partition-releases.json +3 -0
- package/apps/poolday/config/work-profile.json +122 -0
- package/apps/poolday/host/chat-view.js +47 -0
- package/apps/poolday/host/earning/client.js +199 -0
- package/apps/poolday/host/earning/contribution.js +168 -0
- package/apps/poolday/host/partition-release.js +33 -0
- package/apps/poolday/host/work-partition-artifact-source.js +37 -0
- package/apps/poolday/host/work-partition-capsule.js +39 -0
- package/apps/poolday/host/work-partition-plan.js +167 -0
- package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
- package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
- package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
- package/apps/poolday/pool/pack-operation.js +161 -0
- package/apps/poolday/pool/pack-release-policy.js +44 -0
- package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
- package/apps/poolday/providers/work-adapter.js +67 -0
- package/apps/poolday/providers/work-device.js +33 -0
- package/apps/poolday/providers/work-generation-completion.js +55 -0
- package/apps/poolday/providers/work-provider.js +209 -0
- package/apps/poolday/providers/work-resident-provider.js +113 -0
- package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
- package/docs/fleet-service.md +214 -0
- package/docs/native-participant.md +534 -0
- package/native-delivery.json +1174 -0
- package/package.json +29 -4
- package/packages/reploid/src/artifacts/binary.js +25 -0
- package/packages/reploid/src/artifacts/canonical-json.js +23 -0
- package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
- package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
- package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
- package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
- package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
- package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
- package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
- package/packages/reploid/src/artifacts/identity.js +91 -0
- package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
- package/packages/reploid/src/artifacts/receipt.js +131 -0
- package/packages/reploid/src/artifacts/signatures.js +94 -0
- package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
- package/packages/reploid/src/cancellation.js +44 -0
- package/packages/reploid/src/config/defaults.json +110 -0
- package/packages/reploid/src/config/index.js +209 -0
- package/packages/reploid/src/config/request-overrides.json +9 -0
- package/packages/reploid/src/config/transport-defaults.json +7 -0
- package/packages/reploid/src/hashing.js +30 -0
- package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
- package/packages/reploid/src/measurement/index.js +5 -0
- package/packages/reploid/src/measurement/readiness-records.js +310 -0
- package/packages/reploid/src/mesh/computer-identity.js +50 -0
- package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
- package/packages/reploid/src/mesh/contribution.js +141 -0
- package/packages/reploid/src/mesh/generation-recovery.js +58 -0
- package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
- package/packages/reploid/src/mesh/gpu-signature.js +136 -0
- package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
- package/packages/reploid/src/mesh/index.js +66 -0
- package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
- package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
- package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
- package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
- package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
- package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
- package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
- package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
- package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
- package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
- package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
- package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
- package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
- package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
- package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
- package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
- package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
- package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
- package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
- package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
- package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
- package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
- package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
- package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
- package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
- package/packages/reploid/src/mesh/peer-generation.js +896 -0
- package/packages/reploid/src/mesh/peer-identity.js +87 -0
- package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
- package/packages/reploid/src/mesh/preparation-progress.js +46 -0
- package/packages/reploid/src/mesh/provider-hardware.js +11 -0
- package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
- package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
- package/packages/reploid/src/mesh/web-access-network.js +112 -0
- package/packages/reploid/src/mesh/web-access-policy.json +17 -0
- package/packages/reploid/src/retrieval/citations.js +156 -0
- package/packages/reploid/src/retrieval/claim-support.js +113 -0
- package/packages/reploid/src/retrieval/costs.js +101 -0
- package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
- package/packages/reploid/src/retrieval/index.js +20 -0
- package/packages/reploid/src/retrieval/lexical-support.js +27 -0
- package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
- package/packages/reploid/src/retrieval/page-fetch.json +10 -0
- package/packages/reploid/src/retrieval/page-text.js +182 -0
- package/packages/reploid/src/retrieval/passage.js +37 -0
- package/packages/reploid/src/retrieval/policy.json +14 -0
- package/packages/reploid/src/retrieval/retrieval.js +23 -0
- package/packages/reploid/src/retrieval/review-queue.js +76 -0
- package/packages/reploid/src/retrieval/sources.js +263 -0
- package/packages/reploid/src/retrieval/support-review.js +155 -0
- package/packages/reploid/src/retrieval/text.js +21 -0
- package/packages/reploid/src/rules/contribution.rules.json +8 -0
- package/packages/reploid/src/rules/routing.rules.json +24 -0
- package/packages/reploid/src/transport/assignment.js +571 -0
- package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
- package/packages/reploid/src/transport/index.js +11 -0
- package/packages/reploid/src/transport/introduction-admission.js +24 -0
- package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
- package/packages/reploid/src/transport/introduction-policy.json +12 -0
- package/packages/reploid/src/transport/introduction-records.js +90 -0
- package/packages/reploid/src/transport/introductions.js +209 -0
- package/packages/reploid/src/transport/partition-data-channel.js +421 -0
- package/packages/reploid/src/transport/partition-errors.json +16 -0
- package/packages/reploid/src/transport/retry-policy.js +36 -0
- package/packages/reploid/src/transport/room.js +502 -0
- package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
- package/packages/reploid/src/transport/signaling.js +571 -0
- package/packages/reploid/src/transport/swarm.js +1247 -0
- package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
- package/server/browser-roots.js +42 -0
- package/server/browser-web-worker.js +94 -0
- package/server/chat-completions.js +45 -0
- package/server/fleet/browser-bridge.js +145 -0
- package/server/fleet/contract.js +42 -0
- package/server/fleet/credit-evidence.js +103 -0
- package/server/fleet/credit-recovery.js +175 -0
- package/server/fleet/credits.js +178 -0
- package/server/fleet/execution-admission.js +32 -0
- package/server/fleet/native-host.js +817 -0
- package/server/fleet/placement.js +132 -0
- package/server/fleet/requester.js +346 -0
- package/server/fleet/service.js +311 -0
- package/server/fleet/terminal-policy.js +83 -0
- package/server/fleet/text-api.js +94 -0
- package/server/hashing.js +35 -0
- package/server/http-request-owner.js +49 -0
- package/server/messages.js +57 -0
- package/server/native-canonical-epochs.js +90 -0
- package/server/native-change-log.js +40 -0
- package/server/native-earning.js +203 -0
- package/server/native-generation-journal.js +46 -0
- package/server/native-model-config.js +57 -0
- package/server/native-model-files.js +382 -0
- package/server/native-model-origin.js +54 -0
- package/server/native-model-origins.json +12 -0
- package/server/native-node.js +83 -0
- package/server/native-owner/controls.js +162 -0
- package/server/native-owner/monitor.js +251 -0
- package/server/native-owner/policy.js +273 -0
- package/server/native-owner/rate.js +92 -0
- package/server/native-participant.js +866 -0
- package/server/native-readiness-log.js +30 -0
- package/server/native-release-storage.js +64 -0
- package/server/native-shader-cache.js +369 -0
- package/server/native-supply-budget.js +66 -0
- package/server/native-whole-earning.js +67 -0
- package/server/native-whole-model-fill.js +182 -0
- package/server/native-whole-model.js +130 -0
- package/server/public-web-worker.js +66 -0
- package/server/responses.js +92 -0
- package/server/search/errors.js +47 -0
- package/server/search/page-fetch.js +185 -0
- package/server/text-api.js +83 -0
- package/server/text-recovery.js +45 -0
- package/server/text-request-options.js +23 -0
- package/server/text-requester.js +231 -0
- package/server/token-auth.js +9 -0
- package/server/whole-model-requester.js +99 -0
- package/shared/config/chat-models.json +4685 -0
- package/shared/config/doppler-execution-contracts.js +32 -0
- package/shared/config/doppler-execution-contracts.json +43 -0
- package/shared/config/doppler-generation-contract.js +223 -0
- package/shared/config/doppler-local-models.js +79 -0
- package/shared/config/doppler-package.json +7 -0
- package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
- package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
- package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
- package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
- package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
- package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
- package/shared/config/reploid-library.json +49 -0
- package/shared/config/swarm-bootstrap.json +21 -0
- package/shared/infrastructure/doppler-runtime-service.js +261 -0
- package/shared/pool/executable-pack.js +87 -0
- package/shared/pool/pack-operation-adapters.js +124 -0
- package/shared/pool/pack-operation-policy.js +75 -0
- package/shared/pool/pool-config.json +2280 -0
- package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
- package/shared/vendor/reploid/artifacts/binary.js +25 -0
- package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
- package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
- package/shared/vendor/reploid/artifacts/identity.js +91 -0
- package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
- package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
- package/shared/vendor/reploid/artifacts/signatures.js +94 -0
- package/shared/vendor/reploid/config/defaults.json +110 -0
- package/shared/vendor/reploid/config/index.js +209 -0
- package/shared/vendor/reploid/config/request-overrides.json +9 -0
- package/shared/vendor/reploid/hashing.js +30 -0
- package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
- package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
- package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
- package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
- package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
- package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
- package/shared/vendor/reploid/rules/routing.rules.json +24 -0
- package/shared/vendor/reploid/transport/partition-errors.json +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import defaults from './introduction-policy.json' with { type: 'json' };
|
|
2
|
+
import { boundedSnapshot, verifyContact } from './introduction-records.js';
|
|
3
|
+
import { abortable, untilAborted } from '../cancellation.js';
|
|
4
|
+
|
|
5
|
+
/** Host-owned entry points, tried in retained-contact order, then configured order. */
|
|
6
|
+
export async function bootstrapIntroductions({ introductions, routes, store, signal, isConnected,
|
|
7
|
+
policy = defaults, onState = () => {} }) {
|
|
8
|
+
if (!Array.isArray(routes) || routes.length > policy.maxBootstrapRoutes
|
|
9
|
+
|| new Set(routes.map(route => route.id)).size !== routes.length
|
|
10
|
+
|| routes.some(route => typeof route.id !== 'string' || !route.id || typeof route.open !== 'function')) {
|
|
11
|
+
throw new Error('Explicit bounded introduction routes required');
|
|
12
|
+
}
|
|
13
|
+
const local = introductions.exportContact(), roomId = local.body.roomId;
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
const loadSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(policy.messageTtlMs)]) : AbortSignal.timeout(policy.messageTtlMs);
|
|
16
|
+
const loaded = store ? await abortable(() => store.load(), loadSignal) : [];
|
|
17
|
+
const saved = boundedSnapshot(loaded, policy.maxMessageBytes);
|
|
18
|
+
if (!Array.isArray(saved) || saved.length > policy.maxBootstrapRoutes) throw new Error('Invalid retained introduction routes');
|
|
19
|
+
const retained = new Map();
|
|
20
|
+
for (const entry of saved) {
|
|
21
|
+
if (!routes.some(route => route.id === entry.routeId)) continue;
|
|
22
|
+
try { retained.set(entry.routeId, await verifyContact(entry.contact, { roomId, policy })); }
|
|
23
|
+
catch { /* Expired/corrupt records grant no authority. Reopen only configured routes. */ }
|
|
24
|
+
}
|
|
25
|
+
const ordered = [...routes].sort((a, b) => Number(retained.has(b.id)) - Number(retained.has(a.id)));
|
|
26
|
+
const failures = [];
|
|
27
|
+
for (const route of ordered) {
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
const timeout = AbortSignal.timeout(policy.messageTtlMs);
|
|
30
|
+
const lifetime = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
31
|
+
let session, remotePeerId, expired = false;
|
|
32
|
+
onState({ state: 'connecting', routeId: route.id });
|
|
33
|
+
// Each step stops waiting when this route attempt's lifetime ends.
|
|
34
|
+
const step = operation => untilAborted(operation, lifetime);
|
|
35
|
+
try {
|
|
36
|
+
session = await step(Promise.resolve(route.open({ contact: local, retainedContact: retained.get(route.id) || null,
|
|
37
|
+
signal: lifetime, receive: packet => introductions.receive(null, 'reploid:introduction-signal', packet,
|
|
38
|
+
answer => { if (!session || lifetime.aborted) throw new Error('Bootstrap route closed'); return session.publish(answer); })
|
|
39
|
+
})).then(opened => { if (expired || lifetime.aborted) { opened?.close(); throw new Error('Stale bootstrap opening'); } return opened; }));
|
|
40
|
+
if (!session || typeof session.publish !== 'function' || typeof session.close !== 'function') throw new Error('Invalid bootstrap session');
|
|
41
|
+
const remote = await step(verifyContact(session.contact, { roomId, policy }));
|
|
42
|
+
remotePeerId = remote.body.peerId;
|
|
43
|
+
if (retained.has(route.id) && retained.get(route.id).body.participantId !== remote.body.participantId) throw new Error('Bootstrap participant changed');
|
|
44
|
+
await step(introductions.connect(remote, packet => {
|
|
45
|
+
lifetime.throwIfAborted(); return session.publish(packet);
|
|
46
|
+
}, lifetime));
|
|
47
|
+
while (!isConnected(remote.body.peerId)) {
|
|
48
|
+
await step(new Promise(resolve => {
|
|
49
|
+
const finish = () => { clearTimeout(timer); lifetime.removeEventListener('abort', finish); resolve(); };
|
|
50
|
+
const timer = setTimeout(finish, 25);
|
|
51
|
+
lifetime.addEventListener('abort', finish, { once: true });
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
lifetime.throwIfAborted();
|
|
55
|
+
// Store public verified contact records, never keys, prompts or credentials.
|
|
56
|
+
retained.set(route.id, remote);
|
|
57
|
+
if (store) await step(store.save([...retained].slice(-policy.maxBootstrapRoutes)
|
|
58
|
+
.map(([routeId, contact]) => ({ routeId, contact }))));
|
|
59
|
+
onState({ state: 'connected', routeId: route.id, peerId: remote.body.peerId });
|
|
60
|
+
return { routeId: route.id, peerId: remote.body.peerId, failures };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
failures.push({ routeId: route.id, message: error.message });
|
|
64
|
+
onState({ state: 'unavailable', routeId: route.id, message: error.message });
|
|
65
|
+
} finally { expired = true; if (remotePeerId && !isConnected(remotePeerId)) introductions.cancelPending?.(remotePeerId); session?.close(); }
|
|
66
|
+
}
|
|
67
|
+
const error = new Error('No introduction route connected; supply a current invitation or reachable participant');
|
|
68
|
+
error.failures = failures;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A participant-operated endpoint speaks contact/signal JSON; admission stays at endpoints. */
|
|
73
|
+
export function createWebSocketIntroductionRoute({ id, url, expectedParticipantId, maxMessageBytes = defaults.maxMessageBytes,
|
|
74
|
+
WebSocket: Socket = globalThis.WebSocket }) {
|
|
75
|
+
const parsed = new URL(url);
|
|
76
|
+
if (!['wss:', 'ws:'].includes(parsed.protocol) || parsed.username || parsed.password
|
|
77
|
+
|| parsed.protocol === 'ws:' && !['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)) {
|
|
78
|
+
throw new Error('Introduction endpoint requires WSS or loopback WS');
|
|
79
|
+
}
|
|
80
|
+
if (typeof expectedParticipantId !== 'string' || !expectedParticipantId.startsWith('peer:')) throw new Error('Pinned bootstrap participant required');
|
|
81
|
+
return Object.freeze({ id, open({ contact, receive, signal }) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
const socket = new Socket(url);
|
|
85
|
+
let admitted = false, closed = false;
|
|
86
|
+
const close = () => { if (closed) return; closed = true; signal.removeEventListener('abort', aborted); socket.close(); };
|
|
87
|
+
const failed = error => { close(); reject(error); };
|
|
88
|
+
const aborted = () => failed(signal.reason);
|
|
89
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
90
|
+
const publish = record => {
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
if (closed || socket.readyState !== Socket.OPEN || socket.bufferedAmount > maxMessageBytes) throw new Error('Bootstrap endpoint unavailable');
|
|
93
|
+
socket.send(JSON.stringify(boundedSnapshot(record, maxMessageBytes))); return true;
|
|
94
|
+
};
|
|
95
|
+
socket.onopen = () => { try { publish({ type: 'contact', contact }); } catch (error) { failed(error); } };
|
|
96
|
+
socket.onerror = () => failed(new Error('Bootstrap endpoint connection failed'));
|
|
97
|
+
socket.onclose = () => failed(new Error('Bootstrap endpoint closed'));
|
|
98
|
+
socket.onmessage = event => {
|
|
99
|
+
try {
|
|
100
|
+
if (closed || typeof event.data !== 'string' || new TextEncoder().encode(event.data).length > maxMessageBytes) throw new Error('Invalid bootstrap record size');
|
|
101
|
+
const message = JSON.parse(event.data);
|
|
102
|
+
if (!admitted) {
|
|
103
|
+
if (message.type !== 'contact' || message.contact?.body?.participantId !== expectedParticipantId) throw new Error('Bootstrap endpoint identity mismatch');
|
|
104
|
+
admitted = true;
|
|
105
|
+
resolve({ contact: message.contact, publish: record => publish({ type: 'signal', envelope: record }), close });
|
|
106
|
+
} else if (message.type === 'signal') Promise.resolve(receive(message.envelope)).catch(failed);
|
|
107
|
+
else throw new Error('Unexpected bootstrap record');
|
|
108
|
+
} catch (error) { failed(error); }
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
} });
|
|
112
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "reploid.introductions-policy/v1",
|
|
3
|
+
"contactTtlMs": 300000,
|
|
4
|
+
"messageTtlMs": 30000,
|
|
5
|
+
"maxContacts": 64,
|
|
6
|
+
"maxPending": 8,
|
|
7
|
+
"maxMessageBytes": 32768,
|
|
8
|
+
"maxMessagesPerWindow": 256,
|
|
9
|
+
"windowMs": 30000,
|
|
10
|
+
"maxBootstrapRoutes": 8,
|
|
11
|
+
"maxClockSkewMs": 5000
|
|
12
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Signed endpoint contacts and end-to-end encrypted introduction records. */
|
|
2
|
+
import { canonicalize } from '../artifacts/canonical-json.js';
|
|
3
|
+
import { createPeerIdFromPublicJwk, importSigningKey, importVerificationKey,
|
|
4
|
+
getIdentitySignAlgorithm, toBase64Url, fromBase64Url } from '../artifacts/identity.js';
|
|
5
|
+
|
|
6
|
+
const encode = value => new TextEncoder().encode(canonicalize(value));
|
|
7
|
+
const assert = (value, message) => { if (!value) throw new Error(message); };
|
|
8
|
+
export const CONTACT = 'reploid.contact/v1', SIGNAL = 'reploid.introduction/v1';
|
|
9
|
+
|
|
10
|
+
export function boundedSnapshot(value, limit) {
|
|
11
|
+
const text = JSON.stringify(value);
|
|
12
|
+
assert(typeof text === 'string' && new TextEncoder().encode(text).length <= limit, 'Introduction record exceeds allocation');
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function sign(body, identity) {
|
|
17
|
+
return { body, publicJwk: identity.publicJwk,
|
|
18
|
+
signature: toBase64Url(await crypto.subtle.sign(getIdentitySignAlgorithm(identity),
|
|
19
|
+
await importSigningKey(identity), encode(body))) };
|
|
20
|
+
}
|
|
21
|
+
async function verify(record, participantId) {
|
|
22
|
+
assert(record?.publicJwk && !record.publicJwk.d && typeof record.signature === 'string', 'Invalid introduction signature shape');
|
|
23
|
+
assert(await createPeerIdFromPublicJwk(record.publicJwk) === participantId, 'Introduction identity mismatch');
|
|
24
|
+
assert(await crypto.subtle.verify(getIdentitySignAlgorithm(record.publicJwk),
|
|
25
|
+
await importVerificationKey(record.publicJwk), fromBase64Url(record.signature), encode(record.body)), 'Invalid introduction signature');
|
|
26
|
+
}
|
|
27
|
+
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
28
|
+
function lifetime(body, now, ttl, skew) {
|
|
29
|
+
assert(Number.isSafeInteger(body.createdAt) && Number.isSafeInteger(body.expiresAt)
|
|
30
|
+
&& body.createdAt <= now + skew && body.expiresAt > now && body.expiresAt - body.createdAt <= ttl
|
|
31
|
+
&& body.expiresAt > body.createdAt, 'Expired or invalid introduction lifetime');
|
|
32
|
+
}
|
|
33
|
+
export async function createContact({ identity, peerId, roomId, sequence, policy, createAdmission, now = Date.now() }) {
|
|
34
|
+
assert(await createPeerIdFromPublicJwk(identity.publicJwk) === identity.peerId, 'Local introduction identity mismatch');
|
|
35
|
+
const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']);
|
|
36
|
+
const body = { schema: CONTACT, participantId: identity.peerId, peerId, roomId, sequence,
|
|
37
|
+
createdAt: now, expiresAt: now + policy.contactTtlMs,
|
|
38
|
+
encryptionKey: await crypto.subtle.exportKey('jwk', keys.publicKey) };
|
|
39
|
+
if (createAdmission) body.admission = await createAdmission(structuredClone(body));
|
|
40
|
+
return { record: await sign(body, identity), privateKey: keys.privateKey };
|
|
41
|
+
}
|
|
42
|
+
export async function verifyContact(input, { roomId, policy, now = Date.now() }) {
|
|
43
|
+
const record = boundedSnapshot(input, policy.maxMessageBytes), body = record.body;
|
|
44
|
+
assert(body?.schema === CONTACT && body.roomId === roomId && identifier(body.peerId)
|
|
45
|
+
&& identifier(body.participantId) && Number.isSafeInteger(body.sequence) && body.sequence >= 0,
|
|
46
|
+
'Invalid introduction contact');
|
|
47
|
+
lifetime(body, now, policy.contactTtlMs, policy.maxClockSkewMs);
|
|
48
|
+
assert(body.encryptionKey?.kty === 'EC' && body.encryptionKey.crv === 'P-256' && !body.encryptionKey.d,
|
|
49
|
+
'Invalid introduction encryption key');
|
|
50
|
+
await crypto.subtle.importKey('jwk', body.encryptionKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
51
|
+
await verify(record, body.participantId);
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
async function sharedKey(privateKey, publicJwk) {
|
|
55
|
+
const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
56
|
+
return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey,
|
|
57
|
+
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
58
|
+
}
|
|
59
|
+
export async function sealIntroduction({ identity, contact, recipient, payload, policy, now = Date.now() }) {
|
|
60
|
+
const header = { schema: SIGNAL, roomId: contact.record.body.roomId, id: crypto.randomUUID(),
|
|
61
|
+
from: contact.record.body.peerId, to: recipient.body.peerId, participantId: identity.peerId,
|
|
62
|
+
recipientId: recipient.body.participantId, recipientSequence: recipient.body.sequence,
|
|
63
|
+
createdAt: now, expiresAt: Math.min(now + policy.messageTtlMs, contact.record.body.expiresAt, recipient.body.expiresAt) };
|
|
64
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
65
|
+
const key = await sharedKey(contact.privateKey, recipient.body.encryptionKey);
|
|
66
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: encode(header) }, key, encode(payload));
|
|
67
|
+
const record = await sign({ ...header, iv: toBase64Url(iv), ciphertext: toBase64Url(ciphertext), contact: contact.record }, identity);
|
|
68
|
+
return boundedSnapshot(record, policy.maxMessageBytes);
|
|
69
|
+
}
|
|
70
|
+
export async function verifyIntroduction(input, { roomId, policy, now = Date.now() }) {
|
|
71
|
+
const record = boundedSnapshot(input, policy.maxMessageBytes), body = record.body;
|
|
72
|
+
assert(body?.schema === SIGNAL && body.roomId === roomId && identifier(body.id) && identifier(body.to)
|
|
73
|
+
&& identifier(body.recipientId) && typeof body.ciphertext === 'string'
|
|
74
|
+
&& typeof body.iv === 'string' && fromBase64Url(body.iv).length === 12, 'Invalid introduction envelope');
|
|
75
|
+
lifetime(body, now, policy.messageTtlMs, policy.maxClockSkewMs);
|
|
76
|
+
const contact = await verifyContact(body.contact, { roomId, policy, now });
|
|
77
|
+
assert(contact.body.peerId === body.from && contact.body.participantId === body.participantId,
|
|
78
|
+
'Introduction sender does not match contact');
|
|
79
|
+
await verify(record, body.participantId);
|
|
80
|
+
return record;
|
|
81
|
+
}
|
|
82
|
+
export async function openIntroduction(record, contact) {
|
|
83
|
+
const { iv, ciphertext, contact: sender, ...header } = record.body;
|
|
84
|
+
assert(header.to === contact.record.body.peerId && header.recipientId === contact.record.body.participantId
|
|
85
|
+
&& header.recipientSequence === contact.record.body.sequence, 'Introduction recipient changed');
|
|
86
|
+
const key = await sharedKey(contact.privateKey, sender.body.encryptionKey);
|
|
87
|
+
const bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64Url(iv), additionalData: encode(header) },
|
|
88
|
+
key, fromBase64Url(ciphertext));
|
|
89
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
90
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { fingerprint } from './sdp-fingerprint.js';
|
|
2
|
+
import defaults from './introduction-policy.json' with { type: 'json' };
|
|
3
|
+
import { freezeJson } from '../config/index.js';
|
|
4
|
+
import { boundedSnapshot, createContact, verifyContact, sealIntroduction,
|
|
5
|
+
verifyIntroduction, openIntroduction } from './introduction-records.js';
|
|
6
|
+
|
|
7
|
+
export const INTRODUCTION_CONTACT = 'reploid:introduction-contact';
|
|
8
|
+
export const INTRODUCTION_SIGNAL = 'reploid:introduction-signal';
|
|
9
|
+
const assert = (value, message) => { if (!value) throw new Error(message); };
|
|
10
|
+
|
|
11
|
+
/** One-hop introductions. The host grants admission/forwarding independently of compute. */
|
|
12
|
+
export async function createPeerIntroductions({ transport, identity, roomId, authorize, createAdmission, policy = defaults,
|
|
13
|
+
now = Date.now, onError = () => {} }) {
|
|
14
|
+
policy = freezeJson(structuredClone(policy));
|
|
15
|
+
assert(policy.schema === defaults.schema && Object.keys(defaults).every(key => key === 'schema'
|
|
16
|
+
|| Number.isSafeInteger(policy[key]) && policy[key] > 0), 'Invalid introduction policy');
|
|
17
|
+
assert(typeof authorize === 'function', 'Introduction authorization is required');
|
|
18
|
+
identity = structuredClone(identity);
|
|
19
|
+
const peerId = transport._getPeerId();
|
|
20
|
+
const contacts = new Map(), attempts = new Map(), seen = new Map();
|
|
21
|
+
let closed = false, contact = await createContact({ identity, peerId, roomId, sequence: now(), policy, createAdmission, now: now() });
|
|
22
|
+
let inFlight = 0, windowStart = now(), windowCount = 0;
|
|
23
|
+
const alive = () => assert(!closed, 'Introductions are closed');
|
|
24
|
+
const permitted = async (action, details) => {
|
|
25
|
+
alive();
|
|
26
|
+
assert(await authorize(freezeJson({ action, roomId, ...structuredClone(details) })) === true,
|
|
27
|
+
`Introduction ${action} denied`);
|
|
28
|
+
alive();
|
|
29
|
+
};
|
|
30
|
+
const prune = () => {
|
|
31
|
+
for (const [key, value] of seen) if (value <= now()) seen.delete(key);
|
|
32
|
+
for (const [key, value] of contacts) if (value.record.body.expiresAt <= now()) contacts.delete(key);
|
|
33
|
+
for (const [key, value] of attempts) if (value.expiresAt <= now()) {
|
|
34
|
+
attempts.delete(key); if (!value.established) transport.cancelIntroduction?.(key, value.id);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
async function bounded(operation) {
|
|
38
|
+
alive(); prune();
|
|
39
|
+
if (now() - windowStart >= policy.windowMs) { windowStart = now(); windowCount = 0; }
|
|
40
|
+
assert(inFlight < policy.maxPending && windowCount < policy.maxMessagesPerWindow, 'Introduction rate limit');
|
|
41
|
+
inFlight++; windowCount++;
|
|
42
|
+
try { return await operation(); } finally { inFlight--; }
|
|
43
|
+
}
|
|
44
|
+
async function retain(input, route) {
|
|
45
|
+
const record = await verifyContact(input, { roomId, policy, now: now() });
|
|
46
|
+
alive();
|
|
47
|
+
assert(record.body.peerId !== peerId, 'Self introduction');
|
|
48
|
+
await permitted('admit', { contact: record });
|
|
49
|
+
const previous = contacts.get(record.body.peerId);
|
|
50
|
+
if (previous) {
|
|
51
|
+
assert(previous.record.body.participantId === record.body.participantId, 'Contact identity collision');
|
|
52
|
+
assert(record.body.sequence >= previous.record.body.sequence, 'Stale contact sequence');
|
|
53
|
+
if (record.body.sequence === previous.record.body.sequence) {
|
|
54
|
+
assert(JSON.stringify(record.body) === JSON.stringify(previous.record.body), 'Contact sequence collision');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
assert(previous || contacts.size < policy.maxContacts, 'Contact allocation exhausted');
|
|
58
|
+
contacts.set(record.body.peerId, { record, route });
|
|
59
|
+
return record;
|
|
60
|
+
}
|
|
61
|
+
const directRoute = via => envelope => transport.sendToPeer(via, INTRODUCTION_SIGNAL, envelope);
|
|
62
|
+
async function publish(remote, payload, route) {
|
|
63
|
+
alive();
|
|
64
|
+
const recipient = contacts.get(remote)?.record;
|
|
65
|
+
assert(recipient && recipient.body.expiresAt > now() && contact.record.body.expiresAt > now(), 'Contact expired');
|
|
66
|
+
await permitted('connect', { contact: recipient });
|
|
67
|
+
const record = await sealIntroduction({ identity, contact, recipient, payload, policy, now: now() });
|
|
68
|
+
await permitted('connect', { contact: recipient });
|
|
69
|
+
assert(await route({ record, forwarded: false }) !== false, 'Introduction route unavailable');
|
|
70
|
+
alive();
|
|
71
|
+
}
|
|
72
|
+
function exchange(remote, attempt) {
|
|
73
|
+
return { attemptId: attempt.id, isActive: () => !closed && attempts.get(remote) === attempt, publish(message) {
|
|
74
|
+
const payload = { ...boundedSnapshot(message, policy.maxMessageBytes), attemptId: attempt.id };
|
|
75
|
+
// Preserve offer/answer/candidate order even when signing completes asynchronously.
|
|
76
|
+
attempt.sending = attempt.sending.then(async () => {
|
|
77
|
+
alive(); assert(attempts.get(remote) === attempt, 'Introduction attempt superseded');
|
|
78
|
+
await publish(remote, payload, attempt.route);
|
|
79
|
+
});
|
|
80
|
+
attempt.sending.catch(error => { if (!closed) onError(error); });
|
|
81
|
+
return attempt.sending;
|
|
82
|
+
} };
|
|
83
|
+
}
|
|
84
|
+
function allocate(remote, id, route) {
|
|
85
|
+
assert(attempts.has(remote) || attempts.size < policy.maxPending, 'Introduction attempt allocation exhausted');
|
|
86
|
+
const attempt = { id, route, expiresAt: now() + policy.messageTtlMs, sending: Promise.resolve() };
|
|
87
|
+
attempts.set(remote, attempt);
|
|
88
|
+
return attempt;
|
|
89
|
+
}
|
|
90
|
+
async function connectRecord(record, route, signal) {
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
if (transport.getConnectedPeers().some(peer => peer.id === record.body.peerId) || attempts.has(record.body.peerId)) return;
|
|
93
|
+
assert(transport.getConnectedPeers().length < policy.maxContacts, 'Connected peer allocation exhausted');
|
|
94
|
+
await permitted('connect', { contact: record });
|
|
95
|
+
signal?.throwIfAborted();
|
|
96
|
+
if (attempts.has(record.body.peerId)) return;
|
|
97
|
+
const remote = record.body.peerId, attempt = allocate(remote, crypto.randomUUID(), route);
|
|
98
|
+
const aborted = () => {
|
|
99
|
+
if (attempts.get(remote) !== attempt) return;
|
|
100
|
+
attempts.delete(remote); transport.cancelIntroduction?.(remote, attempt.id);
|
|
101
|
+
};
|
|
102
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
103
|
+
try { await transport.connectIntroducedPeer(remote, exchange(remote, attempt)); signal?.throwIfAborted(); }
|
|
104
|
+
catch (error) { if (attempts.get(remote) === attempt) attempts.delete(remote); throw error; }
|
|
105
|
+
finally { signal?.removeEventListener('abort', aborted); }
|
|
106
|
+
}
|
|
107
|
+
async function receiveSignal(via, input, replyRoute) {
|
|
108
|
+
assert(input?.forwarded === false || input?.forwarded === true, 'Invalid introduction route');
|
|
109
|
+
const record = await verifyIntroduction(input.record, { roomId, policy, now: now() });
|
|
110
|
+
alive();
|
|
111
|
+
const { body } = record;
|
|
112
|
+
assert(!seen.has(body.id), 'Replayed introduction');
|
|
113
|
+
assert(seen.size < policy.maxMessagesPerWindow, 'Introduction replay allocation exhausted');
|
|
114
|
+
seen.set(body.id, body.expiresAt);
|
|
115
|
+
if (body.to !== peerId) {
|
|
116
|
+
assert(!input.forwarded && via === body.from && body.to !== via, 'Introduction forwarding limit');
|
|
117
|
+
assert(transport.getConnectedPeers().some(peer => peer.id === body.to), 'Introduction recipient unavailable');
|
|
118
|
+
await permitted('forward', { from: body.contact, to: body.to });
|
|
119
|
+
alive();
|
|
120
|
+
assert(transport.sendToPeer(body.to, INTRODUCTION_SIGNAL, { record, forwarded: true }), 'Introduction forwarding failed');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const remote = body.from, route = replyRoute || directRoute(via);
|
|
124
|
+
await retain(body.contact, route);
|
|
125
|
+
const payload = await openIntroduction(record, contact);
|
|
126
|
+
alive();
|
|
127
|
+
assert(typeof payload.attemptId === 'string' && /^[a-f0-9-]{36}$/.test(payload.attemptId)
|
|
128
|
+
&& ['offer', 'answer', 'ice-candidate'].includes(payload.type), 'Invalid introduction negotiation');
|
|
129
|
+
if (payload.type !== 'ice-candidate') assert(fingerprint(payload[payload.type]), 'Missing unique WebRTC certificate fingerprint');
|
|
130
|
+
// Admission of a contact does not preserve a revoked connection grant.
|
|
131
|
+
await permitted('connect', { contact: body.contact });
|
|
132
|
+
let attempt = attempts.get(remote);
|
|
133
|
+
if (attempt && attempt.id !== payload.attemptId) {
|
|
134
|
+
// One deterministic initiator survives simultaneous introductions.
|
|
135
|
+
assert(payload.type === 'offer' && peerId > remote, 'Introduction attempt collision');
|
|
136
|
+
transport.cancelIntroduction?.(remote, attempt.id); attempts.delete(remote); attempt = null;
|
|
137
|
+
}
|
|
138
|
+
if (!attempt) {
|
|
139
|
+
assert(payload.type === 'offer' || payload.type === 'ice-candidate', 'Unsolicited introduction answer');
|
|
140
|
+
assert(transport.getConnectedPeers().length < policy.maxContacts, 'Connected peer allocation exhausted');
|
|
141
|
+
attempt = allocate(remote, payload.attemptId, route);
|
|
142
|
+
}
|
|
143
|
+
assert(attempt.expiresAt > now(), 'Introduction attempt expired');
|
|
144
|
+
await transport.acceptIntroductionSignal(remote, payload, exchange(remote, attempt));
|
|
145
|
+
}
|
|
146
|
+
async function receiveContact(via, input) {
|
|
147
|
+
assert(typeof input?.forwarded === 'boolean', 'Invalid contact route');
|
|
148
|
+
assert(input.forwarded || input.contact?.body?.peerId === via, 'Contact source mismatch');
|
|
149
|
+
const record = await retain(input.contact, directRoute(via));
|
|
150
|
+
if (!input.forwarded) {
|
|
151
|
+
for (const peer of transport.getConnectedPeers()) {
|
|
152
|
+
if (peer.id === via) continue;
|
|
153
|
+
try {
|
|
154
|
+
await permitted('forward', { from: record, to: peer.id });
|
|
155
|
+
transport.sendToPeer(peer.id, INTRODUCTION_CONTACT, { contact: record, forwarded: true });
|
|
156
|
+
const other = contacts.get(peer.id)?.record;
|
|
157
|
+
if (other) {
|
|
158
|
+
await permitted('forward', { from: other, to: via });
|
|
159
|
+
transport.sendToPeer(via, INTRODUCTION_CONTACT, { contact: other, forwarded: true });
|
|
160
|
+
}
|
|
161
|
+
} catch (error) { onError(error); }
|
|
162
|
+
}
|
|
163
|
+
} else if (peerId < record.body.peerId) await connectRecord(record, directRoute(via));
|
|
164
|
+
}
|
|
165
|
+
const receive = (via, type, value, replyRoute) => {
|
|
166
|
+
// Snapshot before the first asynchronous authority check.
|
|
167
|
+
const input = boundedSnapshot(value, policy.maxMessageBytes);
|
|
168
|
+
return bounded(() => type === INTRODUCTION_CONTACT ? receiveContact(via, input)
|
|
169
|
+
: type === INTRODUCTION_SIGNAL ? receiveSignal(via, input, replyRoute) : Promise.reject(new Error('Unknown introduction message')));
|
|
170
|
+
};
|
|
171
|
+
transport.onMessage(INTRODUCTION_CONTACT, (via, value) => { receive(via, INTRODUCTION_CONTACT, value).catch(onError); });
|
|
172
|
+
transport.onMessage(INTRODUCTION_SIGNAL, (via, value) => { receive(via, INTRODUCTION_SIGNAL, value).catch(onError); });
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
exportContact() { alive(); return structuredClone(contact.record); },
|
|
175
|
+
async announce(remote) {
|
|
176
|
+
alive(); prune();
|
|
177
|
+
await permitted('announce', { to: remote });
|
|
178
|
+
if (contact.record.body.expiresAt <= now() + policy.messageTtlMs) {
|
|
179
|
+
assert(attempts.size === 0, 'Cannot rotate contact during negotiation');
|
|
180
|
+
const next = await createContact({ identity, peerId, roomId, sequence: Math.max(now(), contact.record.body.sequence + 1), policy, createAdmission, now: now() });
|
|
181
|
+
alive(); contact = next;
|
|
182
|
+
}
|
|
183
|
+
transport.sendToPeer(remote, INTRODUCTION_CONTACT, { contact: contact.record, forwarded: false });
|
|
184
|
+
},
|
|
185
|
+
async connect(input, route, signal) {
|
|
186
|
+
signal?.throwIfAborted();
|
|
187
|
+
assert(typeof route === 'function', 'Reachable introduction route required');
|
|
188
|
+
return bounded(async () => { const record = await retain(input, route); await connectRecord(record, route, signal); });
|
|
189
|
+
},
|
|
190
|
+
receive,
|
|
191
|
+
cancelPending(remote) {
|
|
192
|
+
const attempt = attempts.get(remote);
|
|
193
|
+
if (attempt && !attempt.established) { transport.cancelIntroduction?.(remote, attempt.id); attempts.delete(remote); }
|
|
194
|
+
},
|
|
195
|
+
retirePeer(remote, attemptId) {
|
|
196
|
+
const attempt = attempts.get(remote);
|
|
197
|
+
if (!attempt || !attemptId || attempt.id === attemptId) { contacts.delete(remote); attempts.delete(remote); }
|
|
198
|
+
},
|
|
199
|
+
settled(remote) { const attempt = attempts.get(remote); if (attempt) attempt.established = true; },
|
|
200
|
+
getState: () => ({ contacts: contacts.size, pending: attempts.size, closed }),
|
|
201
|
+
close() {
|
|
202
|
+
closed = true;
|
|
203
|
+
for (const [remote, attempt] of attempts) {
|
|
204
|
+
if (!attempt.established) transport.cancelIntroduction?.(remote, attempt.id);
|
|
205
|
+
}
|
|
206
|
+
contacts.clear(); attempts.clear(); seen.clear();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|