From e8646fde26e1cbb9f21dbe822e3233b4e55e8c62 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 24 Mar 2026 16:57:42 -0400 Subject: [PATCH 001/103] feat: RemoteDiscovery module with Hyperswarm --- package-lock.json | 466 +++++++++++++++++++++++++++-- package.json | 1 + src/discovery/remote-discovery.js | 115 +++++++ test/discovery/remote-discovery.js | 77 +++++ types/hyperswarm.d.ts | 28 ++ 5 files changed, 668 insertions(+), 19 deletions(-) create mode 100644 src/discovery/remote-discovery.js create mode 100644 test/discovery/remote-discovery.js create mode 100644 types/hyperswarm.d.ts diff --git a/package-lock.json b/package-lock.json index dc3298ee9..f927e860b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "hypercore": "10.19.0", "hypercore-crypto": "3.4.2", "hyperdrive": "11.5.3", + "hyperswarm": "^4.17.0", "iso-3166": "^4.3.0", "json-stable-stringify": "^1.1.1", "magic-bytes.js": "^1.10.0", @@ -3928,6 +3929,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adaptive-timeout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/adaptive-timeout/-/adaptive-timeout-1.0.1.tgz", + "integrity": "sha512-+seGiBtHqbnyZ0Quq/ZGxxwP66153WBABawa07/QeyuPFzbduPBjiGQAyCiriiLDzt3dkbMBskSlfqtRwblK8Q==", + "license": "Apache-2.0", + "dependencies": { + "xache": "^1.2.1" + } + }, "node_modules/aggregate-error": { "version": "4.0.1", "dev": true, @@ -4360,11 +4370,63 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-ansi-escapes": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", + "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-stream": "^2.6.5" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-assert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", + "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-inspect": "^3.1.2" + } + }, "node_modules/bare-events": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", - "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", - "optional": true + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { "version": "2.3.1", @@ -4377,6 +4439,36 @@ "bare-stream": "^2.0.0" } }, + "node_modules/bare-inspect": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", + "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", + "license": "Apache-2.0", + "dependencies": { + "bare-ansi-escapes": "^2.1.0", + "bare-type": "^1.0.0" + }, + "engines": { + "bare": ">=1.18.0" + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", + "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, "node_modules/bare-os": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.0.tgz", @@ -4392,13 +4484,41 @@ "bare-os": "^2.1.0" } }, + "node_modules/bare-semver": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", + "integrity": "sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA==", + "license": "Apache-2.0" + }, "node_modules/bare-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.1.3.tgz", - "integrity": "sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==", - "optional": true, + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.10.0.tgz", + "integrity": "sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==", + "license": "Apache-2.0", "dependencies": { - "streamx": "^2.18.0" + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", + "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.2.0" } }, "node_modules/base-x": { @@ -4512,6 +4632,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/bits-to-bytes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bits-to-bytes/-/bits-to-bytes-1.3.0.tgz", + "integrity": "sha512-OJoHTpFXS9bXHBCekGTByf3MqM8CGblBDIduKQeeVVeiU9dDWywSSirXIBYGgg3d1zbVuvnMa1vD4r6PA0kOKg==", + "license": "ISC", + "dependencies": { + "b4a": "^1.5.0" + } + }, "node_modules/bl": { "version": "4.1.0", "license": "MIT", @@ -4521,6 +4650,53 @@ "readable-stream": "^3.4.0" } }, + "node_modules/blind-relay": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.4.0.tgz", + "integrity": "sha512-6xt7fDfCs6eGmNNym6I9N42jmjcMQn2qwwOVnkP9ZnrkXFk6c4/tdO1xqRmDEzKzV8gigd+DVdCUG/RUYnen7Q==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4", + "bare-events": "^2.2.0", + "bits-to-bytes": "^1.3.0", + "compact-encoding": "^2.12.0", + "compact-encoding-bitfield": "^1.0.0", + "protomux": "^3.5.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.15.1" + } + }, + "node_modules/blind-relay/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/blind-relay/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/bogon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.1.0.tgz", @@ -5043,6 +5219,15 @@ "b4a": "^1.3.0" } }, + "node_modules/compact-encoding-bitfield": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.0.0.tgz", + "integrity": "sha512-3nMVKUg+PF72UHfainmCL8uKvyWfxsjqOtUY+HiMPGLPCTjnwzoKfFAMo1Ad7nwTPdjBqtGK5b3BOFTFW4EBTg==", + "license": "ISC", + "dependencies": { + "compact-encoding": "^2.4.1" + } + }, "node_modules/compact-encoding-net": { "version": "1.2.0", "license": "ISC", @@ -5730,6 +5915,57 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dht-rpc": { + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.26.3.tgz", + "integrity": "sha512-KuLfRv/hecUHipQcTXHpVv4/N4Jhpww5sLdsrn3Edm5oHwzK9SgNV34hNt7a2aVZCOcG5SfP4AvcQ7pI+y9YNg==", + "license": "MIT", + "dependencies": { + "adaptive-timeout": "^1.0.1", + "b4a": "^1.6.1", + "bare-events": "^2.2.0", + "compact-encoding": "^2.11.0", + "compact-encoding-net": "^1.2.0", + "fast-fifo": "^1.1.0", + "kademlia-routing-table": "^1.0.1", + "nat-sampler": "^1.0.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.13.2", + "time-ordered-set": "^2.0.0", + "udx-native": "^1.5.3" + } + }, + "node_modules/dht-rpc/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/dht-rpc/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/dir-glob": { "version": "3.0.1", "dev": true, @@ -6469,6 +6705,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.1.tgz", @@ -7450,6 +7695,88 @@ "version": "1.0.0", "license": "Apache-2.0" }, + "node_modules/hypercore-id-encoding": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/hypercore-id-encoding/-/hypercore-id-encoding-1.3.0.tgz", + "integrity": "sha512-W6sHdGo5h7LXEsoWfKf/KfuROZmZRQDlGqJF2EPHW+noCK66Vvr0+zE6cL0vqQi18s0kQPeN7Sq3QyR0Ytc2VQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.5.3", + "z32": "^1.0.0" + } + }, + "node_modules/hyperdht": { + "version": "6.29.4", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.29.4.tgz", + "integrity": "sha512-j+2kzyDXAlB7wR3Vg05dsKk5cEJLnG+Phw0jpfXQLEo+5YtiilKgtEW7HWWlMz3pXeqK1HbL/Y5iEa6TJkdztg==", + "license": "MIT", + "dependencies": { + "@hyperswarm/secret-stream": "^6.6.2", + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "blind-relay": "^1.3.0", + "bogon": "^1.0.0", + "compact-encoding": "^2.4.1", + "compact-encoding-net": "^1.0.1", + "dht-rpc": "^6.15.1", + "hypercore-crypto": "^3.3.0", + "hypercore-id-encoding": "^1.2.0", + "noise-curve-ed": "^2.0.0", + "noise-handshake": "^4.0.0", + "record-cache": "^1.1.1", + "safety-catch": "^1.0.1", + "signal-promise": "^1.0.3", + "sodium-universal": "^5.0.1", + "streamx": "^2.16.1", + "unslab": "^1.3.0", + "xache": "^1.1.0" + }, + "bin": { + "hyperdht": "bin.js" + } + }, + "node_modules/hyperdht/node_modules/noise-handshake": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/noise-handshake/-/noise-handshake-4.2.0.tgz", + "integrity": "sha512-9O/VTNX/E2/AToyMTTDU0J/4WhaXMTdqc2DHs9vf+snoZ0cenSBq0dNYTVV1snYYEkmo6QeRrYMxtqtoYnY+LA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.1.0", + "nanoassert": "^2.0.0", + "sodium-universal": "^5.0.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/hyperdrive": { "version": "11.5.3", "license": "Apache-2.0", @@ -7466,6 +7793,21 @@ "unix-path-resolve": "^1.0.2" } }, + "node_modules/hyperswarm": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.0.tgz", + "integrity": "sha512-oe86sK961Ueg7rvDN/veFwG8xH+Iv6vObPhGDkPJcDVxk/NduW41ZhAcVDnHzRbm7S0eLU7WaDUvehOYoKSpRQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "hyperdht": "^6.21.0", + "safety-catch": "^1.0.2", + "shuffled-priority-queue": "^2.1.0", + "streamx": "^2.22.1", + "unslab": "^1.3.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8178,6 +8520,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/kademlia-routing-table": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/kademlia-routing-table/-/kademlia-routing-table-1.0.6.tgz", + "integrity": "sha512-Ve6jwIlUCYvUzBnXnzVRHDZCFgXURW9gmF3r7n05kZs/2rNbLHXwGdcq0qIaSwdmJCvtosgR4JensnVU65hzNQ==", + "license": "MIT", + "dependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/kind-of": { "version": "6.0.3", "dev": true, @@ -9222,6 +9573,12 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/nat-sampler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nat-sampler/-/nat-sampler-1.0.1.tgz", + "integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -10742,6 +11099,15 @@ "node": ">= 12.13.0" } }, + "node_modules/record-cache": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/record-cache/-/record-cache-1.2.0.tgz", + "integrity": "sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==", + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1" + } + }, "node_modules/redent": { "version": "4.0.0", "dev": true, @@ -10784,6 +11150,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-addon": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", + "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "license": "MIT", @@ -11235,6 +11613,15 @@ "@types/hast": "^3.0.4" } }, + "node_modules/shuffled-priority-queue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz", + "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==", + "license": "MIT", + "dependencies": { + "unordered-set": "^2.0.1" + } + }, "node_modules/side-channel": { "version": "1.0.4", "dev": true, @@ -11253,6 +11640,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-promise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz", + "integrity": "sha512-WBgv0UnIq2C+Aeh0/n+IRpP6967eIx9WpynTUoiW3isPpfe1zu2LJzyfXdo9Tgef8yR/sGjcMvoUXD7EYdiz+g==", + "license": "MIT" + }, "node_modules/signed-varint": { "version": "2.0.1", "license": "MIT", @@ -11604,16 +11997,14 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.19.0.tgz", - "integrity": "sha512-5z6CNR4gtkPbwlxyEqoDGDmWIzoNJqCBt4Eac1ICP9YaIT08ct712cFj0u1rx4F8luAuL+3Qc+RFIdI4OX00kg==", + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -12050,6 +12441,15 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "license": "MIT", @@ -12124,6 +12524,12 @@ "readable-stream": "3" } }, + "node_modules/time-ordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/time-ordered-set/-/time-ordered-set-2.0.1.tgz", + "integrity": "sha512-VJEKmgSN2UiOLB8BpN8Sh2b9LGMHTP5OPrQRpnKjvOheOyzk0mufbjzjKTIG2gO4A+Y+vDJ+0TcLbpUmMLsg8A==", + "license": "MIT" + }, "node_modules/timeout-refresh": { "version": "2.0.1", "license": "MIT" @@ -12448,6 +12854,21 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "node_modules/udx-native": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.19.2.tgz", + "integrity": "sha512-RNYh+UhfryCsF5hE2ZOuIqcZ+qdipXK3UsarwxWJwsUQZFE3ybwz0mPjwb5ev1PMBcjFahWiepS/q0wwL51c2g==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.5.0", + "bare-events": "^2.2.0", + "require-addon": "^1.1.0", + "streamx": "^2.22.0" + }, + "engines": { + "bare": ">=1.17.4" + } + }, "node_modules/un-m49": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/un-m49/-/un-m49-2.2.0.tgz", @@ -12598,6 +13019,12 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/unordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz", + "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", + "license": "MIT" + }, "node_modules/unslab": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", @@ -12748,8 +13175,7 @@ "node_modules/which-runtime": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.2.1.tgz", - "integrity": "sha512-8feIHccQFH/whiA1fD1b4c5+Q7T4ry1g1oHYc2mHnFh81tTQFsCvy3zhS2geUapkFAVBddUT/AM1a3rbqJweFg==", - "optional": true + "integrity": "sha512-8feIHccQFH/whiA1fD1b4c5+Q7T4ry1g1oHYc2mHnFh81tTQFsCvy3zhS2geUapkFAVBddUT/AM1a3rbqJweFg==" }, "node_modules/which-typed-array": { "version": "1.1.11", @@ -12823,7 +13249,9 @@ } }, "node_modules/xache": { - "version": "1.1.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/xache/-/xache-1.2.1.tgz", + "integrity": "sha512-igRS6jPreJ54ABdzhh4mCDXcz+XMaWO2q1ABRV2yWYuk29jlp8VT7UBdCqNkX7rpYBbXsebVVKkwIuYZjyZNqA==", "license": "MIT" }, "node_modules/xstate": { diff --git a/package.json b/package.json index bbdefa063..528b169c6 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "hypercore": "10.19.0", "hypercore-crypto": "3.4.2", "hyperdrive": "11.5.3", + "hyperswarm": "^4.17.0", "iso-3166": "^4.3.0", "json-stable-stringify": "^1.1.1", "magic-bytes.js": "^1.10.0", diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js new file mode 100644 index 000000000..21f83b02e --- /dev/null +++ b/src/discovery/remote-discovery.js @@ -0,0 +1,115 @@ +import { TypedEmitter } from 'tiny-typed-emitter' +import { Logger } from '../logger.js' +import Hyperswarm from 'hyperswarm' +import { pEvent } from 'p-event' + +/** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ + +/** + * @typedef {Object} DiscoveryEvents + * @property {(connection: OpenedNoiseStream) => void} connection + */ + +/** + * @extends {TypedEmitter} + */ +export class RemoteDiscovery extends TypedEmitter { + #l + /** @type {Hyperswarm?} */ + #swarm = null + /** @type {Promise?} */ + #loading = null + #identityKeypair + + /** + * @param {Object} opts + * @param {import('./local-discovery.js').Keypair} opts.identityKeypair + * @param {Logger} [opts.logger] + */ + constructor({ identityKeypair, logger }) { + super() + this.#l = Logger.create('RemoteDiscovery', logger) + this.#identityKeypair = identityKeypair + } + + async #initSwarm() { + this.#l.log('Initializing swarm') + + const swarm = new Hyperswarm({ + keyPair: this.#identityKeypair, + maxPeers: 4, + }) + swarm.on('connection', this.#handleHyperswarmConnection.bind(this)) + this.#l.log('Starting listen') + await swarm.listen() + this.#l.log('Listening') + return swarm + } + + /** + * @returns {Promise} + */ + async #ensureSwarm() { + await this.#loading + if (!this.#swarm) { + this.#loading = this.#initSwarm() + this.#swarm = await this.#loading + } + return this.#swarm + } + + /** + * Start listening for incoming connections + */ + async start() { + // TODO: Use start stop state machine + const swarm = await this.#ensureSwarm() + await swarm.resume() + } + /** + * Close all connections and stop listening + */ + async stop() { + await this.#loading + await this.#swarm?.suspend() + } + + async close() { + await this.#loading + await this.#swarm?.destroy() + } + + /** + * Connect to another peer by their NOISE public key + * @param {string} publicKey + * @param {object} [opts] + * @param {number} [opts.timeout] + */ + async connectPeer(publicKey, { timeout = 60_000 } = {}) { + const swarm = await this.#ensureSwarm() + const noisePublicKey = Buffer.from(publicKey, 'hex') + + const onConnected = pEvent(this, 'connection', { + filter: (connection) => connection.publicKey.equals(noisePublicKey), + timeout, + }) + // Start trying to connect + swarm.joinPeer(noisePublicKey) + this.#l.log('Connecting to %S', publicKey) + + // Wait for discovery to finish + await swarm.flush() + this.#l.log('Discovery finished for %S', publicKey) + const socket = await onConnected + + return socket + } + + /** + * @param {OpenedNoiseStream} socket + * @param {import('hyperswarm').PeerInfo} _peerInfo + */ + #handleHyperswarmConnection(socket, _peerInfo) { + this.emit('connection', socket) + } +} diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js new file mode 100644 index 000000000..adee0f9e0 --- /dev/null +++ b/test/discovery/remote-discovery.js @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { KeyManager, keyToPublicId } from '@mapeo/crypto' +import pDefer from 'p-defer' +import { RemoteDiscovery } from '../../src/discovery/remote-discovery.js' + +test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { + const identityKeypair1 = new KeyManager(randomBytes(16)).getIdentityKeypair() + const identityKeypair2 = new KeyManager(randomBytes(16)).getIdentityKeypair() + + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + }) + const remoteDiscovery2 = new RemoteDiscovery({ + identityKeypair: identityKeypair2, + }) + + t.after(() => + Promise.all([remoteDiscovery1.close(), remoteDiscovery2.close()]) + ) + + // Start both instances + await Promise.all([remoteDiscovery1.start(), remoteDiscovery2.start()]) + + const deferred = pDefer() + const publicKey2Hex = identityKeypair2.publicKey.toString('hex') + + // Listen for connection on instance 1 + remoteDiscovery1.on('connection', (stream) => { + stream.on('error', handleConnectionError) + // Verify the remote peer's public key matches instance 2 + assert.ok( + stream.remotePublicKey.equals(identityKeypair2.publicKey), + 'remote public key should match instance 2' + ) + deferred.resolve(stream) + }) + + // Connect from instance 2 to instance 1 + const connectionPromise = remoteDiscovery2.connectPeer(publicKey2Hex) + + const stream = await deferred.promise + const outboundStream = await connectionPromise + + // Verify both sides have the correct keypairs + assert.ok( + stream.remotePublicKey.equals(identityKeypair2.publicKey), + 'instance 1 should have instance 2 public key' + ) + assert.ok( + outboundStream.remotePublicKey.equals(identityKeypair1.publicKey), + 'instance 2 should have instance 1 public key' + ) + + // Verify the public IDs match + const peerId1 = keyToPublicId(identityKeypair1.publicKey) + const peerId2 = keyToPublicId(identityKeypair2.publicKey) + + assert.equal( + keyToPublicId(stream.remotePublicKey), + peerId2, + 'instance 1 connected to correct peer' + ) + assert.equal( + keyToPublicId(outboundStream.remotePublicKey), + peerId1, + 'instance 2 connected to correct peer' + ) +}) + +/** + * @param {Error} e + */ +function handleConnectionError(e) { + assert.fail(`Unexpected connection error: ${e.message}`) +} diff --git a/types/hyperswarm.d.ts b/types/hyperswarm.d.ts new file mode 100644 index 000000000..d815bf2f6 --- /dev/null +++ b/types/hyperswarm.d.ts @@ -0,0 +1,28 @@ +declare module 'hyperswarm' { + import { TypedEmitter } from 'tiny-typed-emitter' + + interface PeerInfo { + publicKey: Buffer + topics: Buffer[] + ban(banStatus: boolean) + } + + interface Keypair { + publicKey: Buffer + secretKey: Buffer + } + + interface SwarmEvents { + connection(socket: Socket, PeerInfo: PeerInfo): void + } + export default class Hyperswarm extends TypedEmitter { + constructor({ keyPair: Keypair, maxPeers: number }) + listen(): Promise + flush(): Promise + suspend(): Promise + resume(): Promise + destroy(): Promise + joinPeer(noisePublicKey: Buffer): void + leavePeer(noisePublicKey: Buffer): void + } +} From 2f14b72d0bf2be2d9e1114ff48d4f4b646cfd483 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 25 Mar 2026 11:12:02 -0400 Subject: [PATCH 002/103] fix: Connect to correct pear in remote discovery test --- src/discovery/remote-discovery.js | 1 + test/discovery/remote-discovery.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 21f83b02e..e9f2a18f9 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -77,6 +77,7 @@ export class RemoteDiscovery extends TypedEmitter { async close() { await this.#loading await this.#swarm?.destroy() + this.#l.log('Closed swarm') } /** diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index adee0f9e0..2087c4078 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -24,7 +24,7 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => await Promise.all([remoteDiscovery1.start(), remoteDiscovery2.start()]) const deferred = pDefer() - const publicKey2Hex = identityKeypair2.publicKey.toString('hex') + const publicKey1Hex = identityKeypair1.publicKey.toString('hex') // Listen for connection on instance 1 remoteDiscovery1.on('connection', (stream) => { @@ -38,10 +38,10 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => }) // Connect from instance 2 to instance 1 - const connectionPromise = remoteDiscovery2.connectPeer(publicKey2Hex) + const connectionPromise = remoteDiscovery2.connectPeer(publicKey1Hex) - const stream = await deferred.promise const outboundStream = await connectionPromise + const stream = await deferred.promise // Verify both sides have the correct keypairs assert.ok( From 4255361280b01ee1fa4231866a68e970e05b1184 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 25 Mar 2026 11:48:46 -0400 Subject: [PATCH 003/103] fix: Update bogon for hyperdht filtering --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f927e860b..0d39917de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "bcp-47-normalize": "^2.3.0", "better-sqlite3": "^11.10.0", "big-sparse-array": "^1.0.3", - "bogon": "^1.1.0", + "bogon": "^1.2.0", "comapeocat": "^1.0.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", @@ -4698,9 +4698,10 @@ } }, "node_modules/bogon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.1.0.tgz", - "integrity": "sha512-a6SnToksXHuUlgeMvI/txWmTcKz7c7iBa8f0HbXL4toN1Uza/CTQ4F7n9jSDX49TCpxv3KUP100q4sZfwLyLiw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.2.0.tgz", + "integrity": "sha512-FqOBr/1VMzCOsoJd+fzNUMarUYki2+TKt07A2+xaulsNx4r53iJ7MV5k0jbqg7W2U0CsLqxCZOrFibdG6h6HSg==", + "license": "MIT", "dependencies": { "compact-encoding": "^2.11.0", "compact-encoding-net": "^1.2.0" diff --git a/package.json b/package.json index 528b169c6..43e95bea2 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "bcp-47-normalize": "^2.3.0", "better-sqlite3": "^11.10.0", "big-sparse-array": "^1.0.3", - "bogon": "^1.1.0", + "bogon": "^1.2.0", "comapeocat": "^1.0.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", From ad6c0bece93ab8986c7f76ad8c21b8aea9248d78 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 25 Mar 2026 11:58:48 -0400 Subject: [PATCH 004/103] test: Fix peer detection in remote discovery --- src/discovery/remote-discovery.js | 5 +---- test/discovery/remote-discovery.js | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index e9f2a18f9..fa6294f09 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -91,16 +91,13 @@ export class RemoteDiscovery extends TypedEmitter { const noisePublicKey = Buffer.from(publicKey, 'hex') const onConnected = pEvent(this, 'connection', { - filter: (connection) => connection.publicKey.equals(noisePublicKey), + filter: (connection) => connection.remotePublicKey.equals(noisePublicKey), timeout, }) // Start trying to connect swarm.joinPeer(noisePublicKey) this.#l.log('Connecting to %S', publicKey) - // Wait for discovery to finish - await swarm.flush() - this.#l.log('Discovery finished for %S', publicKey) const socket = await onConnected return socket diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 2087c4078..c8f2ace59 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -1,13 +1,16 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { randomBytes } from 'node:crypto' import { KeyManager, keyToPublicId } from '@mapeo/crypto' import pDefer from 'p-defer' import { RemoteDiscovery } from '../../src/discovery/remote-discovery.js' test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { - const identityKeypair1 = new KeyManager(randomBytes(16)).getIdentityKeypair() - const identityKeypair2 = new KeyManager(randomBytes(16)).getIdentityKeypair() + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const identityKeypair2 = new KeyManager( + Buffer.alloc(16, 2) + ).getIdentityKeypair() const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, @@ -41,11 +44,11 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const connectionPromise = remoteDiscovery2.connectPeer(publicKey1Hex) const outboundStream = await connectionPromise - const stream = await deferred.promise + const inboundStream = await deferred.promise // Verify both sides have the correct keypairs assert.ok( - stream.remotePublicKey.equals(identityKeypair2.publicKey), + inboundStream.remotePublicKey.equals(identityKeypair2.publicKey), 'instance 1 should have instance 2 public key' ) assert.ok( @@ -58,7 +61,7 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const peerId2 = keyToPublicId(identityKeypair2.publicKey) assert.equal( - keyToPublicId(stream.remotePublicKey), + keyToPublicId(inboundStream.remotePublicKey), peerId2, 'instance 1 connected to correct peer' ) @@ -67,6 +70,9 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => peerId1, 'instance 2 connected to correct peer' ) + + inboundStream.end() + outboundStream.end() }) /** From f4a8c84968bc5bdd03268803cadac71376a5504a Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 25 Mar 2026 14:53:56 -0400 Subject: [PATCH 005/103] feat: Connect RemoteDiscovery to member API `inviteOverInternet` function --- src/mapeo-manager.js | 11 +++++++ src/mapeo-project.js | 3 ++ src/member-api.js | 75 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 72153d675..1683bc17a 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -62,6 +62,7 @@ import { import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' import { migrate } from './lib/drizzle-helpers.js' +import { RemoteDiscovery } from './discovery/remote-discovery.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ @@ -152,6 +153,7 @@ export class MapeoManager extends TypedEmitter { #invite #fastify #localDiscovery + #remoteDiscovery #loggerBase #l #defaultConfigPath @@ -285,6 +287,11 @@ export class MapeoManager extends TypedEmitter { logger, }) this.#localDiscovery.on('connection', this.#replicate.bind(this)) + this.#remoteDiscovery = new RemoteDiscovery({ + identityKeypair: this.#keyManager.getIdentityKeypair(), + logger, + }) + this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) } get deviceId() { @@ -620,6 +627,10 @@ export class MapeoManager extends TypedEmitter { .where(eq(projectKeysTable.projectId, projectId)) .get()?.projectInfo }, + setShouldListenOverInternet: (shouldListen) => { + if (shouldListen) return this.#remoteDiscovery.start() + else return this.#remoteDiscovery.stop() + }, }) return project diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 126eefda1..5e5d4983e 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -174,6 +174,7 @@ export class MapeoProject extends ReadyResource { * @param {import('./local-peers.js').LocalPeers} opts.localPeers * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo + * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {Logger} [opts.logger] * */ @@ -193,6 +194,7 @@ export class MapeoProject extends ReadyResource { logger, isArchiveDevice, getFallbackProjectInfo, + setShouldListenOverInternet, }) { super() @@ -426,6 +428,7 @@ export class MapeoProject extends ReadyResource { deviceInfo: this.#dataTypes.deviceInfo, project: this.#dataTypes.projectSettings, }, + setShouldListenOverInternet, logger: this.#l, }) diff --git a/src/member-api.js b/src/member-api.js index e291040bb..a2fb7f911 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -48,6 +48,7 @@ import { } from './roles.js' import { kCreateOrUpdateWithDocId } from './datatype/index.js' +export const INTERNET_INVITE_PAGE = 'https://i/comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @@ -59,7 +60,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * } from '@comapeo/schema' */ /** @import { Promisable } from 'type-fest' */ -/** @import { DeviceInfo_DeviceType, Invite, InviteResponse } from './generated/rpc.js' */ +/** @import { Invite, InviteResponse } from './generated/rpc.js' */ /** @import { DataType } from './datatype/index.js' */ /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ @@ -85,10 +86,25 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {DeviceInfo['deviceType']} deviceType */ +/** + * @typedef {object} InviteOptions + * @prop {import('./roles.js').RoleIdForNewInvite} opts.roleId + * @prop {string} [roleName] + * @prop {string} [roleDescription] + * @prop {Buffer} [__testOnlyInviteId] Hard-code the invite ID. Only for tests. + * @prop {number} [initialSyncTimeoutMs=5000] + * @prop {InvitePeerInfo} [peerInfo] + */ + /** * @typedef {Omit & {role: import('./roles.js').Role}} ActiveMemberInfo */ +/** + * @typedef {object} MemberEvents + * @property {(deviceId: string) => void} internet-invite-redeemed Emitted when a + */ + export class MemberApi extends TypedEmitter { #ownDeviceId #roles @@ -100,12 +116,16 @@ export class MemberApi extends TypedEmitter { #makeWebsocket #getReplicationStream #waitForInitialSyncWithPeer + #setShouldListenOverInternet #dataTypes #l /** @type {Map} */ #outboundInvitesByDevice = new Map() + /** @type {Map} */ + #pendingInvitesOverInternet = new Map() + /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string @@ -118,6 +138,7 @@ export class MemberApi extends TypedEmitter { * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer + * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {Object} opts.dataTypes * @param {Pick} opts.dataTypes.deviceInfo * @param {Pick} opts.dataTypes.project @@ -134,6 +155,7 @@ export class MemberApi extends TypedEmitter { makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, + setShouldListenOverInternet, dataTypes, logger, }) { @@ -149,21 +171,58 @@ export class MemberApi extends TypedEmitter { this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer + this.#setShouldListenOverInternet = setShouldListenOverInternet this.#dataTypes = dataTypes } + /** + * Start inviting somone over the internet. Returns a URL for the recipient to load. + * @param {InviteOptions} opts + */ + async inviteOverInternet(opts) { + const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + const url = + INTERNET_INVITE_PAGE + `?i=${inviteIdString}&d=${this.#ownDeviceId}` + + this.#pendingInvitesOverInternet.set(url, { + inviteId, + opts, + }) + + if (this.#pendingInvitesOverInternet.size === 1) { + await this.#setShouldListenOverInternet(true) + } + + return url + } + + /** + * Cancel an invite over internet attempt. Omit the specific URL to cancel all instances + * @param {string} [url] + */ + async cancelInviteOverInternet(url) { + if (!url) { + this.#pendingInvitesOverInternet.clear() + await this.#setShouldListenOverInternet(false) + return + } + if (!this.#pendingInvitesOverInternet.has(url)) { + throw new Error('Invalid internet invite URL') + } + this.#pendingInvitesOverInternet.delete(url) + if (this.#pendingInvitesOverInternet.size === 0) { + await this.#setShouldListenOverInternet(false) + } + } + /** * Send an invite. Resolves when receiving a response. Rejects if the invite * is canceled, or if something else goes wrong. * * @param {string} deviceId - * @param {Object} opts - * @param {import('./roles.js').RoleIdForNewInvite} opts.roleId - * @param {string} [opts.roleName] - * @param {string} [opts.roleDescription] - * @param {Buffer} [opts.__testOnlyInviteId] Hard-code the invite ID. Only for tests. - * @param {number} [opts.initialSyncTimeoutMs=5000] - * @param {InvitePeerInfo} [opts.peerInfo] + * @param {InviteOptions} opts * @returns {Promise<( * typeof InviteResponse_Decision.ACCEPT | * typeof InviteResponse_Decision.REJECT | From 8e2e3ac099108f742b50b40f856327c7840f4228 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 26 Mar 2026 16:19:49 -0400 Subject: [PATCH 006/103] feat: Simplify MemberAPI constructor parameters --- src/mapeo-project.js | 22 +++++++++--- src/member-api.js | 83 ++++++++++++++++---------------------------- 2 files changed, 46 insertions(+), 59 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 5e5d4983e..356d0ef50 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -415,18 +415,30 @@ export class MapeoProject extends ReadyResource { this.#memberApi = new MemberApi({ deviceId: this.#deviceId, roles: this.#roles, - coreOwnership: this.#coreOwnership, encryptionKeys, - getProjectName: this.#getProjectName.bind(this), projectKey, rpc: localPeers, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer: (deviceId, abortSignal) => this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal), - dataTypes: { - deviceInfo: this.#dataTypes.deviceInfo, - project: this.#dataTypes.projectSettings, + getProjectSettings: () => this.$getProjectSettings(), + getDeviceInfo: async (deviceId) => { + try { + return await this.#dataTypes.deviceInfo.getByDocId(deviceId) + } catch (e) { + const configCoreId = await this.#coreOwnership.getCoreId( + deviceId, + 'config' + ) + return this.#dataTypes.deviceInfo.getByDocId(configCoreId) + } + }, + setDeviceInfo: async (deviceId, deviceInfo) => { + await this.#dataTypes.deviceInfo[kCreateOrUpdateWithDocId]( + deviceId, + deviceInfo + ) }, setShouldListenOverInternet, logger: this.#l, diff --git a/src/member-api.js b/src/member-api.js index a2fb7f911..5cf39a6c6 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -6,12 +6,10 @@ import { pEvent } from 'p-event' import { InviteResponse_Decision } from './generated/rpc.js' import { noop, - projectKeyToId, projectKeyToProjectInviteId, projectKeyToPublicId, } from './utils.js' import { Logger } from './logger.js' -import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' @@ -46,7 +44,6 @@ import { ROLES, isRoleIdForNewInvite, } from './roles.js' -import { kCreateOrUpdateWithDocId } from './datatype/index.js' export const INTERNET_INVITE_PAGE = 'https://i/comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] @@ -65,10 +62,12 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ /** @import { projectSettingsTable } from './schema/client.js' */ -/** @import { ReplicationStream } from './types.js' */ +/** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ +/** @typedef {import('./datatype/index.js').ExcludeSchema} NewDeviceInfo */ + /** * @typedef {object} MemberInfo * @prop {string} deviceId @@ -108,16 +107,16 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] export class MemberApi extends TypedEmitter { #ownDeviceId #roles - #coreOwnership #encryptionKeys - #getProjectName #projectKey #rpc #makeWebsocket #getReplicationStream #waitForInitialSyncWithPeer #setShouldListenOverInternet - #dataTypes + #getProjectSettings + #getDeviceInfo + #setDeviceInfo #l /** @type {Map} */ @@ -129,50 +128,48 @@ export class MemberApi extends TypedEmitter { /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string - * @param {import('./roles.js').Roles} opts.roles - * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership + * @param {Pick} opts.roles * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys - * @param {() => Promisable} opts.getProjectName * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet - * @param {Object} opts.dataTypes - * @param {Pick} opts.dataTypes.deviceInfo - * @param {Pick} opts.dataTypes.project + * @param {() => Promise} opts.getProjectSettings + * @param {(deviceId: string) => Promise} opts.getDeviceInfo + * @param {(deviceId: string, deviceInfo: NewDeviceInfo) => Promise} opts.setDeviceInfo * @param {Logger} [opts.logger] */ constructor({ deviceId, roles, - coreOwnership, encryptionKeys, - getProjectName, projectKey, rpc, makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, setShouldListenOverInternet, - dataTypes, + getProjectSettings, + getDeviceInfo, + setDeviceInfo, logger, }) { super() this.#l = Logger.create('member-api', logger) this.#ownDeviceId = deviceId this.#roles = roles - this.#coreOwnership = coreOwnership this.#encryptionKeys = encryptionKeys - this.#getProjectName = getProjectName this.#projectKey = projectKey this.#rpc = rpc this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer this.#setShouldListenOverInternet = setShouldListenOverInternet - this.#dataTypes = dataTypes + this.#getProjectSettings = getProjectSettings + this.#getDeviceInfo = getDeviceInfo + this.#setDeviceInfo = setDeviceInfo } /** @@ -217,6 +214,14 @@ export class MemberApi extends TypedEmitter { } } + /** + * Get the list of pending invites over the internet + * @returns {string[]} + */ + pendingInternetInvites() { + return [...this.#pendingInvitesOverInternet.keys()] + } + /** * Send an invite. Resolves when receiving a response. Rejects if the invite * is canceled, or if something else goes wrong. @@ -264,9 +269,8 @@ export class MemberApi extends TypedEmitter { abortSignal.throwIfAborted() const inviteId = __testOnlyInviteId || crypto.randomBytes(32) - const projectId = projectKeyToId(this.#projectKey) const projectInviteId = projectKeyToProjectInviteId(this.#projectKey) - const project = await this.#dataTypes.project.getByDocId(projectId) + const project = await this.#getProjectSettings() const projectName = project.name if (!projectName) { throw new InvalidProjectNameError() @@ -329,10 +333,7 @@ export class MemberApi extends TypedEmitter { selfHostedServerDetails: undefined, schemaName: /** @type {const} */ ('deviceInfo'), } - await this.#dataTypes.deviceInfo[kCreateOrUpdateWithDocId]( - deviceId, - doc - ) + await this.#setDeviceInfo(deviceId, doc) } try { @@ -532,7 +533,7 @@ export class MemberApi extends TypedEmitter { * @returns {Promise<{ serverDeviceId: string }>} */ async #addServerToProject(baseUrl) { - const projectName = await this.#getProjectName() + const { name: projectName } = await this.#getProjectSettings() if (!projectName) { throw new IncompleteProjectDataError() } @@ -655,22 +656,6 @@ export class MemberApi extends TypedEmitter { return result } - /** - * @param {string} deviceId - * @returns {Promise} - */ - async #getDeviceInfo(deviceId) { - try { - return await this.#dataTypes.deviceInfo.getByDocId(deviceId) - } catch (e) { - const configCoreId = await this.#coreOwnership.getCoreId( - deviceId, - 'config' - ) - return this.#dataTypes.deviceInfo.getByDocId(configCoreId) - } - } - /** * @overload * @returns {Promise>} @@ -686,12 +671,7 @@ export class MemberApi extends TypedEmitter { * */ async getMany({ includeLeft = false } = {}) { - const [allRoles, allDeviceInfo] = await Promise.all([ - this.#roles.getAll(), - this.#dataTypes.deviceInfo.getMany(), - ]) - - const deviceInfoByConfigCoreId = keyBy(allDeviceInfo, ({ docId }) => docId) + const allRoles = await this.#roles.getAll() /** * @type {Array>} @@ -708,12 +688,7 @@ export class MemberApi extends TypedEmitter { const memberInfo = { deviceId, role } try { - const configCoreId = await this.#coreOwnership.getCoreId( - deviceId, - 'config' - ) - - const deviceInfo = deviceInfoByConfigCoreId.get(configCoreId) + const deviceInfo = await this.#getDeviceInfo(deviceId) memberInfo.name = deviceInfo?.name memberInfo.deviceType = deviceInfo?.deviceType From 039689f931c0fc916d231e1e234c19ade5a82273 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 26 Mar 2026 16:20:14 -0400 Subject: [PATCH 007/103] test: Check member API invite over internet properties --- test/member-api.js | 189 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 test/member-api.js diff --git a/test/member-api.js b/test/member-api.js new file mode 100644 index 000000000..961baf1fa --- /dev/null +++ b/test/member-api.js @@ -0,0 +1,189 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { KeyManager } from '@mapeo/crypto' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' + +import { MemberApi } from '../src/member-api.js' +import { LocalPeers } from '../src/local-peers.js' +import { MEMBER_ROLE_ID } from '../src/roles.js' + +/** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ +/** @import WebSocket from 'ws' */ + +test('List pending invites over internet', async () => { + const { member } = setup({}) + + const url1 = await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + const url2 = await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + const pending = member.pendingInternetInvites() + + assert.deepEqual( + pending.toSorted(), + [url1, url2].toSorted(), + 'Both pending URLs returned' + ) +}) + +test('setShouldListenOverInternet called once for multiple invites', async () => { + let callCount = 0 + const { member } = setup({ + setShouldListenOverInternet: async (shouldListen) => { + assert(shouldListen) + callCount++ + }, + }) + + await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + assert.equal(callCount, 1, 'only set once') +}) + +test('Cancel invite over internet requests', async () => { + const { member } = setup({}) + + const url1 = await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + const url2 = await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + await member.cancelInviteOverInternet(url1) + + assert.deepEqual( + await member.pendingInternetInvites(), + [url2], + 'One URL left' + ) + await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + await member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + await member.cancelInviteOverInternet() + + assert.deepEqual(await member.pendingInternetInvites(), [], 'No URLs left') +}) + +class MockLocalPeers extends LocalPeers { + /** + * @param {string} deviceId + * @param {ProjectJoinDetails} details + */ + async sendProjectJoinDetails(deviceId, details) { + this.emit('got-project-details', deviceId, details) + } +} + +/** @extends {Roles} */ +class MockRoles { + /** + * @param {string} _deviceId + * @returns {Promise} + */ + async getRole(_deviceId) { + throw new Error('Not Implemented') + } + /** + * @returns {Promise>} Map of deviceId to Role + */ + async getAll() { + throw new Error('Not Implemented') + } + /** + * @param {string} deviceId + * @param {import('../src/roles.js').RoleIdAssignableToAnyone} roleId + * @param {object} [opts] + * @param {string} opts.reason + */ + async assignRole(deviceId, roleId, opts) { + throw new Error('Not Implemented' + deviceId + roleId + opts) + } +} + +/** + * + * @param {Object} [opts] + * @param {Buffer} [opts.rootKey] + * @param {(url: string) => WebSocket} [opts.makeWebsocket] + * @param {() => import('../src/types.js').ReplicationStream} [opts.getReplicationStream] + * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} [opts.waitForInitialSyncWithPeer] + * @param {(shouldListen: boolean) => Promise} [opts.setShouldListenOverInternet] + * @param {() => Promise} [opts.getProjectSettings] + * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] + * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] + * @returns + */ +function setup({ + rootKey = Buffer.alloc(16, 1), + setShouldListenOverInternet = () => Promise.resolve(), + getProjectSettings = () => + Promise.resolve({ name: 'example', sendStats: false }), + getDeviceInfo = () => Promise.reject(new Error('Not implemented')), + setDeviceInfo = () => Promise.reject(new Error('Not implemented')), + waitForInitialSyncWithPeer = () => Promise.resolve(), + makeWebsocket = () => { + throw new Error('Not implemented') + }, + getReplicationStream = () => { + throw new Error('Not implemented') + }, +} = {}) { + const keyManager = new KeyManager(rootKey) + + const identityKeypair = keyManager.getIdentityKeypair() + const encryptionKeys = { auth: randomBytes(32) } + const projectKey = KeyManager.generateProjectKeypair().publicKey + + const deviceId = identityKeypair.publicKey.toString('hex') + + const sqlite = new Database(':memory:') + const db = drizzle(sqlite) + + migrate(db, { + migrationsFolder: new URL('../drizzle/project', import.meta.url).pathname, + }) + + const rpc = new MockLocalPeers() + const roles = new MockRoles() + + const member = new MemberApi({ + deviceId, + rpc, + roles, + encryptionKeys, + projectKey, + makeWebsocket, + getReplicationStream, + waitForInitialSyncWithPeer, + setShouldListenOverInternet, + getProjectSettings, + getDeviceInfo, + setDeviceInfo, + }) + + return { + rpc, + roles, + member, + projectKey, + } +} From b88f0fdb9f8a149be10069870019ee647e775dc3 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 26 Mar 2026 17:03:28 -0400 Subject: [PATCH 008/103] feat: RPC for redeeming invite over internet --- proto/rpc.proto | 8 ++++ src/generated/rpc.d.ts | 18 ++++++++ src/generated/rpc.js | 84 ++++++++++++++++++++++++++++++++++++ src/generated/rpc.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ src/local-peers.js | 61 ++++++++++++++++++++++++++ src/member-api.js | 57 ++++++++++++++++++++---- 6 files changed, 318 insertions(+), 8 deletions(-) diff --git a/proto/rpc.proto b/proto/rpc.proto index 1f3383c81..e108f2a77 100644 --- a/proto/rpc.proto +++ b/proto/rpc.proto @@ -36,6 +36,10 @@ message ProjectJoinDetails { EncryptionKeys encryptionKeys = 3; } +message RedeemInviteOverInternet { + bytes inviteId = 1; +} + message DeviceInfo { enum DeviceType { device_type_unspecified = 0; @@ -68,6 +72,10 @@ message ProjectJoinDetailsAck { bytes inviteId = 1; } +message RedeemInviteOverInternetAck { + bytes inviteId = 1; +} + message MapShareExtension { // URLs to map share repeated string mapShareUrls = 1; diff --git a/src/generated/rpc.d.ts b/src/generated/rpc.d.ts index c5e2a9dcd..182bde714 100644 --- a/src/generated/rpc.d.ts +++ b/src/generated/rpc.d.ts @@ -34,6 +34,9 @@ export interface ProjectJoinDetails { projectKey: Buffer; encryptionKeys: EncryptionKeys | undefined; } +export interface RedeemInviteOverInternet { + inviteId: Buffer; +} export interface DeviceInfo { name: string; deviceType?: DeviceInfo_DeviceType | undefined; @@ -69,6 +72,9 @@ export interface InviteResponseAck { export interface ProjectJoinDetailsAck { inviteId: Buffer; } +export interface RedeemInviteOverInternetAck { + inviteId: Buffer; +} export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -117,6 +123,12 @@ export declare const ProjectJoinDetails: { create, I>>(base?: I): ProjectJoinDetails; fromPartial, I>>(object: I): ProjectJoinDetails; }; +export declare const RedeemInviteOverInternet: { + encode(message: RedeemInviteOverInternet, writer?: _m0.Writer): _m0.Writer; + decode(input: _m0.Reader | Uint8Array, length?: number): RedeemInviteOverInternet; + create, I>>(base?: I): RedeemInviteOverInternet; + fromPartial, I>>(object: I): RedeemInviteOverInternet; +}; export declare const DeviceInfo: { encode(message: DeviceInfo, writer?: _m0.Writer): _m0.Writer; decode(input: _m0.Reader | Uint8Array, length?: number): DeviceInfo; @@ -147,6 +159,12 @@ export declare const ProjectJoinDetailsAck: { create, I>>(base?: I): ProjectJoinDetailsAck; fromPartial, I>>(object: I): ProjectJoinDetailsAck; }; +export declare const RedeemInviteOverInternetAck: { + encode(message: RedeemInviteOverInternetAck, writer?: _m0.Writer): _m0.Writer; + decode(input: _m0.Reader | Uint8Array, length?: number): RedeemInviteOverInternetAck; + create, I>>(base?: I): RedeemInviteOverInternetAck; + fromPartial, I>>(object: I): RedeemInviteOverInternetAck; +}; export declare const MapShareExtension: { encode(message: MapShareExtension, writer?: _m0.Writer): _m0.Writer; decode(input: _m0.Reader | Uint8Array, length?: number): MapShareExtension; diff --git a/src/generated/rpc.js b/src/generated/rpc.js index 7fc8f6d60..6f94c1235 100644 --- a/src/generated/rpc.js +++ b/src/generated/rpc.js @@ -413,6 +413,48 @@ export var ProjectJoinDetails = { return message; }, }; +function createBaseRedeemInviteOverInternet() { + return { inviteId: Buffer.alloc(0) }; +} +export var RedeemInviteOverInternet = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseRedeemInviteOverInternet(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.inviteId = reader.bytes(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return RedeemInviteOverInternet.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a; + var message = createBaseRedeemInviteOverInternet(); + message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + return message; + }, +}; function createBaseDeviceInfo() { return { name: "", features: [] }; } @@ -653,6 +695,48 @@ export var ProjectJoinDetailsAck = { return message; }, }; +function createBaseRedeemInviteOverInternetAck() { + return { inviteId: Buffer.alloc(0) }; +} +export var RedeemInviteOverInternetAck = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseRedeemInviteOverInternetAck(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.inviteId = reader.bytes(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return RedeemInviteOverInternetAck.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a; + var message = createBaseRedeemInviteOverInternetAck(); + message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + return message; + }, +}; function createBaseMapShareExtension() { return { mapShareUrls: [], diff --git a/src/generated/rpc.ts b/src/generated/rpc.ts index ccfb307a4..6347d2144 100644 --- a/src/generated/rpc.ts +++ b/src/generated/rpc.ts @@ -78,6 +78,10 @@ export interface ProjectJoinDetails { encryptionKeys: EncryptionKeys | undefined; } +export interface RedeemInviteOverInternet { + inviteId: Buffer; +} + export interface DeviceInfo { name: string; deviceType?: DeviceInfo_DeviceType | undefined; @@ -182,6 +186,10 @@ export interface ProjectJoinDetailsAck { inviteId: Buffer; } +export interface RedeemInviteOverInternetAck { + inviteId: Buffer; +} + export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -528,6 +536,51 @@ export const ProjectJoinDetails = { }, }; +function createBaseRedeemInviteOverInternet(): RedeemInviteOverInternet { + return { inviteId: Buffer.alloc(0) }; +} + +export const RedeemInviteOverInternet = { + encode(message: RedeemInviteOverInternet, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): RedeemInviteOverInternet { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRedeemInviteOverInternet(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.inviteId = reader.bytes() as Buffer; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + create, I>>(base?: I): RedeemInviteOverInternet { + return RedeemInviteOverInternet.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RedeemInviteOverInternet { + const message = createBaseRedeemInviteOverInternet(); + message.inviteId = object.inviteId ?? Buffer.alloc(0); + return message; + }, +}; + function createBaseDeviceInfo(): DeviceInfo { return { name: "", features: [] }; } @@ -787,6 +840,51 @@ export const ProjectJoinDetailsAck = { }, }; +function createBaseRedeemInviteOverInternetAck(): RedeemInviteOverInternetAck { + return { inviteId: Buffer.alloc(0) }; +} + +export const RedeemInviteOverInternetAck = { + encode(message: RedeemInviteOverInternetAck, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): RedeemInviteOverInternetAck { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRedeemInviteOverInternetAck(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.inviteId = reader.bytes() as Buffer; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + create, I>>(base?: I): RedeemInviteOverInternetAck { + return RedeemInviteOverInternetAck.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RedeemInviteOverInternetAck { + const message = createBaseRedeemInviteOverInternetAck(); + message.inviteId = object.inviteId ?? Buffer.alloc(0); + return message; + }, +}; + function createBaseMapShareExtension(): MapShareExtension { return { mapShareUrls: [], diff --git a/src/local-peers.js b/src/local-peers.js index c246f2ff9..1042a347e 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -16,6 +16,8 @@ import { ProjectJoinDetailsAck, DeviceInfo_RPCFeatures, MapShareExtension, + RedeemInviteOverInternet, + RedeemInviteOverInternetAck, } from './generated/rpc.js' import pDefer from 'p-defer' import { Logger } from './logger.js' @@ -71,6 +73,8 @@ const MESSAGE_TYPES = { InviteResponseAck: 7, ProjectJoinDetailsAck: 8, MapShareExtension: 9, + RedeemInviteOverInternet: 10, + RedeemInviteOverInternetAck: 11, } const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) @@ -387,6 +391,39 @@ class Peer { await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) } + /** + * @param {RedeemInviteOverInternet} redeem + * @returns {Promise} + */ + async sendRedeemInviteOverInternet(redeem) { + this.#assertConnected( + 'Peer disconnected before sending redeem over internet' + ) + const buf = Buffer.from(RedeemInviteOverInternet.encode(redeem).finish()) + const messageType = MESSAGE_TYPES.RedeemInviteOverInternet + await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) + await this.#waitForAck('RedeemInviteOverInternetAck', ({ inviteId }) => + timingSafeEqual(inviteId, redeem.inviteId) + ) + this.#log('redeemed invite over internet %h: %s', redeem.inviteId) + } + + /** + * @param {RedeemInviteOverInternet} redeem + * @returns {Promise} + */ + async sendRedeemInviteOverInternetAck({ inviteId }) { + this.#assertConnected( + 'Peer disconnected before sending redeem over internet ack' + ) + if (!this.supportsAck()) return + const buf = Buffer.from( + RedeemInviteOverInternetAck.encode({ inviteId }).finish() + ) + const messageType = MESSAGE_TYPES.RedeemInviteOverInternetAck + await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) + } + /** @param {ProjectJoinDetails} details */ async sendProjectJoinDetails(details) { this.#assertConnected( @@ -446,6 +483,8 @@ class Peer { * @property {(peerId: string, invite: InviteCancelAck) => void} invite-cancel-ack Emitted when we receive a cancelation acknowledgement for an invite * @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received * @property {(peerId: string, inviteResponse: InviteResponseAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received + * @property {(peerId: string, inviteResponse: RedeemInviteOverInternet) => void} invite-over-internet-redeemed Emitted when a peer attempts to redeem an invite over the internet + * @property {(peerId: string, inviteResponse: RedeemInviteOverInternetAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received * @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received * @property {(sender: PeerInfo, details: MapShareExtension) => void} map-share Emitted when a MapShare request is received @@ -529,6 +568,18 @@ export class LocalPeers extends TypedEmitter { await peer.sendInviteResponse(inviteResponse) } + /** + * Redeem an invite over the internet + * + * @param {string} deviceId id of the peer you want to redeem from (publicKey of peer as hex string) + * @param {RedeemInviteOverInternet} redeem + */ + async sendRedeemInviteOverInternet(deviceId, redeem) { + await this.#waitForPendingConnections() + const peer = await this.#getPeerByDeviceId(deviceId) + await peer.sendRedeemInviteOverInternet(redeem) + } + /** * @param {string} deviceId * @param {ProjectJoinDetails} details @@ -795,6 +846,16 @@ export class LocalPeers extends TypedEmitter { }) break } + case 'RedeemInviteOverInternet': { + const redeem = RedeemInviteOverInternet.decode(value) + const peerId = keyToId(protomux.stream.remotePublicKey) + + this.emit('invite-over-internet-redeemed', peerId, redeem) + peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { + this.#l.log(`Error sending redeem over internet ack ${e.stack}`) + }) + break + } case 'ProjectJoinDetails': { const details = parseProjectJoinDetails(value) const peerId = keyToId(protomux.stream.remotePublicKey) diff --git a/src/member-api.js b/src/member-api.js index 5cf39a6c6..ede0ab25d 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -56,8 +56,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * ProjectSettingsValue, * } from '@comapeo/schema' */ -/** @import { Promisable } from 'type-fest' */ -/** @import { Invite, InviteResponse } from './generated/rpc.js' */ +/** @import { Invite, InviteResponse, RedeemInviteOverInternet } from './generated/rpc.js' */ /** @import { DataType } from './datatype/index.js' */ /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ @@ -95,15 +94,27 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {InvitePeerInfo} [peerInfo] */ +/** + * @typedef {( + * typeof InviteResponse_Decision.ACCEPT | + * typeof InviteResponse_Decision.REJECT | + * typeof InviteResponse_Decision.ALREADY + * )} InviteDecision + */ + /** * @typedef {Omit & {role: import('./roles.js').Role}} ActiveMemberInfo */ /** * @typedef {object} MemberEvents - * @property {(deviceId: string) => void} internet-invite-redeemed Emitted when a + * @property {(deviceId: string, decision: InviteDecision, url: string) => void} internet-invite-redeemed Emitted when an invite over the internet has been redeemed + * @property {(err: Error, deviceId: string, url: string) => void} internet-invite-redeem-error Emitted when an invite over the internet has failed to be redeemed */ +/** + * @extends {TypedEmitter} + */ export class MemberApi extends TypedEmitter { #ownDeviceId #roles @@ -170,6 +181,10 @@ export class MemberApi extends TypedEmitter { this.#getProjectSettings = getProjectSettings this.#getDeviceInfo = getDeviceInfo this.#setDeviceInfo = setDeviceInfo + + rpc.on('invite-over-internet-redeemed', (peerId, redeem) => + this.#handleRedeemInviteOverInternet(peerId, redeem) + ) } /** @@ -222,17 +237,43 @@ export class MemberApi extends TypedEmitter { return [...this.#pendingInvitesOverInternet.keys()] } + /** + * + * @param {string} peerId + * @param {RedeemInviteOverInternet} redeem + */ + async #handleRedeemInviteOverInternet(peerId, { inviteId }) { + const inviteIdString = inviteId.toString('hex') + + const url = + INTERNET_INVITE_PAGE + `?i=${inviteIdString}&d=${this.#ownDeviceId}` + + try { + for (const [ + pendingURL, + { opts }, + ] of this.#pendingInvitesOverInternet.entries()) { + if (pendingURL !== url) continue + const decision = await this.invite(peerId, opts) + this.emit('internet-invite-redeemed', peerId, decision, url) + } + } catch (e) { + this.emit( + 'internet-invite-redeem-error', + ensureKnownError(e), + peerId, + url + ) + } + } + /** * Send an invite. Resolves when receiving a response. Rejects if the invite * is canceled, or if something else goes wrong. * * @param {string} deviceId * @param {InviteOptions} opts - * @returns {Promise<( - * typeof InviteResponse_Decision.ACCEPT | - * typeof InviteResponse_Decision.REJECT | - * typeof InviteResponse_Decision.ALREADY - * )>} + * @returns {Promise} */ async invite( deviceId, From f1f0767d52af59790d1ba2781ce018e16f14c986 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 31 Mar 2026 15:15:47 -0400 Subject: [PATCH 009/103] feat: e2e invite over the internet --- src/local-peers.js | 9 +++++- src/mapeo-manager.js | 28 ++++++++++++++++++ src/member-api.js | 51 +++++++++++++++++++++++++------- test-e2e/invite-over-internet.js | 24 +++++++++++++++ test/member-api.js | 14 ++++++++- 5 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 test-e2e/invite-over-internet.js diff --git a/src/local-peers.js b/src/local-peers.js index 1042a347e..6f40765b7 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -484,7 +484,7 @@ class Peer { * @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received * @property {(peerId: string, inviteResponse: InviteResponseAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received * @property {(peerId: string, inviteResponse: RedeemInviteOverInternet) => void} invite-over-internet-redeemed Emitted when a peer attempts to redeem an invite over the internet - * @property {(peerId: string, inviteResponse: RedeemInviteOverInternetAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received + * @property {(peerId: string, inviteResponse: RedeemInviteOverInternetAck) => void} invite-over-internet-redeemed-ack Emitted when an invite response acknowledgement is received * @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received * @property {(sender: PeerInfo, details: MapShareExtension) => void} map-share Emitted when a MapShare request is received @@ -905,6 +905,13 @@ export class LocalPeers extends TypedEmitter { this.emit('got-project-details-ack', peerId, ack) break } + case 'RedeemInviteOverInternetAck': { + const ack = RedeemInviteOverInternetAck.decode(value) + peer.receiveAck('RedeemInviteOverInternetAck', ack) + const peerId = keyToId(protomux.stream.remotePublicKey) + this.emit('invite-over-internet-redeemed-ack', peerId, ack) + break + } /* c8 ignore next 2 */ default: throw new ExhaustivenessError(type) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 1683bc17a..82dee9b64 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -63,6 +63,8 @@ import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' +import { parseInviteURL } from './member-api.js' +import { pEvent } from 'p-event' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ @@ -708,6 +710,31 @@ export class MapeoManager extends TypedEmitter { return result } + /** + * Attempt to join a project over the internet + * @param {string} url + * @returns {Promise} + */ + async joinProjectOverInternet(url) { + const { deviceId, inviteIdString } = parseInviteURL(url) + const inviteId = Buffer.from(inviteIdString, 'hex') + + const connection = await this.#remoteDiscovery.connectPeer(deviceId) + try { + const onInvited = pEvent(this.#invite, 'invite-received') + await this.#localPeers.sendRedeemInviteOverInternet(deviceId, { + inviteId, + }) + const invite = await onInvited + + const projectId = await this.#invite.accept(invite) + + return projectId + } finally { + connection.end() + } + } + /** * Add a project to this device. After adding a project the client should * await `project.$waitForInitialSync()` to ensure that the device has @@ -1127,6 +1154,7 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async close() { + await this.#remoteDiscovery.close() // This added for workers PR // await this.#projectSettingsIndexWriter.close() await Promise.all( diff --git a/src/member-api.js b/src/member-api.js index ede0ab25d..4c2f02d21 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -45,7 +45,7 @@ import { isRoleIdForNewInvite, } from './roles.js' -export const INTERNET_INVITE_PAGE = 'https://i/comapeo.app/invite/' +export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @@ -133,7 +133,7 @@ export class MemberApi extends TypedEmitter { /** @type {Map} */ #outboundInvitesByDevice = new Map() - /** @type {Map} */ + /** @type {Map} */ #pendingInvitesOverInternet = new Map() /** @@ -195,11 +195,11 @@ export class MemberApi extends TypedEmitter { const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') - const url = - INTERNET_INVITE_PAGE + `?i=${inviteIdString}&d=${this.#ownDeviceId}` + const url = makeInviteURL(inviteIdString, this.#ownDeviceId) - this.#pendingInvitesOverInternet.set(url, { + this.#pendingInvitesOverInternet.set(inviteIdString, { inviteId, + url, opts, }) @@ -220,10 +220,12 @@ export class MemberApi extends TypedEmitter { await this.#setShouldListenOverInternet(false) return } - if (!this.#pendingInvitesOverInternet.has(url)) { + const { inviteIdString } = parseInviteURL(url) + + if (!this.#pendingInvitesOverInternet.has(inviteIdString)) { throw new Error('Invalid internet invite URL') } - this.#pendingInvitesOverInternet.delete(url) + this.#pendingInvitesOverInternet.delete(inviteIdString) if (this.#pendingInvitesOverInternet.size === 0) { await this.#setShouldListenOverInternet(false) } @@ -247,13 +249,12 @@ export class MemberApi extends TypedEmitter { const url = INTERNET_INVITE_PAGE + `?i=${inviteIdString}&d=${this.#ownDeviceId}` - try { for (const [ - pendingURL, + pendingInviteId, { opts }, ] of this.#pendingInvitesOverInternet.entries()) { - if (pendingURL !== url) continue + if (pendingInviteId !== inviteIdString) continue const decision = await this.invite(peerId, opts) this.emit('internet-invite-redeemed', peerId, decision, url) } @@ -880,3 +881,33 @@ async function parseAddServerResponse(response) { `Failed to add server peer due to HTTP status code ${response.status}` ) } + +/** + * @param {string} url + * @returns {{inviteIdString: string, deviceId: string}} + */ +export function parseInviteURL(url) { + const { hash } = new URL(url) + + const params = new URLSearchParams(hash.slice(1)) + + const inviteIdString = params.get('i') + const deviceId = params.get('d') + + if (typeof inviteIdString !== 'string' || typeof deviceId !== 'string') { + throw new Error('Missing invite and device parameters from URL') + } + return { inviteIdString, deviceId } +} + +/** + * + * @param {string} inviteIdString + * @param {string} deviceId + * @returns {string} + */ +export function makeInviteURL(inviteIdString, deviceId) { + const url = INTERNET_INVITE_PAGE + `#i=${inviteIdString}&d=${deviceId}` + + return url +} diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js new file mode 100644 index 000000000..554150056 --- /dev/null +++ b/test-e2e/invite-over-internet.js @@ -0,0 +1,24 @@ +import test from 'node:test' +import { createManagers } from './utils.js' +import { MEMBER_ROLE_ID } from '../src/roles.js' +import assert from 'node:assert/strict' + +test('invite over internet and join from URL', async (t) => { + const managers = await createManagers(2, t) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + const gotProjectId = await invitee.joinProjectOverInternet(url) + + assert.equal(gotProjectId, projectId, 'joined expected project') +}) diff --git a/test/member-api.js b/test/member-api.js index 961baf1fa..994296f76 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -6,13 +6,25 @@ import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' -import { MemberApi } from '../src/member-api.js' +import { makeInviteURL, MemberApi, parseInviteURL } from '../src/member-api.js' import { LocalPeers } from '../src/local-peers.js' import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ +test('serialize and parse invite URLs', () => { + const testDeviceId = 'foo' + const testInviteId = 'bar' + + const url = makeInviteURL(testInviteId, testDeviceId) + + const { inviteIdString, deviceId } = parseInviteURL(url) + + assert.equal(inviteIdString, testInviteId) + assert.equal(deviceId, testDeviceId) +}) + test('List pending invites over internet', async () => { const { member } = setup({}) From 4675bddfc31e94f3b03bfd2fe1a3c0643267062b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 1 Apr 2026 17:58:10 -0400 Subject: [PATCH 010/103] feat: Use ephemeral keys for hyperswarm, give real key on handshake --- proto/handshake.proto | 6 +++ src/discovery/remote-discovery.js | 47 ++++++++++++++-- src/generated/handshake.d.ts | 22 ++++++++ src/generated/handshake.js | 54 +++++++++++++++++++ src/generated/handshake.ts | 74 ++++++++++++++++++++++++++ src/lib/noise-secret-stream-helpers.js | 9 ++-- src/local-peers.js | 43 ++++++++++----- src/mapeo-manager.js | 14 +++-- src/mapeo-project.js | 5 ++ src/member-api.js | 7 ++- test/discovery/remote-discovery.js | 2 + test/member-api.js | 1 + tsconfig.json | 3 ++ types/hyperswarm.d.ts | 12 +++++ 14 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 proto/handshake.proto create mode 100644 src/generated/handshake.d.ts create mode 100644 src/generated/handshake.js create mode 100644 src/generated/handshake.ts diff --git a/proto/handshake.proto b/proto/handshake.proto new file mode 100644 index 000000000..9da3fd9c7 --- /dev/null +++ b/proto/handshake.proto @@ -0,0 +1,6 @@ +syntax = "proto3"; + +message SwarmHandshake { + bytes publicKey = 1; + bytes signature = 2; +} \ No newline at end of file diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index fa6294f09..eb8c669d0 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -2,12 +2,17 @@ import { TypedEmitter } from 'tiny-typed-emitter' import { Logger } from '../logger.js' import Hyperswarm from 'hyperswarm' import { pEvent } from 'p-event' +import sodium from 'sodium-universal' +import b4a from 'b4a' +import { SwarmHandshake } from '../generated/handshake.js' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ +/** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer}} RemoteAuthedNoiseStream */ + /** * @typedef {Object} DiscoveryEvents - * @property {(connection: OpenedNoiseStream) => void} connection + * @property {(connection: RemoteAuthedNoiseStream) => void} connection */ /** @@ -20,23 +25,26 @@ export class RemoteDiscovery extends TypedEmitter { /** @type {Promise?} */ #loading = null #identityKeypair + #swarmIdentityKeypair /** * @param {Object} opts * @param {import('./local-discovery.js').Keypair} opts.identityKeypair + * @param {import('./local-discovery.js').Keypair} opts.swarmIdentityKeypair * @param {Logger} [opts.logger] */ - constructor({ identityKeypair, logger }) { + constructor({ identityKeypair, swarmIdentityKeypair, logger }) { super() this.#l = Logger.create('RemoteDiscovery', logger) this.#identityKeypair = identityKeypair + this.#swarmIdentityKeypair = swarmIdentityKeypair } async #initSwarm() { this.#l.log('Initializing swarm') const swarm = new Hyperswarm({ - keyPair: this.#identityKeypair, + keyPair: this.#swarmIdentityKeypair, maxPeers: 4, }) swarm.on('connection', this.#handleHyperswarmConnection.bind(this)) @@ -107,7 +115,38 @@ export class RemoteDiscovery extends TypedEmitter { * @param {OpenedNoiseStream} socket * @param {import('hyperswarm').PeerInfo} _peerInfo */ - #handleHyperswarmConnection(socket, _peerInfo) { + async #handleHyperswarmConnection(socket, _peerInfo) { + const firstData = pEvent(socket, 'data', { timeout: 30000 }) + const keyPair = this.#identityKeypair + // Sign the Noise handshake hash with our stable key + const sig = b4a.allocUnsafe(64) + sodium.crypto_sign_detached(sig, socket.handshakeHash, keyPair.secretKey) + + // Send stable public key + proof in a single message + socket.write( + SwarmHandshake.encode({ + publicKey: keyPair.publicKey, + signature: Buffer.from(sig), + }) + ) + + const data = await firstData + + const msg = SwarmHandshake.decode(data) + + const valid = sodium.crypto_sign_verify_detached( + msg.signature, + socket.handshakeHash, // same hash on both sides + msg.publicKey + ) + + if (!valid) { + throw new Error('Invalid identity proof') + } + + // @ts-ignore + socket.handshakePublicKey = msg.publicKey + // @ts-ignore this.emit('connection', socket) } } diff --git a/src/generated/handshake.d.ts b/src/generated/handshake.d.ts new file mode 100644 index 000000000..bdbe79679 --- /dev/null +++ b/src/generated/handshake.d.ts @@ -0,0 +1,22 @@ +import _m0 from "protobufjs/minimal.js"; +export interface SwarmHandshake { + publicKey: Buffer; + signature: Buffer; +} +export declare const SwarmHandshake: { + encode(message: SwarmHandshake, writer?: _m0.Writer): _m0.Writer; + decode(input: _m0.Reader | Uint8Array, length?: number): SwarmHandshake; + create, I>>(base?: I): SwarmHandshake; + fromPartial, I>>(object: I): SwarmHandshake; +}; +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; +type DeepPartial = T extends Builtin ? T : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends {} ? { + [K in keyof T]?: DeepPartial; +} : Partial; +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P : P & { + [K in keyof P]: Exact; +} & { + [K in Exclude>]: never; +}; +export {}; diff --git a/src/generated/handshake.js b/src/generated/handshake.js new file mode 100644 index 000000000..6a22c2747 --- /dev/null +++ b/src/generated/handshake.js @@ -0,0 +1,54 @@ +/* eslint-disable */ +import _m0 from "protobufjs/minimal.js"; +function createBaseSwarmHandshake() { + return { publicKey: Buffer.alloc(0), signature: Buffer.alloc(0) }; +} +export var SwarmHandshake = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.publicKey.length !== 0) { + writer.uint32(10).bytes(message.publicKey); + } + if (message.signature.length !== 0) { + writer.uint32(18).bytes(message.signature); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseSwarmHandshake(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.publicKey = reader.bytes(); + continue; + case 2: + if (tag !== 18) { + break; + } + message.signature = reader.bytes(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return SwarmHandshake.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a, _b; + var message = createBaseSwarmHandshake(); + message.publicKey = (_a = object.publicKey) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + message.signature = (_b = object.signature) !== null && _b !== void 0 ? _b : Buffer.alloc(0); + return message; + }, +}; diff --git a/src/generated/handshake.ts b/src/generated/handshake.ts new file mode 100644 index 000000000..9420511a3 --- /dev/null +++ b/src/generated/handshake.ts @@ -0,0 +1,74 @@ +/* eslint-disable */ +import _m0 from "protobufjs/minimal.js"; + +export interface SwarmHandshake { + publicKey: Buffer; + signature: Buffer; +} + +function createBaseSwarmHandshake(): SwarmHandshake { + return { publicKey: Buffer.alloc(0), signature: Buffer.alloc(0) }; +} + +export const SwarmHandshake = { + encode(message: SwarmHandshake, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.publicKey.length !== 0) { + writer.uint32(10).bytes(message.publicKey); + } + if (message.signature.length !== 0) { + writer.uint32(18).bytes(message.signature); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): SwarmHandshake { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSwarmHandshake(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.publicKey = reader.bytes() as Buffer; + continue; + case 2: + if (tag !== 18) { + break; + } + + message.signature = reader.bytes() as Buffer; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + create, I>>(base?: I): SwarmHandshake { + return SwarmHandshake.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SwarmHandshake { + const message = createBaseSwarmHandshake(); + message.publicKey = object.publicKey ?? Buffer.alloc(0); + message.signature = object.signature ?? Buffer.alloc(0); + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Array ? Array> : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; diff --git a/src/lib/noise-secret-stream-helpers.js b/src/lib/noise-secret-stream-helpers.js index 7c50c65ea..de2f5c1d8 100644 --- a/src/lib/noise-secret-stream-helpers.js +++ b/src/lib/noise-secret-stream-helpers.js @@ -1,6 +1,7 @@ /** @import { Duplex as NodeDuplex } from 'node:stream' */ /** @import { Duplex as StreamxDuplex } from 'streamx' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ +/** @import {RemoteAuthedNoiseStream} from "../discovery/remote-discovery.js" */ /** * @internal @@ -28,10 +29,12 @@ * stream is opened) * * @template {RawStream} T - * @param {NoiseSecretStream} stream - * @returns {Promise | DestroyedNoiseStream>} + * @param {NoiseSecretStream|RemoteAuthedNoiseStream} stream + * @returns {Promise | DestroyedNoiseStream | RemoteAuthedNoiseStream>} */ export async function openedNoiseSecretStream(stream) { await stream.opened - return /** @type {OpenedNoiseStream | DestroyedNoiseStream} */ (stream) + return /** @type {OpenedNoiseStream | DestroyedNoiseStream | RemoteAuthedNoiseStream} */ ( + stream + ) } diff --git a/src/local-peers.js b/src/local-peers.js index 6f40765b7..5dfa5f8be 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -34,6 +34,7 @@ import { /** @import NoiseStream from '@hyperswarm/secret-stream' */ /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */ /** @import {DeferredPromise} from 'p-defer' */ +/** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ /** * @typedef {InviteAck|InviteCancelAck|InviteResponseAck|ProjectJoinDetailsAck} AckResponse @@ -614,7 +615,7 @@ export class LocalPeers extends TypedEmitter { /** * Connect to a peer over an existing NoiseSecretStream * - * @param {NoiseStream} stream + * @param {NoiseStream|RemoteAuthedNoiseStream} stream * @returns {import('./types.js').ReplicationStream} */ connect(stream) { @@ -672,7 +673,7 @@ export class LocalPeers extends TypedEmitter { } /** - * @param {Protomux} protomux + * @param {Protomux} protomux * @param {() => void} done */ #makePeer(protomux, done) { @@ -682,7 +683,7 @@ export class LocalPeers extends TypedEmitter { // already open if (protomux.opened({ protocol: PROTOCOL_NAME })) return done() - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) // This is written like this because the protomux uses the index within // the messages array to define the message id over the wire, so this must @@ -754,7 +755,7 @@ export class LocalPeers extends TypedEmitter { // We could also index peers by protomux to avoid this, but that would mean // we need to keep around protomux references for closed peers, and we keep // around closed peers for the lifecycle of the app - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) // We could have more than one connection to the same peer const devicePeers = this.#peers.get(peerId) /** @type {Peer | undefined} */ @@ -810,7 +811,7 @@ export class LocalPeers extends TypedEmitter { switch (type) { case 'Invite': { const invite = parseInvite(value) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite', peerId, invite) peer.sendInviteAck(invite).catch((e) => { this.#l.log(`Error sending invite ack ${e.stack}`) @@ -825,7 +826,7 @@ export class LocalPeers extends TypedEmitter { } case 'InviteCancel': { const inviteCancel = parseInviteCancel(value) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-cancel', peerId, inviteCancel) peer.sendInviteCancelAck(inviteCancel).catch((e) => { this.#l.log(`Error sending invite cancel ack ${e.stack}`) @@ -839,7 +840,7 @@ export class LocalPeers extends TypedEmitter { } case 'InviteResponse': { const inviteResponse = parseInviteResponse(value) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-response', peerId, inviteResponse) peer.sendInviteResponseAck(inviteResponse).catch((e) => { this.#l.log(`Error sending invite response ack ${e.stack}`) @@ -848,7 +849,7 @@ export class LocalPeers extends TypedEmitter { } case 'RedeemInviteOverInternet': { const redeem = RedeemInviteOverInternet.decode(value) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-over-internet-redeemed', peerId, redeem) peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { @@ -858,7 +859,7 @@ export class LocalPeers extends TypedEmitter { } case 'ProjectJoinDetails': { const details = parseProjectJoinDetails(value) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('got-project-details', peerId, details) peer.sendProjectJoinDetailsAck(details).catch((e) => { this.#l.log(`Error sending project details ack ${e.stack}`) @@ -880,35 +881,35 @@ export class LocalPeers extends TypedEmitter { case 'InviteAck': { const ack = InviteAck.decode(value) peer.receiveAck('InviteAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-ack', peerId, ack) break } case 'InviteCancelAck': { const ack = InviteCancelAck.decode(value) peer.receiveAck('InviteCancelAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-cancel-ack', peerId, ack) break } case 'InviteResponseAck': { const ack = InviteResponseAck.decode(value) peer.receiveAck('InviteResponseAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-response-ack', peerId, ack) break } case 'ProjectJoinDetailsAck': { const ack = ProjectJoinDetailsAck.decode(value) peer.receiveAck('ProjectJoinDetailsAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('got-project-details-ack', peerId, ack) break } case 'RedeemInviteOverInternetAck': { const ack = RedeemInviteOverInternetAck.decode(value) peer.receiveAck('RedeemInviteOverInternetAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) + const peerId = peerIdFromNoise(protomux.stream) this.emit('invite-over-internet-redeemed-ack', peerId, ack) break } @@ -1074,3 +1075,17 @@ function chooseDevicePeer(devicePeers) { // @ts-ignore return pick } + +/** + * @param {OpenedNoiseStream|RemoteAuthedNoiseStream} stream + */ +export function peerIdFromNoise(stream) { + const publicKey = + 'handshakePublicKey' in stream + ? stream.handshakePublicKey + : stream.remotePublicKey + + const peerId = keyToId(publicKey) + + return peerId +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 82dee9b64..c4ab962ac 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -46,7 +46,7 @@ import BlobServerPlugin from './fastify-plugins/blobs.js' import IconServerPlugin from './fastify-plugins/icons.js' import { plugin as MapServerPlugin } from './fastify-plugins/maps.js' import { getFastifyServerAddress } from './fastify-plugins/utils.js' -import { LocalPeers } from './local-peers.js' +import { LocalPeers, peerIdFromNoise } from './local-peers.js' import { InviteApi } from './invite/invite-api.js' import { LocalDiscovery } from './discovery/local-discovery.js' import { Logger } from './logger.js' @@ -73,6 +73,7 @@ import { pEvent } from 'p-event' /** @import { CoreStorage, Namespace } from './types.js' */ /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */ /** @import { ProjectSettings, ProjectSettingsValue } from '@comapeo/schema' */ +/** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ /** @typedef {SetNonNullable} ValidatedProjectKeys */ /** @typedef {Pick & { projectName: string, projectColor?: string, projectDescription?: string, sendStats?: boolean, invitorWroteDeviceInfo? : boolean }} ProjectToAddDetails */ @@ -151,6 +152,7 @@ export class MapeoManager extends TypedEmitter { /** @type {string} */ #projectMigrationsFolder #deviceId + #swarmIdentityKeypair #localPeers #invite #fastify @@ -194,7 +196,8 @@ export class MapeoManager extends TypedEmitter { super() this.#keyManager = new KeyManager(rootKey) this.#deviceId = getDeviceId(this.#keyManager) - this.#defaultConfigPath = defaultConfigPath + ;(this.#swarmIdentityKeypair = KeyManager.generateProjectKeypair()), + (this.#defaultConfigPath = defaultConfigPath) this.#defaultIsArchiveDevice = defaultIsArchiveDevice this.#makeWebsocket = makeWebsocket const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId })) @@ -291,6 +294,8 @@ export class MapeoManager extends TypedEmitter { this.#localDiscovery.on('connection', this.#replicate.bind(this)) this.#remoteDiscovery = new RemoteDiscovery({ identityKeypair: this.#keyManager.getIdentityKeypair(), + // ephemeral swarm identity each run + swarmIdentityKeypair: this.#swarmIdentityKeypair, logger, }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) @@ -334,7 +339,7 @@ export class MapeoManager extends TypedEmitter { } /** - * @param {NoiseSecretStream} noiseStream + * @param {NoiseSecretStream|RemoteAuthedNoiseStream} noiseStream */ #replicate(noiseStream) { const replicationStream = this.#localPeers.connect(noiseStream) @@ -351,7 +356,7 @@ export class MapeoManager extends TypedEmitter { features: [DeviceInfo_RPCFeatures.ack], } - const peerId = keyToId(openedNoiseStream.remotePublicKey) + const peerId = peerIdFromNoise(openedNoiseStream) return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend) }) @@ -615,6 +620,7 @@ export class MapeoManager extends TypedEmitter { ...projectKeys, projectMigrationsFolder: this.#projectMigrationsFolder, keyManager: this.#keyManager, + swarmPublicKey: this.#swarmIdentityKeypair.publicKey, sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 356d0ef50..96f2460d0 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -136,6 +136,7 @@ const VARIANT_EXPORT_ORDER = ['original', 'preview', 'thumbnail'] export class MapeoProject extends ReadyResource { #projectKey #deviceId + #swarmPublicKey #identityKeypair #coreManager #indexWriter @@ -164,6 +165,7 @@ export class MapeoProject extends ReadyResource { * @param {string} opts.projectMigrationsFolder path for drizzle migration folder for project * @param {import('@mapeo/crypto').KeyManager} opts.keyManager mapeo/crypto KeyManager instance * @param {Buffer} opts.projectKey 32-byte public key of the project creator core + * @param {Buffer} opts.swarmPublicKey 32-byte public key used to identify oneself on the hyperswarm DHT * @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys Encryption keys for each namespace * @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb @@ -188,6 +190,7 @@ export class MapeoProject extends ReadyResource { projectKey, projectSecretKey, encryptionKeys, + swarmPublicKey, getMediaBaseUrl, makeWebsocket = (url) => new WebSocket(url), localPeers, @@ -200,6 +203,7 @@ export class MapeoProject extends ReadyResource { this.#l = Logger.create('project', logger) this.#deviceId = getDeviceId(keyManager) + this.#swarmPublicKey = swarmPublicKey this.#projectKey = projectKey this.#importingCategories = false this.#getFallbackProjectInfo = getFallbackProjectInfo @@ -414,6 +418,7 @@ export class MapeoProject extends ReadyResource { this.#memberApi = new MemberApi({ deviceId: this.#deviceId, + swarmPublicKey: this.#swarmPublicKey, roles: this.#roles, encryptionKeys, projectKey, diff --git a/src/member-api.js b/src/member-api.js index 4c2f02d21..a3836233d 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -117,6 +117,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] */ export class MemberApi extends TypedEmitter { #ownDeviceId + #swarmPublicKey #roles #encryptionKeys #projectKey @@ -139,6 +140,7 @@ export class MemberApi extends TypedEmitter { /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string + * @param {Buffer} opts.swarmPublicKey public key of this device on the hyperswarm network * @param {Pick} opts.roles * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey @@ -154,6 +156,7 @@ export class MemberApi extends TypedEmitter { */ constructor({ deviceId, + swarmPublicKey, roles, encryptionKeys, projectKey, @@ -170,6 +173,7 @@ export class MemberApi extends TypedEmitter { super() this.#l = Logger.create('member-api', logger) this.#ownDeviceId = deviceId + this.#swarmPublicKey = swarmPublicKey this.#roles = roles this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey @@ -194,8 +198,9 @@ export class MemberApi extends TypedEmitter { async inviteOverInternet(opts) { const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') + const deviceId = this.#swarmPublicKey.toString('hex') - const url = makeInviteURL(inviteIdString, this.#ownDeviceId) + const url = makeInviteURL(inviteIdString, deviceId) this.#pendingInvitesOverInternet.set(inviteIdString, { inviteId, diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index c8f2ace59..3e44ad39e 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -14,9 +14,11 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, + swarmIdentityKeypair: identityKeypair1, }) const remoteDiscovery2 = new RemoteDiscovery({ identityKeypair: identityKeypair2, + swarmIdentityKeypair: identityKeypair2, }) t.after(() => diff --git a/test/member-api.js b/test/member-api.js index 994296f76..4d36630a7 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -179,6 +179,7 @@ function setup({ const member = new MemberApi({ deviceId, + swarmPublicKey: identityKeypair.publicKey, rpc, roles, encryptionKeys, diff --git a/tsconfig.json b/tsconfig.json index 519c4a1dd..de3cbe76c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,9 @@ "@hyperswarm/secret-stream": [ "./node_modules/@digidem/types/vendor/@hyperswarm/secret-stream/index.d.ts" ], + "sodium-universal": [ + "./node_modules/@mapeo/crypto/types/sodium-universal.d.ts" + ], "*": ["./types", "./node_modules/@digidem/types/vendor/*/index.d.ts"] } }, diff --git a/types/hyperswarm.d.ts b/types/hyperswarm.d.ts index d815bf2f6..38857fd90 100644 --- a/types/hyperswarm.d.ts +++ b/types/hyperswarm.d.ts @@ -17,6 +17,7 @@ declare module 'hyperswarm' { } export default class Hyperswarm extends TypedEmitter { constructor({ keyPair: Keypair, maxPeers: number }) + get peers(): Map listen(): Promise flush(): Promise suspend(): Promise @@ -24,5 +25,16 @@ declare module 'hyperswarm' { destroy(): Promise joinPeer(noisePublicKey: Buffer): void leavePeer(noisePublicKey: Buffer): void + join( + topic: Buffer, + opts?: { limit?: number; client?: boolean; server?: boolean } + ): PeerDiscovery + leave(topic: Buffer): void + } + + export class PeerDiscovery { + flushed(): Promise + refresh({ client: boolean, server: boolean }): Promise + destroy(): Promise } } From 394facd52c0ff036a03af818b1649809010b1ca5 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 1 Apr 2026 18:03:47 -0400 Subject: [PATCH 011/103] fix: MemberAPI.pendingInternetInvites should return URLs not invite IDs --- src/member-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index a3836233d..e0ee31312 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -241,7 +241,7 @@ export class MemberApi extends TypedEmitter { * @returns {string[]} */ pendingInternetInvites() { - return [...this.#pendingInvitesOverInternet.keys()] + return [...this.#pendingInvitesOverInternet.values()].map(({ url }) => url) } /** From 01215f26b7d5e3b02dabc81aa974070c203727e6 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Apr 2026 10:54:38 -0400 Subject: [PATCH 012/103] fix: Adjust remote discovery tests to use swarm key for connecting --- src/discovery/remote-discovery.js | 2 +- test/discovery/remote-discovery.js | 42 ++++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index eb8c669d0..6b465a4c0 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -127,7 +127,7 @@ export class RemoteDiscovery extends TypedEmitter { SwarmHandshake.encode({ publicKey: keyPair.publicKey, signature: Buffer.from(sig), - }) + }).finish() ) const data = await firstData diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 3e44ad39e..dcd5964d2 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -11,14 +11,16 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const identityKeypair2 = new KeyManager( Buffer.alloc(16, 2) ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 3)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, - swarmIdentityKeypair: identityKeypair1, + swarmIdentityKeypair: swarmKeypair1, }) const remoteDiscovery2 = new RemoteDiscovery({ identityKeypair: identityKeypair2, - swarmIdentityKeypair: identityKeypair2, + swarmIdentityKeypair: swarmKeypair2, }) t.after(() => @@ -29,33 +31,33 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => await Promise.all([remoteDiscovery1.start(), remoteDiscovery2.start()]) const deferred = pDefer() - const publicKey1Hex = identityKeypair1.publicKey.toString('hex') + const swarmPublicKey1Hex = swarmKeypair1.publicKey.toString('hex') // Listen for connection on instance 1 remoteDiscovery1.on('connection', (stream) => { stream.on('error', handleConnectionError) // Verify the remote peer's public key matches instance 2 assert.ok( - stream.remotePublicKey.equals(identityKeypair2.publicKey), + stream.remotePublicKey.equals(swarmKeypair2.publicKey), 'remote public key should match instance 2' ) deferred.resolve(stream) }) // Connect from instance 2 to instance 1 - const connectionPromise = remoteDiscovery2.connectPeer(publicKey1Hex) + const connectionPromise = remoteDiscovery2.connectPeer(swarmPublicKey1Hex) const outboundStream = await connectionPromise const inboundStream = await deferred.promise // Verify both sides have the correct keypairs assert.ok( - inboundStream.remotePublicKey.equals(identityKeypair2.publicKey), - 'instance 1 should have instance 2 public key' + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'instance 1 should have instance 2 swarm public key' ) assert.ok( - outboundStream.remotePublicKey.equals(identityKeypair1.publicKey), - 'instance 2 should have instance 1 public key' + outboundStream.remotePublicKey.equals(swarmKeypair1.publicKey), + 'instance 2 should have instance 1 swarm public key' ) // Verify the public IDs match @@ -63,16 +65,34 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const peerId2 = keyToPublicId(identityKeypair2.publicKey) assert.equal( - keyToPublicId(inboundStream.remotePublicKey), + keyToPublicId(inboundStream.handshakePublicKey), peerId2, 'instance 1 connected to correct peer' ) assert.equal( - keyToPublicId(outboundStream.remotePublicKey), + keyToPublicId(outboundStream.handshakePublicKey), peerId1, 'instance 2 connected to correct peer' ) + // Verify remotePublicKey and handshakePublicKey are as expected + assert.ok( + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'inbound remotePublicKey should match swarmKeypair2' + ) + assert.ok( + inboundStream.handshakePublicKey.equals(identityKeypair2.publicKey), + 'inbound handshakePublicKey should match identityKeypair2' + ) + assert.ok( + outboundStream.remotePublicKey.equals(swarmKeypair1.publicKey), + 'outbound remotePublicKey should match swarmKeypair1' + ) + assert.ok( + outboundStream.handshakePublicKey.equals(identityKeypair1.publicKey), + 'outbound handshakePublicKey should match identityKeypair1' + ) + inboundStream.end() outboundStream.end() }) From ad82754f5984967c1316ee1a4c56d23714e078e8 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Apr 2026 12:34:50 -0400 Subject: [PATCH 013/103] fix: Set own device ID for server tests since it is needed for project.$member.getMany --- test-e2e/server.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test-e2e/server.js b/test-e2e/server.js index 159c4a307..f2baee5a5 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -47,6 +47,7 @@ const comapeoCoreUrl = new URL('..', import.meta.url) test('invalid base URLs', async (t) => { const manager = createManager('device0', t) + await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) const projectId = await manager.createProject() const project = await manager.getProject(projectId) @@ -273,6 +274,7 @@ test("fails if first request succeeds but sync doesn't", async (t) => { test('adding a server peer', async (t) => { const manager = createManager('device0', t) + await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) @@ -321,6 +323,7 @@ test('adding a server peer and getting stream errors in connectServers', async ( const manager = createManager('device0', t, { makeWebsocket, }) + await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) From 54259c4da975813018820e74e21ed56d17e9bc11 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Apr 2026 14:39:20 -0400 Subject: [PATCH 014/103] test: Check that remote connections can send data after connecting --- test/discovery/remote-discovery.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index dcd5964d2..e59077e3e 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { KeyManager, keyToPublicId } from '@mapeo/crypto' import pDefer from 'p-defer' +import { pEvent } from 'p-event' import { RemoteDiscovery } from '../../src/discovery/remote-discovery.js' test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { @@ -93,6 +94,32 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => 'outbound handshakePublicKey should match identityKeypair1' ) + // Set up data listeners before writing + const dataFromOutbound = Buffer.from('Hello from outbound!') + const dataFromInbound = Buffer.from('Hello from inbound!') + + const inboundDataPromise = pEvent(inboundStream, 'data') + const outboundDataPromise = pEvent(outboundStream, 'data') + + // Send data from both sides + outboundStream.write(dataFromOutbound) + inboundStream.write(dataFromInbound) + + // Wait for data to be received + const [inboundData, outboundData] = await Promise.all([ + inboundDataPromise, + outboundDataPromise, + ]) + + assert.ok( + inboundData.equals(dataFromOutbound), + 'inbound should receive data from outbound' + ) + assert.ok( + outboundData.equals(dataFromInbound), + 'outbound should receive data from inbound' + ) + inboundStream.end() outboundStream.end() }) From 50eaffb25fe7abf0b4a845583f59318aab760528 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Apr 2026 16:12:11 -0400 Subject: [PATCH 015/103] fix: Use swarm public key to join project over internet --- src/mapeo-manager.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index c4ab962ac..de049f40e 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -363,9 +363,9 @@ export class MapeoManager extends TypedEmitter { .catch((e) => { // Ignore error but log this.#l.log( - 'Failed to send device info to peer %h', + 'Failed to send device info to peer %h, error: %s', noiseStream.remotePublicKey, - e + e.message ) }) @@ -722,15 +722,22 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async joinProjectOverInternet(url) { - const { deviceId, inviteIdString } = parseInviteURL(url) + const { deviceId: swarmPublicKeyHex, inviteIdString } = parseInviteURL(url) const inviteId = Buffer.from(inviteIdString, 'hex') - const connection = await this.#remoteDiscovery.connectPeer(deviceId) + const connection = await this.#remoteDiscovery.connectPeer( + swarmPublicKeyHex + ) try { const onInvited = pEvent(this.#invite, 'invite-received') - await this.#localPeers.sendRedeemInviteOverInternet(deviceId, { - inviteId, - }) + // Use the identity key from the handshake, not the swarm key from the URL + const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') + await this.#localPeers.sendRedeemInviteOverInternet( + identityPublicKeyHex, + { + inviteId, + } + ) const invite = await onInvited const projectId = await this.#invite.accept(invite) From 98b335033372b7979259a9d7cc837b240e5bb54b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Apr 2026 16:12:36 -0400 Subject: [PATCH 016/103] chore: Clean handle redeem in member api --- src/member-api.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index e0ee31312..28b1105bb 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -251,13 +251,12 @@ export class MemberApi extends TypedEmitter { */ async #handleRedeemInviteOverInternet(peerId, { inviteId }) { const inviteIdString = inviteId.toString('hex') + this.#l.log('Got incoming invite redeem', inviteIdString.slice(0, 7)) - const url = - INTERNET_INVITE_PAGE + `?i=${inviteIdString}&d=${this.#ownDeviceId}` try { for (const [ pendingInviteId, - { opts }, + { opts, url }, ] of this.#pendingInvitesOverInternet.entries()) { if (pendingInviteId !== inviteIdString) continue const decision = await this.invite(peerId, opts) @@ -268,7 +267,7 @@ export class MemberApi extends TypedEmitter { 'internet-invite-redeem-error', ensureKnownError(e), peerId, - url + inviteIdString ) } } From 20203b010ab9b53a72f57aafb093053dc0005ed2 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 8 Apr 2026 16:01:51 -0400 Subject: [PATCH 017/103] fix: Pause swarm stream after doing initial handshake to avoid packet loss --- src/discovery/remote-discovery.js | 40 +++++++++++++++++++++++++------ src/mapeo-manager.js | 2 ++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 6b465a4c0..1864bb6d5 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -7,6 +7,7 @@ import b4a from 'b4a' import { SwarmHandshake } from '../generated/handshake.js' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ +/** @import {Duplex, Readable} from "streamx" */ /** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer}} RemoteAuthedNoiseStream */ @@ -116,19 +117,21 @@ export class RemoteDiscovery extends TypedEmitter { * @param {import('hyperswarm').PeerInfo} _peerInfo */ async #handleHyperswarmConnection(socket, _peerInfo) { - const firstData = pEvent(socket, 'data', { timeout: 30000 }) + const firstData = readChunk(socket) const keyPair = this.#identityKeypair // Sign the Noise handshake hash with our stable key const sig = b4a.allocUnsafe(64) sodium.crypto_sign_detached(sig, socket.handshakeHash, keyPair.secretKey) // Send stable public key + proof in a single message - socket.write( - SwarmHandshake.encode({ - publicKey: keyPair.publicKey, - signature: Buffer.from(sig), - }).finish() - ) + const handshakeBuffer = SwarmHandshake.encode({ + publicKey: keyPair.publicKey, + signature: Buffer.from(sig), + }).finish() + + const hasDrained = socket.write(Buffer.from(handshakeBuffer)) + + if (!hasDrained) await pEvent(socket, 'drain', { timeout: 10000 }) const data = await firstData @@ -150,3 +153,26 @@ export class RemoteDiscovery extends TypedEmitter { this.emit('connection', socket) } } + +/** + * + * @param {Readable|Duplex} stream + * @returns {Promise} + */ +async function readChunk(stream) { + let data = stream.read() + + if (data) return data + + console.log('waiting for readable') + + await pEvent(stream, 'readable') + + stream.pause() + + data = stream.read() + + if (data) return data + + throw new Error('Unable to read chunk') +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index de049f40e..519413b76 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -344,6 +344,8 @@ export class MapeoManager extends TypedEmitter { #replicate(noiseStream) { const replicationStream = this.#localPeers.connect(noiseStream) + noiseStream.resume() + openedNoiseSecretStream(noiseStream) .then((openedNoiseStream) => { if (openedNoiseStream.destroyed) return From 2b227d647dec3edcbe04ce58b1af7e92bf5c305f Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 8 Apr 2026 17:29:55 -0400 Subject: [PATCH 018/103] feat: Do not trust RPC until manual approval for swarm peers --- src/discovery/remote-discovery.js | 14 ++++-- src/local-peers.js | 72 +++++++++++++++++++------------ src/mapeo-manager.js | 12 +++++- src/mapeo-project.js | 3 ++ src/member-api.js | 67 +++++++++++++++++++++++++--- test-e2e/invite-over-internet.js | 24 ++++++++++- 6 files changed, 155 insertions(+), 37 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 1864bb6d5..79db71776 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -9,7 +9,7 @@ import { SwarmHandshake } from '../generated/handshake.js' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ /** @import {Duplex, Readable} from "streamx" */ -/** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer}} RemoteAuthedNoiseStream */ +/** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer, isTrusted: boolean}} RemoteAuthedNoiseStream */ /** * @typedef {Object} DiscoveryEvents @@ -27,6 +27,8 @@ export class RemoteDiscovery extends TypedEmitter { #loading = null #identityKeypair #swarmIdentityKeypair + /** @type {Set} */ + #shouldTrustKeys = new Set() /** * @param {Object} opts @@ -99,6 +101,8 @@ export class RemoteDiscovery extends TypedEmitter { const swarm = await this.#ensureSwarm() const noisePublicKey = Buffer.from(publicKey, 'hex') + this.#shouldTrustKeys.add(publicKey) + const onConnected = pEvent(this, 'connection', { filter: (connection) => connection.remotePublicKey.equals(noisePublicKey), timeout, @@ -109,6 +113,8 @@ export class RemoteDiscovery extends TypedEmitter { const socket = await onConnected + this.#shouldTrustKeys.delete(publicKey) + return socket } @@ -117,6 +123,10 @@ export class RemoteDiscovery extends TypedEmitter { * @param {import('hyperswarm').PeerInfo} _peerInfo */ async #handleHyperswarmConnection(socket, _peerInfo) { + const remotePublicKeyString = socket.remotePublicKey.toString('hex') + // @ts-ignore + socket.isTrusted = this.#shouldTrustKeys.has(remotePublicKeyString) + const firstData = readChunk(socket) const keyPair = this.#identityKeypair // Sign the Noise handshake hash with our stable key @@ -164,8 +174,6 @@ async function readChunk(stream) { if (data) return data - console.log('waiting for readable') - await pEvent(stream, 'readable') stream.pause() diff --git a/src/local-peers.js b/src/local-peers.js index 5dfa5f8be..4defbaa4a 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -99,6 +99,7 @@ class Peer { /** @type {PeerState} */ #state = 'connecting' #deviceId + #isTrusted #channel #connected /** @type {string | undefined} */ @@ -120,11 +121,13 @@ class Peer { * @param {object} options * @param {string} options.peerId * @param {ReturnType} options.channel + * @param {boolean} options.isTrusted * @param {Protomux} options.protomux * @param {Logger} [options.logger] */ - constructor({ peerId, channel, protomux, logger }) { + constructor({ peerId, channel, protomux, isTrusted, logger }) { this.#deviceId = peerId + this.#isTrusted = isTrusted this.#channel = channel this.#protomux = protomux this.#connected = pDefer() @@ -183,6 +186,18 @@ class Peer { return this.#protomux } + get id() { + return this.#deviceId + } + + get isTrusted() { + return this.#isTrusted + } + + set isTrusted(isTrusted) { + this.#isTrusted = isTrusted + } + connect() { /* c8 ignore next 4 */ if (this.#state !== 'connecting') { @@ -612,13 +627,23 @@ export class LocalPeers extends TypedEmitter { peer[kTestOnlySendRawInvite](buf) } + /** + * Mark a peer as trusted, allowing it to use all RPC methods + * @param {string} peerId + */ + async trustPeer(peerId) { + const peer = await this.#getPeerByDeviceId(peerId) + peer.isTrusted = true + } + /** * Connect to a peer over an existing NoiseSecretStream * * @param {NoiseStream|RemoteAuthedNoiseStream} stream + * @param {boolean} [isTrusted] * @returns {import('./types.js').ReplicationStream} */ - connect(stream) { + connect(stream, isTrusted = true) { const noiseStream = stream.noiseStream const outerStream = noiseStream.rawStream const protomux = @@ -649,7 +674,7 @@ export class LocalPeers extends TypedEmitter { this.#opening.delete(deferredOpen.promise) } - const makePeer = this.#makePeer.bind(this, protomux, done) + const makePeer = this.#makePeer.bind(this, protomux, isTrusted, done) this.#attached.add(protomux) // This happens when the connected peer opens the channel @@ -674,9 +699,10 @@ export class LocalPeers extends TypedEmitter { /** * @param {Protomux} protomux + * @param {boolean} isTrusted * @param {() => void} done */ - #makePeer(protomux, done) { + #makePeer(protomux, isTrusted, done) { // #makePeer is called when the noise stream is opened, but it is also // called when the connected peer tries to open the channel. We only want // one channel, so we ignore attempts to create a peer if the channel is @@ -738,6 +764,7 @@ export class LocalPeers extends TypedEmitter { const existingDevicePeers = this.#peers.get(peerId) || new Set() const peer = new Peer({ + isTrusted, peerId, protomux, channel, @@ -810,38 +837,36 @@ export class LocalPeers extends TypedEmitter { if (!peer) return // TODO: report error - this should not happen switch (type) { case 'Invite': { + if (!peer.isTrusted) return const invite = parseInvite(value) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite', peerId, invite) + this.emit('invite', peer.id, invite) peer.sendInviteAck(invite).catch((e) => { this.#l.log(`Error sending invite ack ${e.stack}`) }) this.#l.log( 'Invite %h from %S for %h', invite.inviteId, - peerId, + peer.id, invite.projectInviteId ) break } case 'InviteCancel': { const inviteCancel = parseInviteCancel(value) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-cancel', peerId, inviteCancel) + this.emit('invite-cancel', peer.id, inviteCancel) peer.sendInviteCancelAck(inviteCancel).catch((e) => { this.#l.log(`Error sending invite cancel ack ${e.stack}`) }) this.#l.log( 'Invite cancel from %S for %h', - peerId, + peer.id, inviteCancel.inviteId ) break } case 'InviteResponse': { const inviteResponse = parseInviteResponse(value) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-response', peerId, inviteResponse) + this.emit('invite-response', peer.id, inviteResponse) peer.sendInviteResponseAck(inviteResponse).catch((e) => { this.#l.log(`Error sending invite response ack ${e.stack}`) }) @@ -849,9 +874,7 @@ export class LocalPeers extends TypedEmitter { } case 'RedeemInviteOverInternet': { const redeem = RedeemInviteOverInternet.decode(value) - const peerId = peerIdFromNoise(protomux.stream) - - this.emit('invite-over-internet-redeemed', peerId, redeem) + this.emit('invite-over-internet-redeemed', peer.id, redeem) peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { this.#l.log(`Error sending redeem over internet ack ${e.stack}`) }) @@ -859,8 +882,7 @@ export class LocalPeers extends TypedEmitter { } case 'ProjectJoinDetails': { const details = parseProjectJoinDetails(value) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('got-project-details', peerId, details) + this.emit('got-project-details', peer.id, details) peer.sendProjectJoinDetailsAck(details).catch((e) => { this.#l.log(`Error sending project details ack ${e.stack}`) }) @@ -873,6 +895,7 @@ export class LocalPeers extends TypedEmitter { break } case 'MapShareExtension': { + if (!peer.isTrusted) return const mapShare = MapShareExtension.decode(value) const info = /** @type {PeerInfo} */ (peer.info) this.emit('map-share', info, mapShare) @@ -881,36 +904,31 @@ export class LocalPeers extends TypedEmitter { case 'InviteAck': { const ack = InviteAck.decode(value) peer.receiveAck('InviteAck', ack) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-ack', peerId, ack) + this.emit('invite-ack', peer.id, ack) break } case 'InviteCancelAck': { const ack = InviteCancelAck.decode(value) peer.receiveAck('InviteCancelAck', ack) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-cancel-ack', peerId, ack) + this.emit('invite-cancel-ack', peer.id, ack) break } case 'InviteResponseAck': { const ack = InviteResponseAck.decode(value) peer.receiveAck('InviteResponseAck', ack) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-response-ack', peerId, ack) + this.emit('invite-response-ack', peer.id, ack) break } case 'ProjectJoinDetailsAck': { const ack = ProjectJoinDetailsAck.decode(value) peer.receiveAck('ProjectJoinDetailsAck', ack) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('got-project-details-ack', peerId, ack) + this.emit('got-project-details-ack', peer.id, ack) break } case 'RedeemInviteOverInternetAck': { const ack = RedeemInviteOverInternetAck.decode(value) peer.receiveAck('RedeemInviteOverInternetAck', ack) - const peerId = peerIdFromNoise(protomux.stream) - this.emit('invite-over-internet-redeemed-ack', peerId, ack) + this.emit('invite-over-internet-redeemed-ack', peer.id, ack) break } /* c8 ignore next 2 */ diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 519413b76..4d716296d 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -342,7 +342,8 @@ export class MapeoManager extends TypedEmitter { * @param {NoiseSecretStream|RemoteAuthedNoiseStream} noiseStream */ #replicate(noiseStream) { - const replicationStream = this.#localPeers.connect(noiseStream) + const isTrusted = `isTrusted` in noiseStream ? noiseStream.isTrusted : true + const replicationStream = this.#localPeers.connect(noiseStream, isTrusted) noiseStream.resume() @@ -641,6 +642,15 @@ export class MapeoManager extends TypedEmitter { if (shouldListen) return this.#remoteDiscovery.start() else return this.#remoteDiscovery.stop() }, + markInternetPeerAsTrusted: async (deviceId) => { + try { + await this.#localPeers.trustPeer(deviceId) + return true + } catch (e) { + // TODO: check error types + return false + } + }, }) return project diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 96f2460d0..988ba7798 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -177,6 +177,7 @@ export class MapeoProject extends ReadyResource { * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet + * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted * @param {Logger} [opts.logger] * */ @@ -198,6 +199,7 @@ export class MapeoProject extends ReadyResource { isArchiveDevice, getFallbackProjectInfo, setShouldListenOverInternet, + markInternetPeerAsTrusted, }) { super() @@ -446,6 +448,7 @@ export class MapeoProject extends ReadyResource { ) }, setShouldListenOverInternet, + markInternetPeerAsTrusted, logger: this.#l, }) diff --git a/src/member-api.js b/src/member-api.js index 28b1105bb..92ce261f3 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -108,7 +108,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** * @typedef {object} MemberEvents - * @property {(deviceId: string, decision: InviteDecision, url: string) => void} internet-invite-redeemed Emitted when an invite over the internet has been redeemed + * @property {(deviceId: string, inviteId: string) => void} internet-invite-redeemed Emitted when an invite over the internet has been redeemed, accept the deviceId to add them * @property {(err: Error, deviceId: string, url: string) => void} internet-invite-redeem-error Emitted when an invite over the internet has failed to be redeemed */ @@ -126,6 +126,7 @@ export class MemberApi extends TypedEmitter { #getReplicationStream #waitForInitialSyncWithPeer #setShouldListenOverInternet + #markInternetPeerAsTrusted #getProjectSettings #getDeviceInfo #setDeviceInfo @@ -134,7 +135,7 @@ export class MemberApi extends TypedEmitter { /** @type {Map} */ #outboundInvitesByDevice = new Map() - /** @type {Map} */ + /** @type {Map} */ #pendingInvitesOverInternet = new Map() /** @@ -149,6 +150,7 @@ export class MemberApi extends TypedEmitter { * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet + * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted * @param {() => Promise} opts.getProjectSettings * @param {(deviceId: string) => Promise} opts.getDeviceInfo * @param {(deviceId: string, deviceInfo: NewDeviceInfo) => Promise} opts.setDeviceInfo @@ -165,6 +167,7 @@ export class MemberApi extends TypedEmitter { getReplicationStream, waitForInitialSyncWithPeer, setShouldListenOverInternet, + markInternetPeerAsTrusted, getProjectSettings, getDeviceInfo, setDeviceInfo, @@ -182,6 +185,7 @@ export class MemberApi extends TypedEmitter { this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer this.#setShouldListenOverInternet = setShouldListenOverInternet + this.#markInternetPeerAsTrusted = markInternetPeerAsTrusted this.#getProjectSettings = getProjectSettings this.#getDeviceInfo = getDeviceInfo this.#setDeviceInfo = setDeviceInfo @@ -256,11 +260,24 @@ export class MemberApi extends TypedEmitter { try { for (const [ pendingInviteId, - { opts, url }, + pendingInvite, ] of this.#pendingInvitesOverInternet.entries()) { if (pendingInviteId !== inviteIdString) continue - const decision = await this.invite(peerId, opts) - this.emit('internet-invite-redeemed', peerId, decision, url) + + if (pendingInvite.inviteeDeviceId) { + this.emit( + 'internet-invite-redeem-error', + new Error('Invite already redeemed'), + peerId, + inviteIdString + ) + return + } + + pendingInvite.inviteeDeviceId = peerId + + this.emit('internet-invite-redeemed', peerId, inviteIdString) + return } } catch (e) { this.emit( @@ -270,6 +287,46 @@ export class MemberApi extends TypedEmitter { inviteIdString ) } + + // TODO: Should we break their connection? + this.emit( + 'internet-invite-redeem-error', + new Error('Unknown invite ID redeem attempt'), + peerId, + inviteIdString + ) + } + + /** + * Accept a device's attempt at redeeming an invite + * @param {string} inviteId + * @returns {Promise} + */ + async acceptRedeemedInvite(inviteId) { + for (const [ + pendingInviteId, + { opts, inviteeDeviceId }, + ] of this.#pendingInvitesOverInternet.entries()) { + if (pendingInviteId !== inviteId) continue + if (!inviteeDeviceId) { + throw new Error('Cannot yet accept: Invite not yet redeemed') + } + + const stillConnected = await this.#markInternetPeerAsTrusted( + inviteeDeviceId + ) + + // If they aren't connected after redeeming, we should mark it as cancelled + if (!stillConnected) { + await this.cancelInviteOverInternet(pendingInviteId) + throw new Error('Peer disconnected since redeeming invite') + } + const decision = await this.invite(inviteeDeviceId, opts) + + return decision + } + + throw new Error('Unknown invite ID') } /** diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 554150056..086f7be79 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -2,6 +2,8 @@ import test from 'node:test' import { createManagers } from './utils.js' import { MEMBER_ROLE_ID } from '../src/roles.js' import assert from 'node:assert/strict' +import { pEvent } from 'p-event' +import { InviteResponse_Decision } from '../src/generated/rpc.js' test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t) @@ -18,7 +20,27 @@ test('invite over internet and join from URL', async (t) => { roleId: MEMBER_ROLE_ID, }) - const gotProjectId = await invitee.joinProjectOverInternet(url) + const onInviteRedeemAttempt = pEvent( + project.$member, + 'internet-invite-redeemed', + { multiArgs: true, timeout: 5000 } + ) + + const onInvited = invitee.joinProjectOverInternet(url) + + const [deviceId, inviteId] = await onInviteRedeemAttempt + + assert.equal(deviceId, invitee.deviceId) + + // Show the user the device ID and their name and have them verify the invitee sees the same device ID + // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji + const reason = await project.$member.acceptRedeemedInvite(inviteId) + + assert.equal(reason, InviteResponse_Decision.ACCEPT) + + const gotProjectId = await onInvited assert.equal(gotProjectId, projectId, 'joined expected project') + + // TODO: Test that initial sync happened }) From 968a73757e5bd2d633872d111dd071aa785f4332 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 9 Apr 2026 17:59:09 -0400 Subject: [PATCH 019/103] chore: Add error classes for invite over internet code --- src/discovery/remote-discovery.js | 8 +++-- src/errors.js | 54 +++++++++++++++++++++++++++++++ src/member-api.js | 21 ++++++++---- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 79db71776..1965a0c90 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -5,6 +5,10 @@ import { pEvent } from 'p-event' import sodium from 'sodium-universal' import b4a from 'b4a' import { SwarmHandshake } from '../generated/handshake.js' +import { + InvalidIdentityProofError, + UnableToReadHandshakeError, +} from '../errors.js' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ /** @import {Duplex, Readable} from "streamx" */ @@ -154,7 +158,7 @@ export class RemoteDiscovery extends TypedEmitter { ) if (!valid) { - throw new Error('Invalid identity proof') + throw new InvalidIdentityProofError() } // @ts-ignore @@ -182,5 +186,5 @@ async function readChunk(stream) { if (data) return data - throw new Error('Unable to read chunk') + throw new UnableToReadHandshakeError() } diff --git a/src/errors.js b/src/errors.js index 857c450a6..c3a2eb2fe 100644 --- a/src/errors.js +++ b/src/errors.js @@ -502,6 +502,60 @@ export const InvalidDrizzleJournalError = createErrorClass({ status: 400, }) +export const InvalidIdentityProofError = createErrorClass({ + code: 'INVALID_IDENTITY_PROOF_ERROR', + message: 'Invalid identity proof', + status: 400, +}) + +export const UnableToReadHandshakeError = createErrorClass({ + code: 'UNABLE_TO_READ_HANDSHAKE_ERROR', + message: 'Unable to read handshake from hyperswarm connection', + status: 500, +}) + +export const InvalidInternetInviteURLError = createErrorClass({ + code: 'INVALID_INTERNET_INVITE_URL_ERROR', + message: 'Invalid internet invite URL', + status: 400, +}) + +export const InviteAlreadyRedeemedError = createErrorClass({ + code: 'INVITE_ALREADY_REDEEMED_ERROR', + message: 'Invite already redeemed', + status: 409, +}) + +export const UnknownInviteIDRedeemAttemptError = createErrorClass({ + code: 'UNKNOWN_INVITE_ID_REDEEM_ATTEMPT_ERROR', + message: 'Unknown invite ID redeem attempt', + status: 404, +}) + +export const InviteNotYetRedeemedError = createErrorClass({ + code: 'INVITE_NOT_YET_REDEEMED_ERROR', + message: 'Cannot yet accept: Invite not yet redeemed', + status: 400, +}) + +export const PeerDisconnectedSinceRedeemingInviteError = createErrorClass({ + code: 'PEER_DISCONNECTED_SINCE_REDEEMING_INVITE_ERROR', + message: 'Peer disconnected since redeeming invite', + status: 408, +}) + +export const UnknownInviteIDError = createErrorClass({ + code: 'UNKNOWN_INVITE_ID_ERROR', + message: 'Unknown invite ID', + status: 404, +}) + +export const MissingInviteAndDeviceParamsError = createErrorClass({ + code: 'MISSING_INVITE_AND_DEVICE_PARAMS_ERROR', + message: 'Missing invite and device parameters from URL', + status: 400, +}) + /** * @param {unknown} err * @returns {null} diff --git a/src/member-api.js b/src/member-api.js index 92ce261f3..a458a5eb4 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -33,6 +33,13 @@ import { AlreadyInvitingError, InvalidResponseBodyError, RPCDisconnectBeforeAckError, + InvalidInternetInviteURLError, + InviteAlreadyRedeemedError, + UnknownInviteIDRedeemAttemptError, + InviteNotYetRedeemedError, + PeerDisconnectedSinceRedeemingInviteError, + UnknownInviteIDError, + MissingInviteAndDeviceParamsError, } from './errors.js' import { wsCoreReplicator } from './lib/ws-core-replicator.js' import { @@ -232,7 +239,7 @@ export class MemberApi extends TypedEmitter { const { inviteIdString } = parseInviteURL(url) if (!this.#pendingInvitesOverInternet.has(inviteIdString)) { - throw new Error('Invalid internet invite URL') + throw new InvalidInternetInviteURLError() } this.#pendingInvitesOverInternet.delete(inviteIdString) if (this.#pendingInvitesOverInternet.size === 0) { @@ -267,7 +274,7 @@ export class MemberApi extends TypedEmitter { if (pendingInvite.inviteeDeviceId) { this.emit( 'internet-invite-redeem-error', - new Error('Invite already redeemed'), + new InviteAlreadyRedeemedError(), peerId, inviteIdString ) @@ -291,7 +298,7 @@ export class MemberApi extends TypedEmitter { // TODO: Should we break their connection? this.emit( 'internet-invite-redeem-error', - new Error('Unknown invite ID redeem attempt'), + new UnknownInviteIDRedeemAttemptError(), peerId, inviteIdString ) @@ -309,7 +316,7 @@ export class MemberApi extends TypedEmitter { ] of this.#pendingInvitesOverInternet.entries()) { if (pendingInviteId !== inviteId) continue if (!inviteeDeviceId) { - throw new Error('Cannot yet accept: Invite not yet redeemed') + throw new InviteNotYetRedeemedError() } const stillConnected = await this.#markInternetPeerAsTrusted( @@ -319,14 +326,14 @@ export class MemberApi extends TypedEmitter { // If they aren't connected after redeeming, we should mark it as cancelled if (!stillConnected) { await this.cancelInviteOverInternet(pendingInviteId) - throw new Error('Peer disconnected since redeeming invite') + throw new PeerDisconnectedSinceRedeemingInviteError() } const decision = await this.invite(inviteeDeviceId, opts) return decision } - throw new Error('Unknown invite ID') + throw new UnknownInviteIDError() } /** @@ -956,7 +963,7 @@ export function parseInviteURL(url) { const deviceId = params.get('d') if (typeof inviteIdString !== 'string' || typeof deviceId !== 'string') { - throw new Error('Missing invite and device parameters from URL') + throw new MissingInviteAndDeviceParamsError() } return { inviteIdString, deviceId } } From d112c76c7815dfc1b16b2b709d8a233e8726afd8 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 13 Apr 2026 13:01:56 -0400 Subject: [PATCH 020/103] test: Check for error paths in RemoteDiscovery --- src/discovery/remote-discovery.js | 112 +++++++++++++++--------- test/discovery/remote-discovery.js | 135 ++++++++++++++++++++++++++++- test/member-api.js | 3 + 3 files changed, 208 insertions(+), 42 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 1965a0c90..c8d239a3c 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -6,6 +6,7 @@ import sodium from 'sodium-universal' import b4a from 'b4a' import { SwarmHandshake } from '../generated/handshake.js' import { + ensureKnownError, InvalidIdentityProofError, UnableToReadHandshakeError, } from '../errors.js' @@ -18,8 +19,14 @@ import { /** * @typedef {Object} DiscoveryEvents * @property {(connection: RemoteAuthedNoiseStream) => void} connection + * @property {(error: Error) => void} error */ +// Symbol for test-only access to internal methods +export const kTestOnlyHandleHyperswarmConnection = Symbol( + 'testOnlyHandleHyperswarmConnection' +) + /** * @extends {TypedEmitter} */ @@ -95,6 +102,13 @@ export class RemoteDiscovery extends TypedEmitter { this.#l.log('Closed swarm') } + /** + * @param {OpenedNoiseStream} socket + */ + async [kTestOnlyHandleHyperswarmConnection](socket) { + return this.#handleHyperswarmConnection(socket) + } + /** * Connect to another peer by their NOISE public key * @param {string} publicKey @@ -124,47 +138,56 @@ export class RemoteDiscovery extends TypedEmitter { /** * @param {OpenedNoiseStream} socket - * @param {import('hyperswarm').PeerInfo} _peerInfo */ - async #handleHyperswarmConnection(socket, _peerInfo) { - const remotePublicKeyString = socket.remotePublicKey.toString('hex') - // @ts-ignore - socket.isTrusted = this.#shouldTrustKeys.has(remotePublicKeyString) - - const firstData = readChunk(socket) - const keyPair = this.#identityKeypair - // Sign the Noise handshake hash with our stable key - const sig = b4a.allocUnsafe(64) - sodium.crypto_sign_detached(sig, socket.handshakeHash, keyPair.secretKey) - - // Send stable public key + proof in a single message - const handshakeBuffer = SwarmHandshake.encode({ - publicKey: keyPair.publicKey, - signature: Buffer.from(sig), - }).finish() - - const hasDrained = socket.write(Buffer.from(handshakeBuffer)) - - if (!hasDrained) await pEvent(socket, 'drain', { timeout: 10000 }) - - const data = await firstData - - const msg = SwarmHandshake.decode(data) - - const valid = sodium.crypto_sign_verify_detached( - msg.signature, - socket.handshakeHash, // same hash on both sides - msg.publicKey - ) - - if (!valid) { - throw new InvalidIdentityProofError() + async #handleHyperswarmConnection(socket) { + try { + const remotePublicKeyString = socket.remotePublicKey.toString('hex') + // @ts-ignore + socket.isTrusted = this.#shouldTrustKeys.has(remotePublicKeyString) + + const firstData = readChunk(socket) + const keyPair = this.#identityKeypair + // Sign the Noise handshake hash with our stable key + const sig = b4a.allocUnsafe(64) + sodium.crypto_sign_detached(sig, socket.handshakeHash, keyPair.secretKey) + + // Send stable public key + proof in a single message + const handshakeBuffer = SwarmHandshake.encode({ + publicKey: keyPair.publicKey, + signature: Buffer.from(sig), + }).finish() + + const hasDrained = socket.write(Buffer.from(handshakeBuffer)) + + if (!hasDrained) await pEvent(socket, 'drain', { timeout: 10000 }) + + const data = await firstData + + const msg = SwarmHandshake.decode(data) + + try { + const valid = sodium.crypto_sign_verify_detached( + msg.signature, + socket.handshakeHash, // same hash on both sides + msg.publicKey + ) + + if (!valid) { + throw new InvalidIdentityProofError() + } + } catch (e) { + if (e instanceof InvalidIdentityProofError) throw e + throw new InvalidIdentityProofError({ cause: e }) + } + + // @ts-ignore + socket.handshakePublicKey = msg.publicKey + // @ts-ignore + this.emit('connection', socket) + } catch (err) { + console.log({ err }) + this.emit('error', ensureKnownError(err)) } - - // @ts-ignore - socket.handshakePublicKey = msg.publicKey - // @ts-ignore - this.emit('connection', socket) } } @@ -173,12 +196,19 @@ export class RemoteDiscovery extends TypedEmitter { * @param {Readable|Duplex} stream * @returns {Promise} */ -async function readChunk(stream) { +export async function readChunk(stream) { let data = stream.read() if (data) return data - await pEvent(stream, 'readable') + try { + await pEvent(stream, 'readable', { + timeout: 10_00, + rejectionEvents: ['error', 'close'], + }) + } catch { + throw new UnableToReadHandshakeError() + } stream.pause() diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index e59077e3e..aed18c3a3 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -3,7 +3,18 @@ import assert from 'node:assert/strict' import { KeyManager, keyToPublicId } from '@mapeo/crypto' import pDefer from 'p-defer' import { pEvent } from 'p-event' -import { RemoteDiscovery } from '../../src/discovery/remote-discovery.js' +import { + RemoteDiscovery, + readChunk, + kTestOnlyHandleHyperswarmConnection, +} from '../../src/discovery/remote-discovery.js' +import { SwarmHandshake } from '../../src/generated/handshake.js' +import { + ensureKnownError, + UnableToReadHandshakeError, + InvalidIdentityProofError, +} from '../../src/errors.js' +import { Duplex, Transform } from 'streamx' test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { const identityKeypair1 = new KeyManager( @@ -124,6 +135,128 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => outboundStream.end() }) +test('RemoteDiscovery - readChunk throws UnableToReadHandshakeError on empty stream', async () => { + // Create a stream that closes immediately without providing data + const emptyStream = new Transform({ + // @ts-ignore + transform(_chunk, _encoding, callback) { + callback() + }, + }) + + // Close the stream immediately + emptyStream.end() + + // readChunk should throw UnableToReadHandshakeError when it can't read data + await assert.rejects( + readChunk(emptyStream), + (err) => ensureKnownError(err).code === UnableToReadHandshakeError.code, + 'readChunk should throw UnableToReadHandshakeError' + ) +}) + +test('RemoteDiscovery - emits InvalidIdentityProofError on invalid signature', async (t) => { + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 3)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() + + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + swarmIdentityKeypair: swarmKeypair1, + }) + + t.after(() => Promise.all([remoteDiscovery1.close()])) + + // Should reject with an error event + const onError = pEvent(remoteDiscovery1, 'error', { + timeout: 5000, + }) + + // Create a mock stream with the required properties + const connection = + /** @type {import('../../src/lib/noise-secret-stream-helpers.js').OpenedNoiseStream}*/ ( + /** @type {unknown} */ new Duplex({ + read() { + // Push the handshake data inside the read handler + const handshakeData = SwarmHandshake.encode({ + publicKey: identityKeypair1.publicKey, + signature: Buffer.alloc(64), + }).finish() + this.push(handshakeData) + this.push(null) // end the stream + }, + write(_chunk, callback) { + callback() + }, + }) + ) + // Set up the stream properties + connection.remotePublicKey = swarmKeypair2.publicKey + connection.handshakeHash = Buffer.alloc(32, 0) + + await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) + + const err = await onError + + // The error should be emitted on the server side when invalid signature is received + assert.equal( + ensureKnownError(err).code, + InvalidIdentityProofError.code, + 'should emit error with InvalidIdentityProofError code on invalid signature' + ) +}) + +test.only('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', async (t) => { + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 3)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() + + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + swarmIdentityKeypair: swarmKeypair1, + }) + + t.after(() => Promise.all([remoteDiscovery1.close()])) + + // Should reject with an error event + const onError = pEvent(remoteDiscovery1, 'error', { + timeout: 5000, + }) + + // Create a mock stream with the required properties + const connection = + /** @type {import('../../src/lib/noise-secret-stream-helpers.js').OpenedNoiseStream}*/ ( + /** @type {unknown} */ new Duplex({ + read() { + // Push invalid packet + this.push(Buffer.from('Hello World!')) + this.push(null) // end the stream + }, + write(_chunk, callback) { + callback() + }, + }) + ) + // Set up the stream properties + connection.remotePublicKey = swarmKeypair2.publicKey + connection.handshakeHash = Buffer.alloc(32, 0) + + await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) + + const err = await onError + + // The error should be emitted on the server side when invalid signature is received + assert.equal( + ensureKnownError(err).code, + InvalidIdentityProofError.code, + 'should emit error with InvalidIdentityProofError code on invalid signature' + ) +}) + /** * @param {Error} e */ diff --git a/test/member-api.js b/test/member-api.js index 4d36630a7..fa96fa282 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -139,6 +139,7 @@ class MockRoles { * @param {() => import('../src/types.js').ReplicationStream} [opts.getReplicationStream] * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} [opts.waitForInitialSyncWithPeer] * @param {(shouldListen: boolean) => Promise} [opts.setShouldListenOverInternet] + * @param {(deviceId: string) => Promise} [opts.markInternetPeerAsTrusted] * @param {() => Promise} [opts.getProjectSettings] * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] @@ -158,6 +159,7 @@ function setup({ getReplicationStream = () => { throw new Error('Not implemented') }, + markInternetPeerAsTrusted = () => Promise.resolve(true), } = {}) { const keyManager = new KeyManager(rootKey) @@ -188,6 +190,7 @@ function setup({ getReplicationStream, waitForInitialSyncWithPeer, setShouldListenOverInternet, + markInternetPeerAsTrusted, getProjectSettings, getDeviceInfo, setDeviceInfo, From f0026ac9055adff0f22bb44889b508c7024d17b7 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 13 Apr 2026 16:24:40 -0400 Subject: [PATCH 021/103] fix: Detect existing connections --- src/discovery/remote-discovery.js | 46 ++++++++-- test/discovery/remote-discovery.js | 143 +++++++++++++++++++---------- types/hyperswarm.d.ts | 4 +- 3 files changed, 135 insertions(+), 58 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index c8d239a3c..4c7994339 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -11,6 +11,8 @@ import { UnableToReadHandshakeError, } from '../errors.js' +import { openedNoiseSecretStream } from '../lib/noise-secret-stream-helpers.js' + /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ /** @import {Duplex, Readable} from "streamx" */ @@ -40,6 +42,8 @@ export class RemoteDiscovery extends TypedEmitter { #swarmIdentityKeypair /** @type {Set} */ #shouldTrustKeys = new Set() + /** @type {Set} */ + #connections = new Set() /** * @param {Object} opts @@ -61,6 +65,7 @@ export class RemoteDiscovery extends TypedEmitter { keyPair: this.#swarmIdentityKeypair, maxPeers: 4, }) + // @ts-expect-error Hyperswarm lacks the expected utility class to mark the stream as opened swarm.on('connection', this.#handleHyperswarmConnection.bind(this)) this.#l.log('Starting listen') await swarm.listen() @@ -114,11 +119,23 @@ export class RemoteDiscovery extends TypedEmitter { * @param {string} publicKey * @param {object} [opts] * @param {number} [opts.timeout] + * @returns {Promise} */ async connectPeer(publicKey, { timeout = 60_000 } = {}) { const swarm = await this.#ensureSwarm() const noisePublicKey = Buffer.from(publicKey, 'hex') + for (const existingConnection of this.#connections) { + if (existingConnection.remotePublicKey?.equals(noisePublicKey)) { + const opened = await openedNoiseSecretStream(existingConnection) + if (opened.destroyed) break + // @ts-ignore Some connections might not be handshaked, wait for them to be + if (!existingConnection.handshakePublicKey) break + // @ts-ignore + return opened + } + } + this.#shouldTrustKeys.add(publicKey) const onConnected = pEvent(this, 'connection', { @@ -140,6 +157,8 @@ export class RemoteDiscovery extends TypedEmitter { * @param {OpenedNoiseStream} socket */ async #handleHyperswarmConnection(socket) { + this.#connections.add(socket) + socket.once('close', () => this.#connections.delete(socket)) try { const remotePublicKeyString = socket.remotePublicKey.toString('hex') // @ts-ignore @@ -148,14 +167,7 @@ export class RemoteDiscovery extends TypedEmitter { const firstData = readChunk(socket) const keyPair = this.#identityKeypair // Sign the Noise handshake hash with our stable key - const sig = b4a.allocUnsafe(64) - sodium.crypto_sign_detached(sig, socket.handshakeHash, keyPair.secretKey) - - // Send stable public key + proof in a single message - const handshakeBuffer = SwarmHandshake.encode({ - publicKey: keyPair.publicKey, - signature: Buffer.from(sig), - }).finish() + const handshakeBuffer = makeSwarmHandshake(socket.handshakeHash, keyPair) const hasDrained = socket.write(Buffer.from(handshakeBuffer)) @@ -218,3 +230,21 @@ export async function readChunk(stream) { throw new UnableToReadHandshakeError() } + +/** + * + * @param {Buffer|null} handshakeHash + * @param {import('../types.js').KeyPair} keyPair + * @returns + */ +export function makeSwarmHandshake(handshakeHash, keyPair) { + const sig = b4a.allocUnsafe(64) + sodium.crypto_sign_detached(sig, handshakeHash, keyPair.secretKey) + + // Send stable public key + proof in a single message + const handshakeBuffer = SwarmHandshake.encode({ + publicKey: keyPair.publicKey, + signature: Buffer.from(sig), + }).finish() + return handshakeBuffer +} diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index aed18c3a3..5a73739de 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -1,12 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { KeyManager, keyToPublicId } from '@mapeo/crypto' -import pDefer from 'p-defer' import { pEvent } from 'p-event' import { RemoteDiscovery, readChunk, kTestOnlyHandleHyperswarmConnection, + makeSwarmHandshake, } from '../../src/discovery/remote-discovery.js' import { SwarmHandshake } from '../../src/generated/handshake.js' import { @@ -16,6 +16,8 @@ import { } from '../../src/errors.js' import { Duplex, Transform } from 'streamx' +/** @import {OpenedNoiseStream} from '../../src/lib/noise-secret-stream-helpers.js'*/ + test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { const identityKeypair1 = new KeyManager( Buffer.alloc(16, 1) @@ -42,25 +44,22 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => // Start both instances await Promise.all([remoteDiscovery1.start(), remoteDiscovery2.start()]) - const deferred = pDefer() const swarmPublicKey1Hex = swarmKeypair1.publicKey.toString('hex') // Listen for connection on instance 1 - remoteDiscovery1.on('connection', (stream) => { - stream.on('error', handleConnectionError) - // Verify the remote peer's public key matches instance 2 - assert.ok( - stream.remotePublicKey.equals(swarmKeypair2.publicKey), - 'remote public key should match instance 2' - ) - deferred.resolve(stream) - }) + const onConnection = pEvent(remoteDiscovery1, 'connection') // Connect from instance 2 to instance 1 const connectionPromise = remoteDiscovery2.connectPeer(swarmPublicKey1Hex) const outboundStream = await connectionPromise - const inboundStream = await deferred.promise + const inboundStream = await onConnection + + assert.ok( + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'remote public key should match instance 2' + ) + inboundStream.on('error', handleConnectionError) // Verify both sides have the correct keypairs assert.ok( @@ -175,26 +174,14 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid signature', a }) // Create a mock stream with the required properties - const connection = - /** @type {import('../../src/lib/noise-secret-stream-helpers.js').OpenedNoiseStream}*/ ( - /** @type {unknown} */ new Duplex({ - read() { - // Push the handshake data inside the read handler - const handshakeData = SwarmHandshake.encode({ - publicKey: identityKeypair1.publicKey, - signature: Buffer.alloc(64), - }).finish() - this.push(handshakeData) - this.push(null) // end the stream - }, - write(_chunk, callback) { - callback() - }, - }) - ) - // Set up the stream properties - connection.remotePublicKey = swarmKeypair2.publicKey - connection.handshakeHash = Buffer.alloc(32, 0) + const connection = mockConnection( + swarmKeypair2, + // Push the handshake data inside the read handler + SwarmHandshake.encode({ + publicKey: identityKeypair1.publicKey, + signature: Buffer.alloc(64), + }).finish() + ) await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) @@ -208,7 +195,7 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid signature', a ) }) -test.only('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', async (t) => { +test('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', async (t) => { const identityKeypair1 = new KeyManager( Buffer.alloc(16, 1) ).getIdentityKeypair() @@ -228,22 +215,7 @@ test.only('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshak }) // Create a mock stream with the required properties - const connection = - /** @type {import('../../src/lib/noise-secret-stream-helpers.js').OpenedNoiseStream}*/ ( - /** @type {unknown} */ new Duplex({ - read() { - // Push invalid packet - this.push(Buffer.from('Hello World!')) - this.push(null) // end the stream - }, - write(_chunk, callback) { - callback() - }, - }) - ) - // Set up the stream properties - connection.remotePublicKey = swarmKeypair2.publicKey - connection.handshakeHash = Buffer.alloc(32, 0) + const connection = mockConnection(swarmKeypair2, Buffer.from('Hello World!')) await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) @@ -257,9 +229,82 @@ test.only('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshak ) }) +test.only('RemoteDiscovery - connectPeer returns same socket for duplicate connection', async (t) => { + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const identityKeypair2 = new KeyManager( + Buffer.alloc(16, 2) + ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 3)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() + + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + swarmIdentityKeypair: swarmKeypair1, + }) + + t.after(() => Promise.all([remoteDiscovery1.close()])) + + const onConnection = pEvent(remoteDiscovery1, 'connection') + + // Set up the stream properties + const handshakeHash = Buffer.alloc(32, 0) + + // Create a mock stream with the required properties + const connection = mockConnection( + swarmKeypair2, + makeSwarmHandshake(handshakeHash, identityKeypair2), + handshakeHash + ) + + await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) + + await onConnection + + const gotConnection = await remoteDiscovery1.connectPeer( + swarmKeypair2.publicKey.toString('hex') + ) + + assert.equal(gotConnection, connection, 'Got existing connection') + assert( + gotConnection.handshakePublicKey.equals(identityKeypair2.publicKey), + 'Handshake was valid' + ) +}) + /** * @param {Error} e */ function handleConnectionError(e) { assert.fail(`Unexpected connection error: ${e.message}`) } + +/** + * @param {import('../../src/types.js').KeyPair} swarmKeypair + * @param {Uint8Array|Buffer} body + * @param {Buffer} [handshakeHash] + * @returns {OpenedNoiseStream} + */ +function mockConnection( + swarmKeypair, + body, + handshakeHash = Buffer.alloc(32, 0) +) { + const connection = /** @type {OpenedNoiseStream}*/ ( + /** @type {unknown} */ new Duplex({ + read() { + this.push(body) + this.push(null) // end the stream + }, + write(_chunk, callback) { + callback() + }, + }) + ) + + // Set up the stream properties + connection.remotePublicKey = swarmKeypair.publicKey + connection.handshakeHash = handshakeHash + return connection +} diff --git a/types/hyperswarm.d.ts b/types/hyperswarm.d.ts index 38857fd90..b84586b66 100644 --- a/types/hyperswarm.d.ts +++ b/types/hyperswarm.d.ts @@ -1,5 +1,6 @@ declare module 'hyperswarm' { import { TypedEmitter } from 'tiny-typed-emitter' + import NoiseSecretStream from '@hyperswarm/secret-stream' interface PeerInfo { publicKey: Buffer @@ -13,11 +14,12 @@ declare module 'hyperswarm' { } interface SwarmEvents { - connection(socket: Socket, PeerInfo: PeerInfo): void + connection(socket: NoiseSecretStream, PeerInfo: PeerInfo): void } export default class Hyperswarm extends TypedEmitter { constructor({ keyPair: Keypair, maxPeers: number }) get peers(): Map + get connections(): Set listen(): Promise flush(): Promise suspend(): Promise From 186d55b8d8cb8062651a4809b07e801e487cf60f Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 14 Apr 2026 14:50:17 -0400 Subject: [PATCH 022/103] test: Check handling of errors during invite over internet process --- src/discovery/remote-discovery.js | 43 +++++++-- src/errors.js | 12 +++ src/local-peers.js | 6 ++ src/mapeo-manager.js | 40 +++++++-- src/mapeo-project.js | 3 + src/member-api.js | 44 +++++++++- test-e2e/invite-over-internet.js | 141 ++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 18 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 4c7994339..b37095be1 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -1,13 +1,14 @@ import { TypedEmitter } from 'tiny-typed-emitter' import { Logger } from '../logger.js' import Hyperswarm from 'hyperswarm' -import { pEvent } from 'p-event' +import { pEvent, TimeoutError as EventTimeoutError } from 'p-event' import sodium from 'sodium-universal' import b4a from 'b4a' import { SwarmHandshake } from '../generated/handshake.js' import { ensureKnownError, InvalidIdentityProofError, + TimeoutError, UnableToReadHandshakeError, } from '../errors.js' @@ -42,7 +43,7 @@ export class RemoteDiscovery extends TypedEmitter { #swarmIdentityKeypair /** @type {Set} */ #shouldTrustKeys = new Set() - /** @type {Set} */ + /** @type {Set} */ #connections = new Set() /** @@ -114,6 +115,27 @@ export class RemoteDiscovery extends TypedEmitter { return this.#handleHyperswarmConnection(socket) } + /** + * Disconnect from a peer by their NOISE public key + * @param {string} publicKey + */ + async disconnectPeer(publicKey) { + const noisePublicKey = Buffer.from(publicKey, 'hex') + + for (const connection of this.#connections) { + if ( + connection.remotePublicKey?.equals(noisePublicKey) || + ('handshakePublicKey' in connection && + connection.handshakePublicKey.equals(noisePublicKey)) + ) { + this.#l.log('Disconnecting from peer %S', publicKey) + connection.end() + break + } + } + // TODO: Error on unknown peer? + } + /** * Connect to another peer by their NOISE public key * @param {string} publicKey @@ -145,12 +167,18 @@ export class RemoteDiscovery extends TypedEmitter { // Start trying to connect swarm.joinPeer(noisePublicKey) this.#l.log('Connecting to %S', publicKey) + try { + const socket = await onConnected - const socket = await onConnected - - this.#shouldTrustKeys.delete(publicKey) - - return socket + return socket + } catch (e) { + if (e instanceof EventTimeoutError) { + throw new TimeoutError('Timed out waiting for peer') + } + throw e + } finally { + this.#shouldTrustKeys.delete(publicKey) + } } /** @@ -197,7 +225,6 @@ export class RemoteDiscovery extends TypedEmitter { // @ts-ignore this.emit('connection', socket) } catch (err) { - console.log({ err }) this.emit('error', ensureKnownError(err)) } } diff --git a/src/errors.js b/src/errors.js index c3a2eb2fe..4201283ee 100644 --- a/src/errors.js +++ b/src/errors.js @@ -417,6 +417,12 @@ export const InvalidMapShareError = createErrorClass({ status: 400, }) +export const InvalidMapShareReceiverError = createErrorClass({ + code: 'INVALID_MAP_SHARE_RECEIVER_ERROR', + message: 'Got map share intended for a different peer', + status: 400, +}) + export const InvalidResponseBodyError = createErrorClass({ code: 'INVALID_RESPONSE_BODY_ERROR', message: 'Response body is not valid', @@ -429,6 +435,12 @@ export const InvalidInviteError = createErrorClass({ status: 400, }) +export const InviteRedeemConnectionClosedError = createErrorClass({ + code: 'INVITE_REDEEM_CONNECTION_CLOSED_ERROR', + message: 'Connection closed before invite redeem', + status: 408, +}) + export const InviteNotFoundError = createErrorClass({ code: 'INVITE_NOT_FOUND_ERROR', message: 'Cannot find invite {inviteId}', diff --git a/src/local-peers.js b/src/local-peers.js index 4defbaa4a..b3e66ac9d 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -493,6 +493,7 @@ class Peer { * @typedef {object} LocalPeersEvents * @property {(peers: PeerInfo[]) => void} peers Emitted whenever the connection status of peers changes. An array of peerInfo objects with a peer id and the peer connection status * @property {(peer: PeerInfoConnected) => void} peer-add Emitted when a new peer is connected + * @property {(peer: PeerInfoDisconnected) => void} peer-remove Emitted when an existing peer is disconnected * @property {(peerId: string, invite: Invite) => void} invite Emitted when an invite is received * @property {(peerId: string, invite: InviteAck) => void} invite-ack Emitted when an invite acknowledgement is received * @property {(peerId: string, invite: InviteCancel) => void} invite-cancel Emitted when we receive a cancelation for an invite @@ -753,6 +754,11 @@ export class LocalPeers extends TypedEmitter { existingDevicePeers.delete(peer) } this.#attached.delete(peer.protomux) + if (peer.info.status === 'disconnected') { + this.emit('peer-remove', peer.info) + } else { + this.#l.log('Error: Peer not marked as disconnected after disconnect') + } this.#emitPeers() done() }, diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 4d716296d..0adc2ae9d 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -32,6 +32,7 @@ import { deNullify, getDeviceId, keyToId, + noop, projectIdToNonce, projectKeyToId, projectKeyToProjectInviteId, @@ -58,6 +59,8 @@ import { FailedToSetIsArchiveDeviceError, NotFoundError, ProjectExistsError, + InviteRedeemConnectionClosedError, + InvalidMapShareReceiverError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -651,6 +654,9 @@ export class MapeoManager extends TypedEmitter { return false } }, + disconnectFromPeer: async (deviceId) => { + await this.#remoteDiscovery.disconnectPeer(deviceId) + }, }) return project @@ -731,26 +737,42 @@ export class MapeoManager extends TypedEmitter { /** * Attempt to join a project over the internet * @param {string} url + * @param {object} [options] + * @param {number} [options.timeout] * @returns {Promise} */ - async joinProjectOverInternet(url) { + async joinProjectOverInternet(url, { timeout = 60_000 } = {}) { const { deviceId: swarmPublicKeyHex, inviteIdString } = parseInviteURL(url) const inviteId = Buffer.from(inviteIdString, 'hex') const connection = await this.#remoteDiscovery.connectPeer( - swarmPublicKeyHex + swarmPublicKeyHex, + { timeout } + ) + const onClose = pEvent(connection, 'close').then( + () => { + throw new InviteRedeemConnectionClosedError() + }, + // Handle `error` event on connection if there's sudden closes + (e) => { + throw new InviteRedeemConnectionClosedError({ cause: e }) + } ) + // It's okay if this rejection never gets handled + onClose.catch(noop) try { const onInvited = pEvent(this.#invite, 'invite-received') + // Use the identity key from the handshake, not the swarm key from the URL const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') - await this.#localPeers.sendRedeemInviteOverInternet( - identityPublicKeyHex, - { + await Promise.race([ + this.#localPeers.sendRedeemInviteOverInternet(identityPublicKeyHex, { inviteId, - } - ) - const invite = await onInvited + }), + onClose, + ]) + + const invite = await Promise.race([onInvited, onClose]) const projectId = await this.#invite.accept(invite) @@ -1152,7 +1174,7 @@ export class MapeoManager extends TypedEmitter { const receiverDeviceId = receiverDeviceKey.toString('hex') if (receiverDeviceId !== this.#deviceId) { - throw new Error('Got map share intended for a different peer') + throw new InvalidMapShareReceiverError() } /** @type {MapShare} */ diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 988ba7798..2f8744cf0 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -178,6 +178,7 @@ export class MapeoProject extends ReadyResource { * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted + * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {Logger} [opts.logger] * */ @@ -200,6 +201,7 @@ export class MapeoProject extends ReadyResource { getFallbackProjectInfo, setShouldListenOverInternet, markInternetPeerAsTrusted, + disconnectFromPeer, }) { super() @@ -449,6 +451,7 @@ export class MapeoProject extends ReadyResource { }, setShouldListenOverInternet, markInternetPeerAsTrusted, + disconnectFromPeer, logger: this.#l, }) diff --git a/src/member-api.js b/src/member-api.js index a458a5eb4..4b8195766 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -69,6 +69,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { deviceInfoTable } from './schema/project.js' */ /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ +/** @import { PeerInfoDisconnected } from './local-peers.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -116,6 +117,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** * @typedef {object} MemberEvents * @property {(deviceId: string, inviteId: string) => void} internet-invite-redeemed Emitted when an invite over the internet has been redeemed, accept the deviceId to add them + * @property {(inviteId: string) => void} internet-invite-cancelled Emitted when an invite over the internet has been redeemed, accept the deviceId to add them * @property {(err: Error, deviceId: string, url: string) => void} internet-invite-redeem-error Emitted when an invite over the internet has failed to be redeemed */ @@ -134,6 +136,7 @@ export class MemberApi extends TypedEmitter { #waitForInitialSyncWithPeer #setShouldListenOverInternet #markInternetPeerAsTrusted + #disconnectFromPeer #getProjectSettings #getDeviceInfo #setDeviceInfo @@ -158,6 +161,7 @@ export class MemberApi extends TypedEmitter { * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted + * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {() => Promise} opts.getProjectSettings * @param {(deviceId: string) => Promise} opts.getDeviceInfo * @param {(deviceId: string, deviceInfo: NewDeviceInfo) => Promise} opts.setDeviceInfo @@ -175,6 +179,7 @@ export class MemberApi extends TypedEmitter { waitForInitialSyncWithPeer, setShouldListenOverInternet, markInternetPeerAsTrusted, + disconnectFromPeer, getProjectSettings, getDeviceInfo, setDeviceInfo, @@ -193,6 +198,7 @@ export class MemberApi extends TypedEmitter { this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer this.#setShouldListenOverInternet = setShouldListenOverInternet this.#markInternetPeerAsTrusted = markInternetPeerAsTrusted + this.#disconnectFromPeer = disconnectFromPeer this.#getProjectSettings = getProjectSettings this.#getDeviceInfo = getDeviceInfo this.#setDeviceInfo = setDeviceInfo @@ -200,6 +206,7 @@ export class MemberApi extends TypedEmitter { rpc.on('invite-over-internet-redeemed', (peerId, redeem) => this.#handleRedeemInviteOverInternet(peerId, redeem) ) + rpc.on('peer-remove', (peer) => this.#handlePeerRemove(peer)) } /** @@ -232,16 +239,28 @@ export class MemberApi extends TypedEmitter { */ async cancelInviteOverInternet(url) { if (!url) { + for (const inviteId in this.#pendingInvitesOverInternet.keys()) { + this.emit('internet-invite-cancelled', inviteId) + } this.#pendingInvitesOverInternet.clear() await this.#setShouldListenOverInternet(false) return } const { inviteIdString } = parseInviteURL(url) + await this.#cancelInviteOverInternetById(inviteIdString) + } + /** + * Cancel an invite over internet attempt. + * @param {string} inviteIdString + */ + async #cancelInviteOverInternetById(inviteIdString) { if (!this.#pendingInvitesOverInternet.has(inviteIdString)) { throw new InvalidInternetInviteURLError() } this.#pendingInvitesOverInternet.delete(inviteIdString) + this.emit('internet-invite-cancelled', inviteIdString) + if (this.#pendingInvitesOverInternet.size === 0) { await this.#setShouldListenOverInternet(false) } @@ -255,6 +274,25 @@ export class MemberApi extends TypedEmitter { return [...this.#pendingInvitesOverInternet.values()].map(({ url }) => url) } + /** + * + * @param {PeerInfoDisconnected} peer + */ + async #handlePeerRemove(peer) { + for (const [ + pendingInviteId, + pendingInvite, + ] of this.#pendingInvitesOverInternet.entries()) { + if (pendingInvite.inviteeDeviceId === peer.deviceId) { + // TODO: Handle errors? Emit event saying this happened? + this.#cancelInviteOverInternetById(pendingInviteId).catch((e) => { + this.#l.log('Error: Unable to cancel invite', pendingInviteId, e) + }) + break + } + } + } + /** * * @param {string} peerId @@ -278,6 +316,7 @@ export class MemberApi extends TypedEmitter { peerId, inviteIdString ) + await this.#disconnectFromPeer(peerId) return } @@ -293,15 +332,18 @@ export class MemberApi extends TypedEmitter { peerId, inviteIdString ) + await this.#disconnectFromPeer(peerId) + return } - // TODO: Should we break their connection? this.emit( 'internet-invite-redeem-error', new UnknownInviteIDRedeemAttemptError(), peerId, inviteIdString ) + + await this.#disconnectFromPeer(peerId) } /** diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 086f7be79..e69f6c3de 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -4,6 +4,14 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' import assert from 'node:assert/strict' import { pEvent } from 'p-event' import { InviteResponse_Decision } from '../src/generated/rpc.js' +import { + UnknownInviteIDRedeemAttemptError, + TimeoutError, + InviteRedeemConnectionClosedError, + ensureKnownError, + UnknownInviteIDError, +} from '../src/errors.js' +import crypto from 'node:crypto' test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t) @@ -44,3 +52,136 @@ test('invite over internet and join from URL', async (t) => { // TODO: Test that initial sync happened }) + +test('invite over internet errors if invitor deviceID is invalid', async (t) => { + const managers = await createManagers(2, t) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + // Parse and modify the URL to use an invalid device ID + const urlObj = new URL(url) + const params = new URLSearchParams(urlObj.hash.slice(1)) + const invalidDeviceId = '0'.repeat(64) // Invalid device ID + params.set('d', invalidDeviceId) + urlObj.hash = params.toString() + const modifiedUrl = urlObj.toString() + + // Try to join with invalid device ID - should fail immediately + // The invitee won't be able to connect to the non-existent device + await assert.rejects( + invitee.joinProjectOverInternet(modifiedUrl, { timeout: 1000 }), + (err) => ensureKnownError(err).code === TimeoutError.code + ) +}) + +test('invite over internet errors if inviter closes before accepting', async (t) => { + const managers = await createManagers(2, t) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + const onInviteRedeemAttempt = pEvent( + project.$member, + 'internet-invite-redeemed', + { multiArgs: true, timeout: 5000 } + ) + + const onInviteCancelled = pEvent(project.$member, 'internet-invite-cancelled') + + const onInvited = invitee.joinProjectOverInternet(url) + + const [deviceId, attemptedRedeemId] = await onInviteRedeemAttempt + + assert.equal(deviceId, invitee.deviceId) + + // Close the invitor before accepting + invitor.close() + + // The invitee's join should fail because the invitor disconnected + await assert.rejects( + onInvited, + (err) => + ensureKnownError(err).code === InviteRedeemConnectionClosedError.code + ) + + const cancelledId = await onInviteCancelled + assert.equal(cancelledId, attemptedRedeemId, 'redeemed invite got cancelled') + + const pending = project.$member.pendingInternetInvites() + + assert.equal(pending.length, 0, 'Invite got cancelled on fail') + + await assert.rejects( + () => project.$member.acceptRedeemedInvite(attemptedRedeemId), + (err) => ensureKnownError(err).code === UnknownInviteIDError.code, + 'Accepting after a disconnect causes an error' + ) +}) + +test('invite over internet errors if invitee uses random invalid inviteId', async (t) => { + const managers = await createManagers(2, t) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + // Parse and modify the URL to use an invalid invite ID + const urlObj = new URL(url) + const params = new URLSearchParams(urlObj.hash.slice(1)) + const invalidInviteId = crypto.randomBytes(32).toString('hex') // Random 32-byte invite ID + params.set('i', invalidInviteId) + urlObj.hash = params.toString() + const modifiedUrl = urlObj.toString() + + // Expect the invitor to emit an error when the invalid invite is attempted + const onError = pEvent(project.$member, 'internet-invite-redeem-error', { + timeout: 5000, + multiArgs: true, + }) + + // Try to join with invalid invite ID + const joinPromise = invitee.joinProjectOverInternet(modifiedUrl) + + // The invitor should receive the redeem attempt with an error + const [error, peerId] = await onError + assert.equal( + error.code, + UnknownInviteIDRedeemAttemptError.code, + 'Expected UnknownInviteIDRedeemAttemptError' + ) + assert.equal(peerId, invitee.deviceId, 'Error from expected peer ID') + + // The invitee's join should fail because the connection closes when the invite is invalid + await assert.rejects( + joinPromise, + (err) => + ensureKnownError(err).code === InviteRedeemConnectionClosedError.code + ) +}) From 91b661447f7b037c35db37e168669a5c02d87673 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 14 Apr 2026 14:52:11 -0400 Subject: [PATCH 023/103] chore: Fix formatting in manager constructor --- src/mapeo-manager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 0adc2ae9d..269588916 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -199,8 +199,8 @@ export class MapeoManager extends TypedEmitter { super() this.#keyManager = new KeyManager(rootKey) this.#deviceId = getDeviceId(this.#keyManager) - ;(this.#swarmIdentityKeypair = KeyManager.generateProjectKeypair()), - (this.#defaultConfigPath = defaultConfigPath) + this.#swarmIdentityKeypair = KeyManager.generateProjectKeypair() + this.#defaultConfigPath = defaultConfigPath this.#defaultIsArchiveDevice = defaultIsArchiveDevice this.#makeWebsocket = makeWebsocket const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId })) From 225cb40ad752c77ae093cf55c31d9b19f42e3fdb Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:04:18 -0400 Subject: [PATCH 024/103] feat: Add new project table for pending internet invites --- drizzle/project/0004_lazy_sway.sql | 10 + drizzle/project/meta/0004_snapshot.json | 1372 +++++++++++++++++++++++ drizzle/project/meta/_journal.json | 7 + src/schema/project.js | 13 +- 4 files changed, 1401 insertions(+), 1 deletion(-) create mode 100644 drizzle/project/0004_lazy_sway.sql create mode 100644 drizzle/project/meta/0004_snapshot.json diff --git a/drizzle/project/0004_lazy_sway.sql b/drizzle/project/0004_lazy_sway.sql new file mode 100644 index 000000000..3f936c414 --- /dev/null +++ b/drizzle/project/0004_lazy_sway.sql @@ -0,0 +1,10 @@ +CREATE TABLE `pendingInvites` ( + `inviteId` text PRIMARY KEY NOT NULL, + `inviteIdBuffer` blob NOT NULL, + `url` text NOT NULL, + `roleId` text NOT NULL, + `roleName` text, + `roleDescription` text, + `inviteeDeviceId` text, + `createdAt` integer NOT NULL +); diff --git a/drizzle/project/meta/0004_snapshot.json b/drizzle/project/meta/0004_snapshot.json new file mode 100644 index 000000000..6011c55e1 --- /dev/null +++ b/drizzle/project/meta/0004_snapshot.json @@ -0,0 +1,1372 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "48907b38-2a10-478e-815c-60de03d47213", + "prevId": "d36d93d4-5452-4a18-ba67-3d9d3c8ad82b", + "tables": { + "coreOwnership_backlink": { + "name": "coreOwnership_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coreOwnership": { + "name": "coreOwnership", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authCoreId": { + "name": "authCoreId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configCoreId": { + "name": "configCoreId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dataCoreId": { + "name": "dataCoreId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blobCoreId": { + "name": "blobCoreId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blobIndexCoreId": { + "name": "blobIndexCoreId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cores": { + "name": "cores", + "columns": { + "publicKey": { + "name": "publicKey", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceInfo_backlink": { + "name": "deviceInfo_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceInfo": { + "name": "deviceInfo", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selfHostedServerDetails": { + "name": "selfHostedServerDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "field_backlink": { + "name": "field_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "field": { + "name": "field", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagKey": { + "name": "tagKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appearance": { + "name": "appearance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snakeCase": { + "name": "snakeCase", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "universal": { + "name": "universal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "placeholder": { + "name": "placeholder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helperText": { + "name": "helperText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon_backlink": { + "name": "icon_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variants": { + "name": "variants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "observation_backlink": { + "name": "observation_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "observation": { + "name": "observation", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lat": { + "name": "lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lon": { + "name": "lon", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presetRef": { + "name": "presetRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pendingInvites": { + "name": "pendingInvites", + "columns": { + "inviteId": { + "name": "inviteId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inviteIdBuffer": { + "name": "inviteIdBuffer", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleName": { + "name": "roleName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roleDescription": { + "name": "roleDescription", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteeDeviceId": { + "name": "inviteeDeviceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preset_backlink": { + "name": "preset_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preset": { + "name": "preset", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addTags": { + "name": "addTags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removeTags": { + "name": "removeTags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldRefs": { + "name": "fieldRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRef": { + "name": "iconRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms": { + "name": "terms", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remoteDetectionAlert_backlink": { + "name": "remoteDetectionAlert_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remoteDetectionAlert": { + "name": "remoteDetectionAlert", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detectionDateStart": { + "name": "detectionDateStart", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detectionDateEnd": { + "name": "detectionDateEnd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "role_backlink": { + "name": "role_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "role": { + "name": "role", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromIndex": { + "name": "fromIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track_backlink": { + "name": "track_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "track": { + "name": "track", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locations": { + "name": "locations", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observationRefs": { + "name": "observationRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "presetRef": { + "name": "presetRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translation_backlink": { + "name": "translation_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translation": { + "name": "translation", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "docRef": { + "name": "docRef", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "docRefType": { + "name": "docRefType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "propertyRef": { + "name": "propertyRef", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "languageCode": { + "name": "languageCode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "regionCode": { + "name": "regionCode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/project/meta/_journal.json b/drizzle/project/meta/_journal.json index 0d9d51712..30788b70b 100644 --- a/drizzle/project/meta/_journal.json +++ b/drizzle/project/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1761590068314, "tag": "0003_lying_piledriver", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1776276151106, + "tag": "0004_lazy_sway", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/schema/project.js b/src/schema/project.js index a4bacffd7..871bd3c86 100644 --- a/src/schema/project.js +++ b/src/schema/project.js @@ -1,6 +1,6 @@ // These schemas are all in a "project" database. Each project in Mapeo has an // independent "project" database. -import { blob, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { blob, sqliteTable, text, int } from 'drizzle-orm/sqlite-core' import { dereferencedDocSchemas as schemas } from '@comapeo/schema' import { NAMESPACES } from '../constants.js' import { @@ -36,3 +36,14 @@ export const coresTable = sqliteTable('cores', { publicKey: blob('publicKey', { mode: 'buffer' }).notNull(), namespace: text('namespace', { enum: NAMESPACES }).notNull(), }) + +export const pendingInvitesTable = sqliteTable('pendingInvites', { + inviteId: text('inviteId').notNull().primaryKey(), + inviteIdBuffer: blob('inviteIdBuffer', { mode: 'buffer' }).notNull(), + url: text('url').notNull(), + roleId: text('roleId').notNull(), + roleName: text('roleName'), + roleDescription: text('roleDescription'), + inviteeDeviceId: text('inviteeDeviceId'), + createdAt: int('createdAt').notNull(), +}) From adccdcf9705ce0fbda82002724d765fd27577605 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:04:56 -0400 Subject: [PATCH 025/103] feat: Pending invites API with test --- src/errors.js | 6 + src/pending-invites-api.js | 154 +++++++++++++++ test/pending-invites-api.js | 370 ++++++++++++++++++++++++++++++++++++ 3 files changed, 530 insertions(+) create mode 100644 src/pending-invites-api.js create mode 100644 test/pending-invites-api.js diff --git a/src/errors.js b/src/errors.js index 4201283ee..23e25293a 100644 --- a/src/errors.js +++ b/src/errors.js @@ -568,6 +568,12 @@ export const MissingInviteAndDeviceParamsError = createErrorClass({ status: 400, }) +export const PendingInviteAlreadyExistsError = createErrorClass({ + code: 'PENDING_INVITE_ALREADY_EXISTS_ERROR', + message: 'Pending invite with ID {inviteId} already exists', + status: 409, +}) + /** * @param {unknown} err * @returns {null} diff --git a/src/pending-invites-api.js b/src/pending-invites-api.js new file mode 100644 index 000000000..ac0acbb3b --- /dev/null +++ b/src/pending-invites-api.js @@ -0,0 +1,154 @@ +import { eq, sql } from 'drizzle-orm' +import { pendingInvitesTable } from './schema/project.js' +import { isRoleIdForNewInvite } from './roles.js' +import { + ensureKnownError, + getErrorCode, + PendingInviteAlreadyExistsError, +} from './errors.js' + +/** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ +/** @import { InviteOptions } from './member-api.js' */ + +/** + * @typedef {object} PendingInviteRecord + * @property {string} inviteId Hex string invite ID (primary key) + * @property {Buffer} inviteIdBuffer Binary invite ID + * @property {string} url Invite URL + * @property {import('./roles.js').RoleIdForNewInvite} roleId + * @property {string} [roleName] + * @property {string} [roleDescription] + * @property {string} [inviteeDeviceId] Device ID of invitee (set when redeemed) + * @property {number} createdAt Timestamp when created + */ + +/** + * @typedef {object} PendingInviteCreate + * @property {string} inviteId + * @property {Buffer} inviteIdBuffer + * @property {string} url + * @property {InviteOptions} opts + */ + +/** + * @typedef {object} PendingInviteUpdate + * @property {string} inviteeDeviceId + */ + +/** + * API for CRUD operations on pending invites over internet + */ +export class PendingInvitesApi { + /** @type {BetterSQLite3Database} */ + #db + #sql + + /** + * @param {BetterSQLite3Database} db Drizzle database instance + */ + constructor(db) { + this.#db = db + this.#sql = { + getById: db + .select() + .from(pendingInvitesTable) + .where(eq(pendingInvitesTable.inviteId, sql.placeholder('inviteId'))) + .limit(1) + .prepare(), + getAll: db.select().from(pendingInvitesTable).prepare(), + } + } + + /** + * Create a new pending invite record + * @param {PendingInviteCreate} data + * @returns {Promise} + */ + async create(data) { + try { + await this.#db.insert(pendingInvitesTable).values({ + inviteId: data.inviteId, + inviteIdBuffer: data.inviteIdBuffer, + url: data.url, + roleId: data.opts.roleId, + roleName: data.opts.roleName, + roleDescription: data.opts.roleDescription, + createdAt: Date.now(), + }) + } catch (err) { + if (getErrorCode(err) === 'SQLITE_CONSTRAINT_PRIMARYKEY') { + throw new PendingInviteAlreadyExistsError({ inviteId: data.inviteId }) + } + throw ensureKnownError(err) + } + } + + /** + * Get a pending invite by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + const row = this.#sql.getById.get({ inviteId }) + if (!row) return undefined + + if (!isRoleIdForNewInvite(row.roleId)) { + throw new Error(`Invalid roleId in database: ${row.roleId}`) + } + + return /** @type {PendingInviteRecord} */ ({ + ...row, + inviteeDeviceId: row.inviteeDeviceId ?? undefined, + }) + } + + /** + * Get all pending invites + * @returns {Promise} + */ + async getAll() { + const rows = this.#sql.getAll.all() + + return rows.map((row) => { + if (!isRoleIdForNewInvite(row.roleId)) { + throw new Error(`Invalid roleId in database: ${row.roleId}`) + } + return /** @type {PendingInviteRecord} */ ({ + ...row, + inviteeDeviceId: row.inviteeDeviceId ?? undefined, + }) + }) + } + + /** + * Update a pending invite (e.g., set invitee device ID when redeemed) + * @param {string} inviteId + * @param {PendingInviteUpdate} updates + * @returns {Promise} + */ + async update(inviteId, updates) { + await this.#db + .update(pendingInvitesTable) + .set(updates) + .where(eq(pendingInvitesTable.inviteId, inviteId)) + } + + /** + * Delete a pending invite + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + await this.#db + .delete(pendingInvitesTable) + .where(eq(pendingInvitesTable.inviteId, inviteId)) + } + + /** + * Delete all pending invites + * @returns {Promise} + */ + async deleteAll() { + await this.#db.delete(pendingInvitesTable) + } +} diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js new file mode 100644 index 000000000..36e8a9afe --- /dev/null +++ b/test/pending-invites-api.js @@ -0,0 +1,370 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { randomBytes } from 'node:crypto' +import { PendingInvitesApi } from '../src/pending-invites-api.js' +import { + MEMBER_ROLE_ID, + COORDINATOR_ROLE_ID, + BLOCKED_ROLE_ID, +} from '../src/roles.js' +import { pendingInvitesTable } from '../src/schema/project.js' +import { PendingInviteAlreadyExistsError } from '../src/errors.js' + +/** @import {BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ + +/** + * @returns {{api:PendingInvitesApi, db:BetterSQLite3Database}} + */ +function setup() { + const sqlite = new Database(':memory:') + const db = drizzle(sqlite) + + migrate(db, { + migrationsFolder: new URL('../drizzle/project', import.meta.url).pathname, + }) + + const api = new PendingInvitesApi(db) + + return { api, db } +} + +test('create() - basic functionality', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const url = 'https://example.com/invite/abc123' + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url, + opts: { + roleId: MEMBER_ROLE_ID, + roleName: 'Member', + roleDescription: 'A regular member', + }, + }) + + const retrieved = await api.getById(inviteIdString) + assert(retrieved, 'invite can be retrieved') + assert.equal(retrieved.inviteId, inviteIdString) + assert.equal(retrieved.url, url) + assert.equal(retrieved.roleId, MEMBER_ROLE_ID) + assert.equal(retrieved.roleName, 'Member') + assert.equal(retrieved.roleDescription, 'A regular member') + assert.ok(retrieved.createdAt, 'has createdAt timestamp') +}) + +test('create() - duplicate inviteId throws', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const url1 = 'https://example.com/invite/first' + const url2 = 'https://example.com/invite/second' + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: url1, + opts: { + roleId: MEMBER_ROLE_ID, + }, + }) + + assert.rejects( + api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: url2, + opts: { + roleId: COORDINATOR_ROLE_ID, + roleName: 'Coordinator', + }, + }), + { code: PendingInviteAlreadyExistsError.code }, + 'Second reate throws an error' + ) +}) + +test('getById() - non-existent invite', async () => { + const { api } = setup() + + const result = await api.getById('non-existent-id') + assert.equal(result, undefined, 'returns undefined for non-existent invite') +}) + +test('getAll() - empty database', async () => { + const { api } = setup() + + const invites = await api.getAll() + assert.deepEqual(invites, [], 'returns empty array when no invites exist') +}) + +test('getAll() - multiple invites', async () => { + const { api } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + const invite3 = randomBytes(32) + + await api.create({ + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.create({ + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await api.create({ + inviteId: invite3.toString('hex'), + inviteIdBuffer: invite3, + url: 'https://example.com/3', + opts: { roleId: BLOCKED_ROLE_ID }, + }) + + const invites = await api.getAll() + assert.equal(invites.length, 3, 'returns all three invites') + + const inviteIds = invites.map((i) => i.inviteId) + assert.ok(inviteIds.includes(invite1.toString('hex'))) + assert.ok(inviteIds.includes(invite2.toString('hex'))) + assert.ok(inviteIds.includes(invite3.toString('hex'))) +}) + +test('update() - set inviteeDeviceId', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const beforeUpdate = await api.getById(inviteIdString) + assert.equal(beforeUpdate?.inviteeDeviceId, undefined) + + const inviteeDeviceId = randomBytes(32).toString('hex') + await api.update(inviteIdString, { inviteeDeviceId }) + + const afterUpdate = await api.getById(inviteIdString) + assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) +}) + +test('update() - update non-existent invite', async () => { + const { api } = setup() + + const inviteeDeviceId = randomBytes(32).toString('hex') + await api.update('non-existent-id', { inviteeDeviceId }) + + const result = await api.getById('non-existent-id') + assert.equal(result, undefined, 'no-op for non-existent invite') +}) + +test('delete() - single invite', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.delete(inviteIdString) + + const retrieved = await api.getById(inviteIdString) + assert.equal(retrieved, undefined, 'invite deleted') + + const all = await api.getAll() + assert.equal(all.length, 0, 'invite removed from getAll') +}) + +test('delete() - non-existent invite', async () => { + const { api } = setup() + + await api.delete('non-existent-id') + + const result = await api.getById('non-existent-id') + assert.equal(result, undefined, 'no-op for non-existent invite') +}) + +test('deleteAll() - clear all invites', async () => { + const { api } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + const invite3 = randomBytes(32) + + await api.create({ + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.create({ + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await api.create({ + inviteId: invite3.toString('hex'), + inviteIdBuffer: invite3, + url: 'https://example.com/3', + opts: { roleId: BLOCKED_ROLE_ID }, + }) + + await api.deleteAll() + + const all = await api.getAll() + assert.equal(all.length, 0, 'all invites deleted') +}) + +test('deleteAll() - empty database', async () => { + const { api } = setup() + + await api.deleteAll() + + const all = await api.getAll() + assert.equal(all.length, 0, 'no-op on empty database') +}) + +test('Role ID validation on read', async () => { + const { api, db } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + // Directly insert an invalid roleId into the database using Drizzle, bypassing the API + await db.insert(pendingInvitesTable).values({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + roleId: 'invalid-role-id', + createdAt: Date.now(), + }) + + await assert.rejects( + async () => await api.getById(inviteIdString), + /Invalid roleId in database/, + 'throws error for invalid roleId on getById' + ) + + await assert.rejects( + async () => await api.getAll(), + /Invalid roleId in database/, + 'throws error for invalid roleId on getAll' + ) +}) + +test('Buffer persistence', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const retrieved = await api.getById(inviteIdString) + assert.ok(retrieved?.inviteIdBuffer.equals(inviteId), 'buffer is identical') +}) + +test('Timestamp verification', async () => { + const { api } = setup() + + const beforeCreate = Date.now() + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const afterCreate = Date.now() + const retrieved = await api.getById(inviteIdString) + + assert.ok(retrieved, 'able to retrieve') + + assert.ok( + retrieved.createdAt >= beforeCreate && retrieved.createdAt <= afterCreate, + 'createdAt is between before and after create call' + ) +}) + +test('Optional fields', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { + roleId: MEMBER_ROLE_ID, + // No roleName or roleDescription + }, + }) + + const retrieved = await api.getById(inviteIdString) + assert.equal( + retrieved?.roleName, + null, + 'roleName is undefined when not provided' + ) + assert.equal( + retrieved?.roleDescription, + null, + 'roleDescription is undefined when not provided' + ) +}) + +test('create() and getAll() with inviteeDeviceId already set', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const inviteeDeviceId = randomBytes(32).toString('hex') + + await api.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.update(inviteIdString, { inviteeDeviceId }) + + const all = await api.getAll() + assert.equal(all.length, 1) + assert.equal(all[0].inviteeDeviceId, inviteeDeviceId) +}) From 925e48a42af55f7c76541dcefed89ea9b0eae897 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:05:28 -0400 Subject: [PATCH 026/103] fix: All ready resource methods can be optiona --- types/ready-resource.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/ready-resource.d.ts b/types/ready-resource.d.ts index e94d507c4..35f3785e7 100644 --- a/types/ready-resource.d.ts +++ b/types/ready-resource.d.ts @@ -21,6 +21,6 @@ declare module 'ready-resource' { /** * Unload any async resources here */ - abstract _close(): Promise | void + _close(): Promise | void } } From dcc7c05cef6624676d1b7a016ac3902a470ad1cc Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:05:55 -0400 Subject: [PATCH 027/103] chore: Tidy remote discovery JSdoc types --- src/discovery/remote-discovery.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index b37095be1..d1c736fcb 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -15,6 +15,7 @@ import { import { openedNoiseSecretStream } from '../lib/noise-secret-stream-helpers.js' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ +/** @import {Keypair} from './local-discovery.js' */ /** @import {Duplex, Readable} from "streamx" */ /** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer, isTrusted: boolean}} RemoteAuthedNoiseStream */ @@ -48,7 +49,7 @@ export class RemoteDiscovery extends TypedEmitter { /** * @param {Object} opts - * @param {import('./local-discovery.js').Keypair} opts.identityKeypair + * @param {Keypair} opts.identityKeypair * @param {import('./local-discovery.js').Keypair} opts.swarmIdentityKeypair * @param {Logger} [opts.logger] */ From 35c4296faef6a22103373f7c45483f9a10975df0 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:13:17 -0400 Subject: [PATCH 028/103] feat: Add persistent pending invites to member API --- src/mapeo-project.js | 4 ++ src/member-api.js | 60 ++++++++++++++++--- test/member-api.js | 134 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 2f8744cf0..8b46e7e7c 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -49,6 +49,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' +import { PendingInvitesApi } from './pending-invites-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -249,6 +250,8 @@ export class MapeoProject extends ReadyResource { remoteDetectionAlertTable, ] + const pendingInvitesApi = new PendingInvitesApi(db) + ///////// 2. Wipe data if we need to re-index if (reindex) { @@ -427,6 +430,7 @@ export class MapeoProject extends ReadyResource { encryptionKeys, projectKey, rpc: localPeers, + pendingInvitesApi, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer: (deviceId, abortSignal) => diff --git a/src/member-api.js b/src/member-api.js index 4b8195766..409ba5b05 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,7 +1,6 @@ import * as b4a from 'b4a' import * as crypto from 'node:crypto' import WebSocket from 'ws' -import { TypedEmitter } from 'tiny-typed-emitter' import { pEvent } from 'p-event' import { InviteResponse_Decision } from './generated/rpc.js' import { @@ -13,6 +12,7 @@ import { Logger } from './logger.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' +import ReadyResource from 'ready-resource' import { AlreadyBlockedError, DeviceIdNotForServerError, @@ -70,6 +70,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ +/** @import { PendingInvitesApi } from './pending-invites-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -122,9 +123,9 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] */ /** - * @extends {TypedEmitter} + * @extends {ReadyResource} */ -export class MemberApi extends TypedEmitter { +export class MemberApi extends ReadyResource { #ownDeviceId #swarmPublicKey #roles @@ -140,6 +141,7 @@ export class MemberApi extends TypedEmitter { #getProjectSettings #getDeviceInfo #setDeviceInfo + #pendingInvitesApi #l /** @type {Map} */ @@ -156,6 +158,7 @@ export class MemberApi extends TypedEmitter { * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc + * @param {Pick} opts.pendingInvitesApi * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer @@ -174,6 +177,7 @@ export class MemberApi extends TypedEmitter { encryptionKeys, projectKey, rpc, + pendingInvitesApi, makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, @@ -193,6 +197,7 @@ export class MemberApi extends TypedEmitter { this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey this.#rpc = rpc + this.#pendingInvitesApi = pendingInvitesApi this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer @@ -203,10 +208,33 @@ export class MemberApi extends TypedEmitter { this.#getDeviceInfo = getDeviceInfo this.#setDeviceInfo = setDeviceInfo - rpc.on('invite-over-internet-redeemed', (peerId, redeem) => + // Setup event listeners + this.#rpc.on('invite-over-internet-redeemed', (peerId, redeem) => this.#handleRedeemInviteOverInternet(peerId, redeem) ) - rpc.on('peer-remove', (peer) => this.#handlePeerRemove(peer)) + this.#rpc.on('peer-remove', (peer) => this.#handlePeerRemove(peer)) + } + + async _open() { + // Load pending invites from database + const persistedInvites = await this.#pendingInvitesApi.getAll() + for (const row of persistedInvites) { + this.#pendingInvitesOverInternet.set(row.inviteId, { + inviteId: row.inviteIdBuffer, + url: row.url, + opts: { + roleId: row.roleId, + roleName: row.roleName, + roleDescription: row.roleDescription, + }, + inviteeDeviceId: row.inviteeDeviceId ?? undefined, + }) + } + + // Enable listening if there are pending invites + if (this.#pendingInvitesOverInternet.size > 0) { + await this.#setShouldListenOverInternet(true) + } } /** @@ -214,6 +242,7 @@ export class MemberApi extends TypedEmitter { * @param {InviteOptions} opts */ async inviteOverInternet(opts) { + await this.ready() const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') const deviceId = this.#swarmPublicKey.toString('hex') @@ -226,6 +255,13 @@ export class MemberApi extends TypedEmitter { opts, }) + await this.#pendingInvitesApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url, + opts, + }) + if (this.#pendingInvitesOverInternet.size === 1) { await this.#setShouldListenOverInternet(true) } @@ -238,11 +274,13 @@ export class MemberApi extends TypedEmitter { * @param {string} [url] */ async cancelInviteOverInternet(url) { + await this.ready() if (!url) { - for (const inviteId in this.#pendingInvitesOverInternet.keys()) { + for (const inviteId of this.#pendingInvitesOverInternet.keys()) { this.emit('internet-invite-cancelled', inviteId) } this.#pendingInvitesOverInternet.clear() + await this.#pendingInvitesApi.deleteAll() await this.#setShouldListenOverInternet(false) return } @@ -260,6 +298,7 @@ export class MemberApi extends TypedEmitter { } this.#pendingInvitesOverInternet.delete(inviteIdString) this.emit('internet-invite-cancelled', inviteIdString) + await this.#pendingInvitesApi.delete(inviteIdString) if (this.#pendingInvitesOverInternet.size === 0) { await this.#setShouldListenOverInternet(false) @@ -268,9 +307,10 @@ export class MemberApi extends TypedEmitter { /** * Get the list of pending invites over the internet - * @returns {string[]} + * @returns {Promise} */ - pendingInternetInvites() { + async pendingInternetInvites() { + await this.ready() return [...this.#pendingInvitesOverInternet.values()].map(({ url }) => url) } @@ -321,6 +361,9 @@ export class MemberApi extends TypedEmitter { } pendingInvite.inviteeDeviceId = peerId + await this.#pendingInvitesApi.update(inviteIdString, { + inviteeDeviceId: peerId, + }) this.emit('internet-invite-redeemed', peerId, inviteIdString) return @@ -352,6 +395,7 @@ export class MemberApi extends TypedEmitter { * @returns {Promise} */ async acceptRedeemedInvite(inviteId) { + await this.ready() for (const [ pendingInviteId, { opts, inviteeDeviceId }, diff --git a/test/member-api.js b/test/member-api.js index fa96fa282..7771efefe 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -2,9 +2,6 @@ import test from 'node:test' import assert from 'node:assert/strict' import { randomBytes } from 'node:crypto' import { KeyManager } from '@mapeo/crypto' -import Database from 'better-sqlite3' -import { drizzle } from 'drizzle-orm/better-sqlite3' -import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { makeInviteURL, MemberApi, parseInviteURL } from '../src/member-api.js' import { LocalPeers } from '../src/local-peers.js' @@ -12,6 +9,8 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ +/** @import { InviteOptions } from '../src/member-api.js' */ +/** @import {PendingInviteRecord, PendingInviteCreate,PendingInviteUpdate} from '../src/pending-invites-api.js' */ test('serialize and parse invite URLs', () => { const testDeviceId = 'foo' @@ -36,7 +35,7 @@ test('List pending invites over internet', async () => { roleId: MEMBER_ROLE_ID, }) - const pending = member.pendingInternetInvites() + const pending = await member.pendingInternetInvites() assert.deepEqual( pending.toSorted(), @@ -95,6 +94,43 @@ test('Cancel invite over internet requests', async () => { assert.deepEqual(await member.pendingInternetInvites(), [], 'No URLs left') }) +test('Pending invites are loaded from persistence on ready', async () => { + const pendingInvitesApi = new MockPendingInvitesApi() + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const deviceId = randomBytes(32).toString('hex') + const url = makeInviteURL(inviteIdString, deviceId) + + // Pre-populate the mock with a pending invite + await pendingInvitesApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url, + opts: { + roleId: MEMBER_ROLE_ID, + roleName: 'Member', + }, + }) + + let didStartInternet = false + + const { member } = setup({ + pendingInvitesApi, + setShouldListenOverInternet: async (shouldStart) => { + didStartInternet = shouldStart + }, + }) + + // Wait for member API to be ready (loads pending invites) + await member.ready() + + assert.ok(didStartInternet, 'Did start listening on internet') + + // Verify the pending invite was loaded + const pending = await member.pendingInternetInvites() + assert.deepEqual(pending, [url], 'Pending invite loaded from persistence') +}) + class MockLocalPeers extends LocalPeers { /** * @param {string} deviceId @@ -131,6 +167,82 @@ class MockRoles { } } +/** + * In-memory mock of PendingInvitesApi for testing + */ +class MockPendingInvitesApi { + /** @type {Map} */ + #invites = new Map() + + /** + * Create a new pending invite record + * @param {PendingInviteCreate} data + * @returns {Promise} + */ + async create(data) { + this.#invites.set(data.inviteId, { + inviteId: data.inviteId, + inviteIdBuffer: data.inviteIdBuffer, + url: data.url, + roleId: data.opts.roleId, + roleName: data.opts.roleName, + roleDescription: data.opts.roleDescription, + createdAt: Date.now(), + }) + } + + /** + * Get a pending invite by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + const invite = this.#invites.get(inviteId) + return invite + ? { ...invite, inviteeDeviceId: invite.inviteeDeviceId ?? undefined } + : undefined + } + + /** + * Get all pending invites + * @returns {Promise} + */ + async getAll() { + return Array.from(this.#invites.values()).map((invite) => ({ + ...invite, + inviteeDeviceId: invite.inviteeDeviceId ?? undefined, + })) + } + + /** + * Update a pending invite (e.g., set invitee device ID when redeemed) + * @param {string} inviteId + * @param {PendingInviteUpdate} updates + * @returns {Promise} + */ + async update(inviteId, updates) { + const invite = this.#invites.get(inviteId) + if (invite) { + Object.assign(invite, updates) + } + } + + /** + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + this.#invites.delete(inviteId) + } + + /** + * @returns {Promise} + */ + async deleteAll() { + this.#invites.clear() + } +} + /** * * @param {Object} [opts] @@ -140,9 +252,11 @@ class MockRoles { * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} [opts.waitForInitialSyncWithPeer] * @param {(shouldListen: boolean) => Promise} [opts.setShouldListenOverInternet] * @param {(deviceId: string) => Promise} [opts.markInternetPeerAsTrusted] + * @param {(deviceId: string) => Promise} [opts.disconnectFromPeer] * @param {() => Promise} [opts.getProjectSettings] * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] + * @param {MockPendingInvitesApi} [opts.pendingInvitesApi] * @returns */ function setup({ @@ -153,6 +267,7 @@ function setup({ getDeviceInfo = () => Promise.reject(new Error('Not implemented')), setDeviceInfo = () => Promise.reject(new Error('Not implemented')), waitForInitialSyncWithPeer = () => Promise.resolve(), + disconnectFromPeer = () => Promise.resolve(), makeWebsocket = () => { throw new Error('Not implemented') }, @@ -160,6 +275,7 @@ function setup({ throw new Error('Not implemented') }, markInternetPeerAsTrusted = () => Promise.resolve(true), + pendingInvitesApi = new MockPendingInvitesApi(), } = {}) { const keyManager = new KeyManager(rootKey) @@ -169,13 +285,6 @@ function setup({ const deviceId = identityKeypair.publicKey.toString('hex') - const sqlite = new Database(':memory:') - const db = drizzle(sqlite) - - migrate(db, { - migrationsFolder: new URL('../drizzle/project', import.meta.url).pathname, - }) - const rpc = new MockLocalPeers() const roles = new MockRoles() @@ -186,9 +295,11 @@ function setup({ roles, encryptionKeys, projectKey, + pendingInvitesApi, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer, + disconnectFromPeer, setShouldListenOverInternet, markInternetPeerAsTrusted, getProjectSettings, @@ -201,5 +312,6 @@ function setup({ roles, member, projectKey, + pendingInvitesApi, } } From b789b56119cbcd5e07939d7d20411ada6a32560b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:13:52 -0400 Subject: [PATCH 029/103] fix: Type cast members events in tests due to ReadyResource change --- test-e2e/invite-over-internet.js | 16 +++++++++++----- test-types/data-types.ts | 4 ++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index e69f6c3de..58cb4b2c5 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -36,7 +36,9 @@ test('invite over internet and join from URL', async (t) => { const onInvited = invitee.joinProjectOverInternet(url) - const [deviceId, inviteId] = await onInviteRedeemAttempt + const [deviceId, inviteId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) assert.equal(deviceId, invitee.deviceId) @@ -109,7 +111,9 @@ test('invite over internet errors if inviter closes before accepting', async (t) const onInvited = invitee.joinProjectOverInternet(url) - const [deviceId, attemptedRedeemId] = await onInviteRedeemAttempt + const [deviceId, attemptedRedeemId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) assert.equal(deviceId, invitee.deviceId) @@ -126,7 +130,7 @@ test('invite over internet errors if inviter closes before accepting', async (t) const cancelledId = await onInviteCancelled assert.equal(cancelledId, attemptedRedeemId, 'redeemed invite got cancelled') - const pending = project.$member.pendingInternetInvites() + const pending = await project.$member.pendingInternetInvites() assert.equal(pending.length, 0, 'Invite got cancelled on fail') @@ -170,9 +174,11 @@ test('invite over internet errors if invitee uses random invalid inviteId', asyn const joinPromise = invitee.joinProjectOverInternet(modifiedUrl) // The invitor should receive the redeem attempt with an error - const [error, peerId] = await onError + const [error, peerId] = /** @type {[Error, string]} */ ( + /**@type unknown*/ (await onError) + ) assert.equal( - error.code, + ensureKnownError(error).code, UnknownInviteIDRedeemAttemptError.code, 'Expected UnknownInviteIDRedeemAttemptError' ) diff --git a/test-types/data-types.ts b/test-types/data-types.ts index c723d028a..74ec1b104 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -34,6 +34,7 @@ const mapeoProject = new MapeoProject({ coreStorage: () => new RAM(), keyManager: new KeyManager(randomBytes(32)), projectKey: randomBytes(32), + swarmPublicKey: randomBytes(32), encryptionKeys: { auth: randomBytes(32) }, sharedDb: drizzle(sqlite), sharedIndexWriter: new IndexWriter({ @@ -45,6 +46,9 @@ const mapeoProject = new MapeoProject({ `http://127.0.0.1:8080/${mediaType}`, localPeers: new LocalPeers(), getFallbackProjectInfo: () => ({ sendStats: false }), + setShouldListenOverInternet: (_shouldListen) => Promise.resolve(), + markInternetPeerAsTrusted: async (_deviceId) => Promise.resolve(true), + disconnectFromPeer: async (_deviceId) => Promise.resolve(), }) ///// Observations From e78ca8670bbf84b41d7a5c02e3531a3a4f5543f7 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Apr 2026 16:26:09 -0400 Subject: [PATCH 030/103] test: Check for persistence in member API --- test/member-api.js | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/test/member-api.js b/test/member-api.js index 7771efefe..b0f2b4619 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -25,7 +25,7 @@ test('serialize and parse invite URLs', () => { }) test('List pending invites over internet', async () => { - const { member } = setup({}) + const { member, pendingInvitesApi } = setup({}) const url1 = await member.inviteOverInternet({ roleId: MEMBER_ROLE_ID, @@ -42,6 +42,13 @@ test('List pending invites over internet', async () => { [url1, url2].toSorted(), 'Both pending URLs returned' ) + + // Verify persistence + const persisted = await pendingInvitesApi.getAll() + assert.equal(persisted.length, 2, 'Two invites persisted') + const persistedUrls = persisted.map((p) => p.url) + assert.ok(persistedUrls.includes(url1), 'url1 is persisted') + assert.ok(persistedUrls.includes(url2), 'url2 is persisted') }) test('setShouldListenOverInternet called once for multiple invites', async () => { @@ -65,7 +72,7 @@ test('setShouldListenOverInternet called once for multiple invites', async () => }) test('Cancel invite over internet requests', async () => { - const { member } = setup({}) + const { member, pendingInvitesApi } = setup({}) const url1 = await member.inviteOverInternet({ roleId: MEMBER_ROLE_ID, @@ -75,6 +82,10 @@ test('Cancel invite over internet requests', async () => { roleId: MEMBER_ROLE_ID, }) + // Verify both invites are persisted + let persisted = await pendingInvitesApi.getAll() + assert.equal(persisted.length, 2, 'Two invites persisted initially') + await member.cancelInviteOverInternet(url1) assert.deepEqual( @@ -82,6 +93,12 @@ test('Cancel invite over internet requests', async () => { [url2], 'One URL left' ) + + // Verify only url2 remains in persistence + persisted = await pendingInvitesApi.getAll() + assert.equal(persisted.length, 1, 'One invite remains after cancel') + assert.equal(persisted[0].url, url2, 'url2 is still persisted') + await member.inviteOverInternet({ roleId: MEMBER_ROLE_ID, }) @@ -89,9 +106,21 @@ test('Cancel invite over internet requests', async () => { roleId: MEMBER_ROLE_ID, }) + // Verify 3 invites now persisted + persisted = await pendingInvitesApi.getAll() + assert.equal( + persisted.length, + 3, + 'Three invites persisted after adding two more' + ) + await member.cancelInviteOverInternet() assert.deepEqual(await member.pendingInternetInvites(), [], 'No URLs left') + + // Verify persistence is cleared + persisted = await pendingInvitesApi.getAll() + assert.equal(persisted.length, 0, 'All invites removed from persistence') }) test('Pending invites are loaded from persistence on ready', async () => { From e0264b13d4cb1e738c50ca7d2db0d76800f77894 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 20 Apr 2026 14:50:03 -0400 Subject: [PATCH 031/103] feat: Length prefix remote discovery handshake buffer --- src/discovery/remote-discovery.js | 78 ++++++++++++++++++++++++------ test/discovery/remote-discovery.js | 22 ++++++--- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index d1c736fcb..b55fef8c0 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -31,6 +31,9 @@ export const kTestOnlyHandleHyperswarmConnection = Symbol( 'testOnlyHandleHyperswarmConnection' ) +// 4 bytes 32bit unsigned int +export const LENGTH_BYTES_LENGTH = 4 + /** * @extends {TypedEmitter} */ @@ -193,7 +196,7 @@ export class RemoteDiscovery extends TypedEmitter { // @ts-ignore socket.isTrusted = this.#shouldTrustKeys.has(remotePublicKeyString) - const firstData = readChunk(socket) + const firstData = readHandshakeBuffer(socket) const keyPair = this.#identityKeypair // Sign the Noise handshake hash with our stable key const handshakeBuffer = makeSwarmHandshake(socket.handshakeHash, keyPair) @@ -234,29 +237,58 @@ export class RemoteDiscovery extends TypedEmitter { /** * * @param {Readable|Duplex} stream - * @returns {Promise} + * @param {number} length + * @returns {Promise} */ -export async function readChunk(stream) { +async function readChunk(stream, length) { let data = stream.read() - if (data) return data + if (!data) { + try { + await pEvent(stream, 'readable', { + timeout: 10_00, + rejectionEvents: ['error', 'close'], + }) + data = stream.read() + } catch { + throw new UnableToReadHandshakeError() + } + } - try { - await pEvent(stream, 'readable', { - timeout: 10_00, - rejectionEvents: ['error', 'close'], - }) - } catch { + stream.pause() + + if (!data) { + // This should never happen throw new UnableToReadHandshakeError() } - stream.pause() + if (data.length === length) return data - data = stream.read() + if (data.length > length) { + const slice = data.subarray(0, length) + const remainder = data.subarray(length) + stream.unshift(remainder) + return slice + } - if (data) return data + const remainingBytes = length - data.length + const remainingData = await readChunk(stream, remainingBytes) + return b4a.concat([data, remainingData]) +} - throw new UnableToReadHandshakeError() +/** + * + * @param {Readable|Duplex} stream + * @returns {Promise} + */ +export async function readHandshakeBuffer(stream) { + const handshakeLengthBytes = await readChunk(stream, LENGTH_BYTES_LENGTH) + + const handshakeLength = await b4a.readUInt32LE(handshakeLengthBytes) + + const data = await readChunk(stream, handshakeLength) + + return Buffer.from(data) } /** @@ -274,5 +306,21 @@ export function makeSwarmHandshake(handshakeHash, keyPair) { publicKey: keyPair.publicKey, signature: Buffer.from(sig), }).finish() - return handshakeBuffer + + return lengthPrefix(handshakeBuffer) +} + +/** + * Convert a buffer to its length prefixed version using Uin32LE + * @param {Uint8Array} buffer + * @return + */ +export function lengthPrefix(buffer) { + const fullBuffer = b4a.allocUnsafe(buffer.length + LENGTH_BYTES_LENGTH) + + b4a.writeUInt32LE(fullBuffer, buffer.length) + + b4a.copy(buffer, fullBuffer, LENGTH_BYTES_LENGTH) + + return fullBuffer } diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 5a73739de..09b9aff64 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -4,9 +4,10 @@ import { KeyManager, keyToPublicId } from '@mapeo/crypto' import { pEvent } from 'p-event' import { RemoteDiscovery, - readChunk, + readHandshakeBuffer, kTestOnlyHandleHyperswarmConnection, makeSwarmHandshake, + lengthPrefix, } from '../../src/discovery/remote-discovery.js' import { SwarmHandshake } from '../../src/generated/handshake.js' import { @@ -148,7 +149,7 @@ test('RemoteDiscovery - readChunk throws UnableToReadHandshakeError on empty str // readChunk should throw UnableToReadHandshakeError when it can't read data await assert.rejects( - readChunk(emptyStream), + readHandshakeBuffer(emptyStream), (err) => ensureKnownError(err).code === UnableToReadHandshakeError.code, 'readChunk should throw UnableToReadHandshakeError' ) @@ -177,10 +178,12 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid signature', a const connection = mockConnection( swarmKeypair2, // Push the handshake data inside the read handler - SwarmHandshake.encode({ - publicKey: identityKeypair1.publicKey, - signature: Buffer.alloc(64), - }).finish() + lengthPrefix( + SwarmHandshake.encode({ + publicKey: identityKeypair1.publicKey, + signature: Buffer.alloc(64), + }).finish() + ) ) await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) @@ -215,7 +218,10 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', a }) // Create a mock stream with the required properties - const connection = mockConnection(swarmKeypair2, Buffer.from('Hello World!')) + const connection = mockConnection( + swarmKeypair2, + lengthPrefix(Buffer.from('Hello World!')) + ) await remoteDiscovery1[kTestOnlyHandleHyperswarmConnection](connection) @@ -229,7 +235,7 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', a ) }) -test.only('RemoteDiscovery - connectPeer returns same socket for duplicate connection', async (t) => { +test('RemoteDiscovery - connectPeer returns same socket for duplicate connection', async (t) => { const identityKeypair1 = new KeyManager( Buffer.alloc(16, 1) ).getIdentityKeypair() From e9b7426696b2e718e677d0572bce31b7a130500b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 22 Apr 2026 14:16:39 -0400 Subject: [PATCH 032/103] chore: Fix ready-resource removal issues --- src/member-api.js | 4 +++- types/ready-resource.d.ts | 26 -------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 types/ready-resource.d.ts diff --git a/src/member-api.js b/src/member-api.js index 409ba5b05..e0130e9ea 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -55,6 +55,8 @@ import { export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] +/** @import { TypedEmitter } from 'tiny-typed-emitter' */ + /** * @import { * DeviceInfo, @@ -123,7 +125,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] */ /** - * @extends {ReadyResource} + * @type {ReadyResource & TypedEmitter} */ export class MemberApi extends ReadyResource { #ownDeviceId diff --git a/types/ready-resource.d.ts b/types/ready-resource.d.ts deleted file mode 100644 index 35f3785e7..000000000 --- a/types/ready-resource.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -declare module 'ready-resource' { - import { TypedEmitter } from 'tiny-typed-emitter' - - export default abstract class ReadyResource< - EventMap - > extends TypedEmitter { - /** - * Resolves when this resource is initialized. - */ - async ready(): Promise - /** - * Resolves when this resource has closed any dependencies. - */ - async close(): Promise - - /** - * Load any async resources here - */ - _open(): Promise | void - - /** - * Unload any async resources here - */ - _close(): Promise | void - } -} From 5cde04bb4a7c3e476ed9daa42c509de51e434360 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 22 Apr 2026 15:14:58 -0400 Subject: [PATCH 033/103] feat: Determenistic swarm key generation --- package-lock.json | 103 ++++++++++++++++++++++++++--- package.json | 2 +- src/discovery/remote-discovery.js | 10 +-- src/mapeo-manager.js | 6 +- src/mapeo-project.js | 10 +-- src/member-api.js | 10 +-- test-types/data-types.ts | 2 +- test/discovery/remote-discovery.js | 10 +-- test/member-api.js | 2 +- 9 files changed, 117 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53a2cc6b1..715ae7207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@fastify/error": "^3.4.1", "@fastify/type-provider-typebox": "^4.1.0", "@hyperswarm/secret-stream": "^6.6.3", - "@mapeo/crypto": "1.0.0-alpha.10", + "@mapeo/crypto": "^1.1.0", "@mapeo/sqlite-indexer": "^1.0.2", "@sinclair/typebox": "^0.33.17", "@sindresorhus/merge-streams": "^4.0.0", @@ -305,6 +305,26 @@ "zip-stream-promise": "^1.0.2" } }, + "node_modules/@comapeo/cloud/node_modules/@mapeo/crypto": { + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", + "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/b4a": "^1.6.0", + "b4a": "^1.6.4", + "base-x": "^3.0.9", + "base32.js": "^0.1.0", + "compact-encoding": "^2.5.1", + "compact-encoding-net": "^1.0.1", + "compact-encoding-struct": "^1.2.0", + "crc": "^3.8.0", + "lodash": "^4.17.21", + "sodium-universal": "^4.0.0", + "z32": "^1.0.0" + } + }, "node_modules/@comapeo/cloud/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -431,6 +451,27 @@ "commander": "~12.1.0" } }, + "node_modules/@comapeo/core/node_modules/@mapeo/crypto": { + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", + "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@types/b4a": "^1.6.0", + "b4a": "^1.6.4", + "base-x": "^3.0.9", + "base32.js": "^0.1.0", + "compact-encoding": "^2.5.1", + "compact-encoding-net": "^1.0.1", + "compact-encoding-struct": "^1.2.0", + "crc": "^3.8.0", + "lodash": "^4.17.21", + "sodium-universal": "^4.0.0", + "z32": "^1.0.0" + } + }, "node_modules/@comapeo/core/node_modules/@mapeo/sqlite-indexer": { "version": "1.0.0-alpha.9", "resolved": "https://registry.npmjs.org/@mapeo/sqlite-indexer/-/sqlite-indexer-1.0.0-alpha.9.tgz", @@ -822,6 +863,26 @@ "commander": "~12.1.0" } }, + "node_modules/@comapeo/core2.0.1/node_modules/@mapeo/crypto": { + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", + "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/b4a": "^1.6.0", + "b4a": "^1.6.4", + "base-x": "^3.0.9", + "base32.js": "^0.1.0", + "compact-encoding": "^2.5.1", + "compact-encoding-net": "^1.0.1", + "compact-encoding-struct": "^1.2.0", + "crc": "^3.8.0", + "lodash": "^4.17.21", + "sodium-universal": "^4.0.0", + "z32": "^1.0.0" + } + }, "node_modules/@comapeo/core2.0.1/node_modules/@mapeo/sqlite-indexer": { "version": "1.0.0-alpha.9", "resolved": "https://registry.npmjs.org/@mapeo/sqlite-indexer/-/sqlite-indexer-1.0.0-alpha.9.tgz", @@ -1202,6 +1263,26 @@ "type-fest": "^4.26.0" } }, + "node_modules/@comapeo/core4.1.4/node_modules/@mapeo/crypto": { + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", + "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/b4a": "^1.6.0", + "b4a": "^1.6.4", + "base-x": "^3.0.9", + "base32.js": "^0.1.0", + "compact-encoding": "^2.5.1", + "compact-encoding-net": "^1.0.1", + "compact-encoding-struct": "^1.2.0", + "crc": "^3.8.0", + "lodash": "^4.17.21", + "sodium-universal": "^4.0.0", + "z32": "^1.0.0" + } + }, "node_modules/@comapeo/core4.1.4/node_modules/@mapeo/sqlite-indexer": { "version": "1.0.0-alpha.9", "resolved": "https://registry.npmjs.org/@mapeo/sqlite-indexer/-/sqlite-indexer-1.0.0-alpha.9.tgz", @@ -2191,19 +2272,17 @@ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" }, "node_modules/@mapeo/crypto": { - "version": "1.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", - "integrity": "sha512-TEK8HN1W0XZOOADIMxa4saXtqAZKyBDeVVn3RBCcPaCiOGHeYy43/0rMnBVTbXZCLsLVPnOXwv6vg+vUkasrWQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.1.0.tgz", + "integrity": "sha512-8vkqvbskpAFlWkVUa/sC/CcpTVkbr5+gjTG3e2FigbGBb4q+2SzMjA+pZpQY/ahhDz7W9qfG7J/Dwwyg8cQ7aw==", + "license": "MIT", "dependencies": { - "@types/b4a": "^1.6.0", - "b4a": "^1.6.4", "base-x": "^3.0.9", "base32.js": "^0.1.0", - "compact-encoding": "^2.5.1", + "compact-encoding": "^2.19.2", "compact-encoding-net": "^1.0.1", "compact-encoding-struct": "^1.2.0", "crc": "^3.8.0", - "lodash": "^4.17.21", "sodium-universal": "^4.0.0", "z32": "^1.0.0" } @@ -2557,6 +2636,7 @@ }, "node_modules/@types/b4a": { "version": "1.6.0", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4118,9 +4198,10 @@ } }, "node_modules/compact-encoding": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.15.0.tgz", - "integrity": "sha512-af/NomxL9Mo0lqCk++rxLLDZI+lJqeBrPt4dK6FbjxTCEhfC9yQAIoO6yq9ixyCirce0luQwErkwJrhem6clxA==", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz", + "integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==", + "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" } diff --git a/package.json b/package.json index 8b289c91c..e1a738a7f 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "@fastify/error": "^3.4.1", "@fastify/type-provider-typebox": "^4.1.0", "@hyperswarm/secret-stream": "^6.6.3", - "@mapeo/crypto": "1.0.0-alpha.10", + "@mapeo/crypto": "^1.1.0", "@mapeo/sqlite-indexer": "^1.0.2", "@sinclair/typebox": "^0.33.17", "@sindresorhus/merge-streams": "^4.0.0", diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index b55fef8c0..2655eae37 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -44,7 +44,7 @@ export class RemoteDiscovery extends TypedEmitter { /** @type {Promise?} */ #loading = null #identityKeypair - #swarmIdentityKeypair + #deriveSwarmIdentityKeypair /** @type {Set} */ #shouldTrustKeys = new Set() /** @type {Set} */ @@ -53,21 +53,21 @@ export class RemoteDiscovery extends TypedEmitter { /** * @param {Object} opts * @param {Keypair} opts.identityKeypair - * @param {import('./local-discovery.js').Keypair} opts.swarmIdentityKeypair + * @param {() => Keypair} opts.deriveSwarmIdentityKeypair * @param {Logger} [opts.logger] */ - constructor({ identityKeypair, swarmIdentityKeypair, logger }) { + constructor({ identityKeypair, deriveSwarmIdentityKeypair, logger }) { super() this.#l = Logger.create('RemoteDiscovery', logger) this.#identityKeypair = identityKeypair - this.#swarmIdentityKeypair = swarmIdentityKeypair + this.#deriveSwarmIdentityKeypair = deriveSwarmIdentityKeypair } async #initSwarm() { this.#l.log('Initializing swarm') const swarm = new Hyperswarm({ - keyPair: this.#swarmIdentityKeypair, + keyPair: this.#deriveSwarmIdentityKeypair(), maxPeers: 4, }) // @ts-expect-error Hyperswarm lacks the expected utility class to mark the stream as opened diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index cd60c5899..25e6db956 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -159,7 +159,6 @@ export class MapeoManager extends TypedEmitter { /** @type {string} */ #projectMigrationsFolder #deviceId - #swarmIdentityKeypair #localPeers #invite #fastify @@ -203,7 +202,6 @@ export class MapeoManager extends TypedEmitter { super() this.#keyManager = new KeyManager(rootKey) this.#deviceId = getDeviceId(this.#keyManager) - this.#swarmIdentityKeypair = KeyManager.generateProjectKeypair() this.#defaultConfigPath = defaultConfigPath this.#defaultIsArchiveDevice = defaultIsArchiveDevice this.#makeWebsocket = makeWebsocket @@ -302,7 +300,7 @@ export class MapeoManager extends TypedEmitter { this.#remoteDiscovery = new RemoteDiscovery({ identityKeypair: this.#keyManager.getIdentityKeypair(), // ephemeral swarm identity each run - swarmIdentityKeypair: this.#swarmIdentityKeypair, + deriveSwarmIdentityKeypair: () => this.#keyManager.deriveSwarmIdentity(), logger, }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) @@ -630,7 +628,7 @@ export class MapeoManager extends TypedEmitter { ...projectKeys, projectMigrationsFolder: this.#projectMigrationsFolder, keyManager: this.#keyManager, - swarmPublicKey: this.#swarmIdentityKeypair.publicKey, + getSwarmPublicKey: () => this.#keyManager.deriveSwarmIdentity().publicKey, sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 6cc0e8579..192217531 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -139,7 +139,7 @@ const VARIANT_EXPORT_ORDER = ['original', 'preview', 'thumbnail'] export class MapeoProject extends ReadyResource { #projectKey #deviceId - #swarmPublicKey + #getSwarmPublicKey #identityKeypair #coreManager #indexWriter @@ -168,7 +168,7 @@ export class MapeoProject extends ReadyResource { * @param {string} opts.projectMigrationsFolder path for drizzle migration folder for project * @param {import('@mapeo/crypto').KeyManager} opts.keyManager mapeo/crypto KeyManager instance * @param {Buffer} opts.projectKey 32-byte public key of the project creator core - * @param {Buffer} opts.swarmPublicKey 32-byte public key used to identify oneself on the hyperswarm DHT + * @param {() => Buffer} opts.getSwarmPublicKey Get the current 32 byte public key used for hyperswarm connections * @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys Encryption keys for each namespace * @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb @@ -195,7 +195,6 @@ export class MapeoProject extends ReadyResource { projectKey, projectSecretKey, encryptionKeys, - swarmPublicKey, getMediaBaseUrl, makeWebsocket = (url) => new WebSocket(url), localPeers, @@ -205,12 +204,13 @@ export class MapeoProject extends ReadyResource { setShouldListenOverInternet, markInternetPeerAsTrusted, disconnectFromPeer, + getSwarmPublicKey, }) { super() this.#l = Logger.create('project', logger) this.#deviceId = getDeviceId(keyManager) - this.#swarmPublicKey = swarmPublicKey + this.#getSwarmPublicKey = getSwarmPublicKey this.#projectKey = projectKey this.#importingCategories = false this.#getFallbackProjectInfo = getFallbackProjectInfo @@ -427,12 +427,12 @@ export class MapeoProject extends ReadyResource { this.#memberApi = new MemberApi({ deviceId: this.#deviceId, - swarmPublicKey: this.#swarmPublicKey, roles: this.#roles, encryptionKeys, projectKey, rpc: localPeers, pendingInvitesApi, + getSwarmPublicKey: this.#getSwarmPublicKey, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer: (deviceId, abortSignal) => diff --git a/src/member-api.js b/src/member-api.js index e0130e9ea..34a1aa4c2 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -129,7 +129,6 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] */ export class MemberApi extends ReadyResource { #ownDeviceId - #swarmPublicKey #roles #encryptionKeys #projectKey @@ -143,6 +142,7 @@ export class MemberApi extends ReadyResource { #getProjectSettings #getDeviceInfo #setDeviceInfo + #getSwarmPublicKey #pendingInvitesApi #l @@ -155,7 +155,7 @@ export class MemberApi extends ReadyResource { /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string - * @param {Buffer} opts.swarmPublicKey public key of this device on the hyperswarm network + * @param {() => Buffer} opts.getSwarmPublicKey * @param {Pick} opts.roles * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey @@ -174,7 +174,6 @@ export class MemberApi extends ReadyResource { */ constructor({ deviceId, - swarmPublicKey, roles, encryptionKeys, projectKey, @@ -189,12 +188,12 @@ export class MemberApi extends ReadyResource { getProjectSettings, getDeviceInfo, setDeviceInfo, + getSwarmPublicKey, logger, }) { super() this.#l = Logger.create('member-api', logger) this.#ownDeviceId = deviceId - this.#swarmPublicKey = swarmPublicKey this.#roles = roles this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey @@ -209,6 +208,7 @@ export class MemberApi extends ReadyResource { this.#getProjectSettings = getProjectSettings this.#getDeviceInfo = getDeviceInfo this.#setDeviceInfo = setDeviceInfo + this.#getSwarmPublicKey = getSwarmPublicKey // Setup event listeners this.#rpc.on('invite-over-internet-redeemed', (peerId, redeem) => @@ -247,7 +247,7 @@ export class MemberApi extends ReadyResource { await this.ready() const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') - const deviceId = this.#swarmPublicKey.toString('hex') + const deviceId = this.#getSwarmPublicKey().toString('hex') const url = makeInviteURL(inviteIdString, deviceId) diff --git a/test-types/data-types.ts b/test-types/data-types.ts index 74ec1b104..5a6326b32 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -34,7 +34,7 @@ const mapeoProject = new MapeoProject({ coreStorage: () => new RAM(), keyManager: new KeyManager(randomBytes(32)), projectKey: randomBytes(32), - swarmPublicKey: randomBytes(32), + getSwarmPublicKey: () => randomBytes(32), encryptionKeys: { auth: randomBytes(32) }, sharedDb: drizzle(sqlite), sharedIndexWriter: new IndexWriter({ diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 09b9aff64..654cfd266 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -31,11 +31,11 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, - swarmIdentityKeypair: swarmKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, }) const remoteDiscovery2 = new RemoteDiscovery({ identityKeypair: identityKeypair2, - swarmIdentityKeypair: swarmKeypair2, + deriveSwarmIdentityKeypair: () => swarmKeypair2, }) t.after(() => @@ -164,7 +164,7 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid signature', a const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, - swarmIdentityKeypair: swarmKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, }) t.after(() => Promise.all([remoteDiscovery1.close()])) @@ -207,7 +207,7 @@ test('RemoteDiscovery - emits InvalidIdentityProofError on invalid handshake', a const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, - swarmIdentityKeypair: swarmKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, }) t.after(() => Promise.all([remoteDiscovery1.close()])) @@ -247,7 +247,7 @@ test('RemoteDiscovery - connectPeer returns same socket for duplicate connection const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, - swarmIdentityKeypair: swarmKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, }) t.after(() => Promise.all([remoteDiscovery1.close()])) diff --git a/test/member-api.js b/test/member-api.js index b0f2b4619..b32fef66f 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -319,7 +319,6 @@ function setup({ const member = new MemberApi({ deviceId, - swarmPublicKey: identityKeypair.publicKey, rpc, roles, encryptionKeys, @@ -334,6 +333,7 @@ function setup({ getProjectSettings, getDeviceInfo, setDeviceInfo, + getSwarmPublicKey: () => identityKeypair.publicKey, }) return { From 922761aefc56ff6f2acb26d9e68738eac89cf786 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 22 Apr 2026 15:21:41 -0400 Subject: [PATCH 034/103] fix: Use array.sort instead of array.toSorted for node18 support --- test/member-api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/member-api.js b/test/member-api.js index b32fef66f..4c0290fc3 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -38,8 +38,8 @@ test('List pending invites over internet', async () => { const pending = await member.pendingInternetInvites() assert.deepEqual( - pending.toSorted(), - [url1, url2].toSorted(), + pending.sort(), + [url1, url2].sort(), 'Both pending URLs returned' ) From f79759d378b9787d51a7d9e9fb0e3a6878a5ce2f Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 23 Apr 2026 11:25:57 -0400 Subject: [PATCH 035/103] fix: log error when error is unknown type --- src/errors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.js b/src/errors.js index 160ee6ef0..26766303b 100644 --- a/src/errors.js +++ b/src/errors.js @@ -619,7 +619,7 @@ export function getErrorCode(maybeError) { */ export function ensureKnownError(err) { if (typeof err.status !== 'number' || typeof err.code !== 'string') { - return new UnknownError(err) + return new UnknownError({ err }) } return err } From 138ba9751b43eedcd1321b8110e3e00b3a6d5ee2 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 23 Apr 2026 11:26:29 -0400 Subject: [PATCH 036/103] fix: Delete null values from loaded pending invite before passing to member.invite --- src/member-api.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/member-api.js b/src/member-api.js index 34a1aa4c2..906068f1b 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -416,6 +416,8 @@ export class MemberApi extends ReadyResource { await this.cancelInviteOverInternet(pendingInviteId) throw new PeerDisconnectedSinceRedeemingInviteError() } + if (!opts.roleName) delete opts.roleName + if (!opts.roleDescription) delete opts.roleDescription const decision = await this.invite(inviteeDeviceId, opts) return decision From 6c0122cc6b5839bf245430968da1e81b381cb4cc Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 23 Apr 2026 11:26:55 -0400 Subject: [PATCH 037/103] test: invite over the internet after invitor reloads app --- test-e2e/invite-over-internet.js | 76 +++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 58cb4b2c5..c4b47f794 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -1,5 +1,6 @@ import test from 'node:test' -import { createManagers } from './utils.js' +import fsPromises from 'node:fs/promises' +import { createManager, createManagers } from './utils.js' import { MEMBER_ROLE_ID } from '../src/roles.js' import assert from 'node:assert/strict' import { pEvent } from 'p-event' @@ -12,6 +13,7 @@ import { UnknownInviteIDError, } from '../src/errors.js' import crypto from 'node:crypto' +import { temporaryDirectory } from 'tempy' test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t) @@ -55,6 +57,78 @@ test('invite over internet and join from URL', async (t) => { // TODO: Test that initial sync happened }) +test.only('invite over internet, close, reopen, and join from URL', async (t) => { + const dbFolder = temporaryDirectory() + const coreStorage = temporaryDirectory() + const directories = [dbFolder, coreStorage] + async function closeDirs() { + await Promise.all( + directories.map((dir) => + fsPromises.rm(dir, { + recursive: true, + }) + ) + ) + } + + t.after(closeDirs) + let invitor = createManager('invitor', t, { + coreStorage, + dbFolder, + }) + const invitee = createManager('invitee', t) + + await invitor.setDeviceInfo({ name: 'invitor', deviceType: 'desktop' }) + await invitee.setDeviceInfo({ name: 'invitee', deviceType: 'desktop' }) + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + let project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + await invitor.close() + invitor = createManager('invitor', t, { + coreStorage, + dbFolder, + }) + + project = await invitor.getProject(projectId) + + const pending = await project.$member.pendingInternetInvites() + + assert.deepEqual(pending, [url], 'Pending internet invites loaded on reload') + + const onInviteRedeemAttempt = pEvent( + project.$member, + 'internet-invite-redeemed', + { multiArgs: true, timeout: 5000 } + ) + + const onInvited = invitee.joinProjectOverInternet(url) + + const [deviceId, inviteId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + + assert.equal(deviceId, invitee.deviceId) + + // Show the user the device ID and their name and have them verify the invitee sees the same device ID + // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji + const reason = await project.$member.acceptRedeemedInvite(inviteId) + + assert.equal(reason, InviteResponse_Decision.ACCEPT) + + const gotProjectId = await onInvited + + assert.equal(gotProjectId, projectId, 'joined expected project') +}) + test('invite over internet errors if invitor deviceID is invalid', async (t) => { const managers = await createManagers(2, t) const [invitor, invitee] = managers From 3434e8c3fa8dd085b3db41fb1bdd33d65caa0c8c Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 19 May 2026 18:00:30 -0400 Subject: [PATCH 038/103] feat: Store pending invites at the mapeo manager level --- .../0005_massive_pretty_boy.sql} | 1 + drizzle/client/meta/0005_snapshot.json | 316 ++++ drizzle/client/meta/_journal.json | 7 + drizzle/project/meta/0004_snapshot.json | 1372 ----------------- drizzle/project/meta/_journal.json | 7 - src/mapeo-manager.js | 15 +- src/mapeo-project.js | 14 +- src/member-api.js | 22 +- src/pending-invites-api.js | 170 +- src/schema/client.js | 12 + src/schema/project.js | 13 +- test-types/data-types.ts | 5 +- test/member-api.js | 40 +- test/pending-invites-api.js | 659 +++++++- 14 files changed, 1161 insertions(+), 1492 deletions(-) rename drizzle/{project/0004_lazy_sway.sql => client/0005_massive_pretty_boy.sql} (88%) create mode 100644 drizzle/client/meta/0005_snapshot.json delete mode 100644 drizzle/project/meta/0004_snapshot.json diff --git a/drizzle/project/0004_lazy_sway.sql b/drizzle/client/0005_massive_pretty_boy.sql similarity index 88% rename from drizzle/project/0004_lazy_sway.sql rename to drizzle/client/0005_massive_pretty_boy.sql index 3f936c414..d5cf586ac 100644 --- a/drizzle/project/0004_lazy_sway.sql +++ b/drizzle/client/0005_massive_pretty_boy.sql @@ -1,5 +1,6 @@ CREATE TABLE `pendingInvites` ( `inviteId` text PRIMARY KEY NOT NULL, + `projectId` text KEY NOT NULL, `inviteIdBuffer` blob NOT NULL, `url` text NOT NULL, `roleId` text NOT NULL, diff --git a/drizzle/client/meta/0005_snapshot.json b/drizzle/client/meta/0005_snapshot.json new file mode 100644 index 000000000..30577d3e7 --- /dev/null +++ b/drizzle/client/meta/0005_snapshot.json @@ -0,0 +1,316 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4241faac-fbc2-4482-b712-125a963b69c0", + "prevId": "2dec4710-70ac-4857-b889-2bc3586695ee", + "tables": { + "deviceSettings": { + "name": "deviceSettings", + "columns": { + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceInfo": { + "name": "deviceInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isArchiveDevice": { + "name": "isArchiveDevice", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "deviceSettings_deviceId_unique": { + "name": "deviceSettings_deviceId_unique", + "columns": [ + "deviceId" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pendingInvites": { + "name": "pendingInvites", + "columns": { + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviteId": { + "name": "inviteId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inviteIdBuffer": { + "name": "inviteIdBuffer", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roleName": { + "name": "roleName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roleDescription": { + "name": "roleDescription", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteeDeviceId": { + "name": "inviteeDeviceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projectSettings_backlink": { + "name": "projectSettings_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projectKeys": { + "name": "projectKeys", + "columns": { + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projectPublicId": { + "name": "projectPublicId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectInviteId": { + "name": "projectInviteId", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keysCipher": { + "name": "keysCipher", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectInfo": { + "name": "projectInfo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"sendStats\":false}'" + }, + "hasLeftProject": { + "name": "hasLeftProject", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projectSettings": { + "name": "projectSettings", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projectDescription": { + "name": "projectDescription", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projectColor": { + "name": "projectColor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sendStats": { + "name": "sendStats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultPresets": { + "name": "defaultPresets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configMetadata": { + "name": "configMetadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/client/meta/_journal.json b/drizzle/client/meta/_journal.json index d6dd71db7..eedf7b7e1 100644 --- a/drizzle/client/meta/_journal.json +++ b/drizzle/client/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1758874242014, "tag": "0004_glorious_shape", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779209357699, + "tag": "0005_massive_pretty_boy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/project/meta/0004_snapshot.json b/drizzle/project/meta/0004_snapshot.json deleted file mode 100644 index 6011c55e1..000000000 --- a/drizzle/project/meta/0004_snapshot.json +++ /dev/null @@ -1,1372 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "48907b38-2a10-478e-815c-60de03d47213", - "prevId": "d36d93d4-5452-4a18-ba67-3d9d3c8ad82b", - "tables": { - "coreOwnership_backlink": { - "name": "coreOwnership_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "coreOwnership": { - "name": "coreOwnership", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "authCoreId": { - "name": "authCoreId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "configCoreId": { - "name": "configCoreId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "dataCoreId": { - "name": "dataCoreId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "blobCoreId": { - "name": "blobCoreId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "blobIndexCoreId": { - "name": "blobIndexCoreId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "cores": { - "name": "cores", - "columns": { - "publicKey": { - "name": "publicKey", - "type": "blob", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "deviceInfo_backlink": { - "name": "deviceInfo_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "deviceInfo": { - "name": "deviceInfo", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deviceType": { - "name": "deviceType", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "selfHostedServerDetails": { - "name": "selfHostedServerDetails", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "field_backlink": { - "name": "field_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "field": { - "name": "field", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tagKey": { - "name": "tagKey", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "appearance": { - "name": "appearance", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "snakeCase": { - "name": "snakeCase", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "options": { - "name": "options", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "universal": { - "name": "universal", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "placeholder": { - "name": "placeholder", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "helperText": { - "name": "helperText", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "icon_backlink": { - "name": "icon_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "icon": { - "name": "icon", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "variants": { - "name": "variants", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "observation_backlink": { - "name": "observation_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "observation": { - "name": "observation", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lat": { - "name": "lat", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lon": { - "name": "lon", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "attachments": { - "name": "attachments", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tags": { - "name": "tags", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "presetRef": { - "name": "presetRef", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pendingInvites": { - "name": "pendingInvites", - "columns": { - "inviteId": { - "name": "inviteId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "inviteIdBuffer": { - "name": "inviteIdBuffer", - "type": "blob", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "roleId": { - "name": "roleId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "roleName": { - "name": "roleName", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "roleDescription": { - "name": "roleDescription", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "inviteeDeviceId": { - "name": "inviteeDeviceId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "preset_backlink": { - "name": "preset_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "preset": { - "name": "preset", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "geometry": { - "name": "geometry", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tags": { - "name": "tags", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "addTags": { - "name": "addTags", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "removeTags": { - "name": "removeTags", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fieldRefs": { - "name": "fieldRefs", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "iconRef": { - "name": "iconRef", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "terms": { - "name": "terms", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "remoteDetectionAlert_backlink": { - "name": "remoteDetectionAlert_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "remoteDetectionAlert": { - "name": "remoteDetectionAlert", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "detectionDateStart": { - "name": "detectionDateStart", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "detectionDateEnd": { - "name": "detectionDateEnd", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sourceId": { - "name": "sourceId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "geometry": { - "name": "geometry", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "role_backlink": { - "name": "role_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "role": { - "name": "role", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "roleId": { - "name": "roleId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fromIndex": { - "name": "fromIndex", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "track_backlink": { - "name": "track_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "track": { - "name": "track", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "locations": { - "name": "locations", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "observationRefs": { - "name": "observationRefs", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tags": { - "name": "tags", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "presetRef": { - "name": "presetRef", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "translation_backlink": { - "name": "translation_backlink", - "columns": { - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "translation": { - "name": "translation", - "columns": { - "docId": { - "name": "docId", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "versionId": { - "name": "versionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "originalVersionId": { - "name": "originalVersionId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schemaName": { - "name": "schemaName", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "links": { - "name": "links", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted": { - "name": "deleted", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "docRef": { - "name": "docRef", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "docRefType": { - "name": "docRefType", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "propertyRef": { - "name": "propertyRef", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "languageCode": { - "name": "languageCode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "regionCode": { - "name": "regionCode", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "forks": { - "name": "forks", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/drizzle/project/meta/_journal.json b/drizzle/project/meta/_journal.json index 30788b70b..0d9d51712 100644 --- a/drizzle/project/meta/_journal.json +++ b/drizzle/project/meta/_journal.json @@ -29,13 +29,6 @@ "when": 1761590068314, "tag": "0003_lying_piledriver", "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1776276151106, - "tag": "0004_lazy_sway", - "breakpoints": true } ] } \ No newline at end of file diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 25e6db956..1b5436aa3 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -68,6 +68,7 @@ import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' import { parseInviteURL } from './member-api.js' import { pEvent } from 'p-event' +import { PendingInvitesApi } from './pending-invites-api.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ @@ -160,6 +161,7 @@ export class MapeoManager extends TypedEmitter { #projectMigrationsFolder #deviceId #localPeers + #pendingInvitesApi #invite #fastify #localDiscovery @@ -304,6 +306,14 @@ export class MapeoManager extends TypedEmitter { logger, }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) + + this.#pendingInvitesApi = new PendingInvitesApi( + this.#db, + (shouldListen) => { + if (shouldListen) return this.#remoteDiscovery.start() + else return this.#remoteDiscovery.stop() + } + ) } get deviceId() { @@ -632,6 +642,7 @@ export class MapeoManager extends TypedEmitter { sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, + pendingInvitesApi: this.#pendingInvitesApi, logger: this.#loggerBase, getMediaBaseUrl: this.#getMediaBaseUrl.bind(this), isArchiveDevice, @@ -643,10 +654,6 @@ export class MapeoManager extends TypedEmitter { .where(eq(projectKeysTable.projectId, projectId)) .get()?.projectInfo }, - setShouldListenOverInternet: (shouldListen) => { - if (shouldListen) return this.#remoteDiscovery.start() - else return this.#remoteDiscovery.stop() - }, markInternetPeerAsTrusted: async (deviceId) => { try { await this.#localPeers.trustPeer(deviceId) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 192217531..30b53eddc 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -49,7 +49,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' -import { PendingInvitesApi } from './pending-invites-api.js' +import { PendingInvitesApiForProject } from './pending-invites-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -177,9 +177,9 @@ export class MapeoProject extends ReadyResource { * @param {(mediaType: 'blobs' | 'icons') => Promise} opts.getMediaBaseUrl * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {import('./local-peers.js').LocalPeers} opts.localPeers + * @param {import('./pending-invites-api.js').PendingInvitesApi} opts.pendingInvitesApi * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo - * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {Logger} [opts.logger] @@ -198,10 +198,10 @@ export class MapeoProject extends ReadyResource { getMediaBaseUrl, makeWebsocket = (url) => new WebSocket(url), localPeers, + pendingInvitesApi, logger, isArchiveDevice, getFallbackProjectInfo, - setShouldListenOverInternet, markInternetPeerAsTrusted, disconnectFromPeer, getSwarmPublicKey, @@ -252,8 +252,6 @@ export class MapeoProject extends ReadyResource { remoteDetectionAlertTable, ] - const pendingInvitesApi = new PendingInvitesApi(db) - ///////// 2. Wipe data if we need to re-index if (reindex) { @@ -431,7 +429,10 @@ export class MapeoProject extends ReadyResource { encryptionKeys, projectKey, rpc: localPeers, - pendingInvitesApi, + pendingInvitesApi: new PendingInvitesApiForProject( + this.#projectId, + pendingInvitesApi + ), getSwarmPublicKey: this.#getSwarmPublicKey, makeWebsocket, getReplicationStream, @@ -455,7 +456,6 @@ export class MapeoProject extends ReadyResource { deviceInfo ) }, - setShouldListenOverInternet, markInternetPeerAsTrusted, disconnectFromPeer, logger: this.#l, diff --git a/src/member-api.js b/src/member-api.js index 906068f1b..1993f6d0e 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -72,7 +72,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ -/** @import { PendingInvitesApi } from './pending-invites-api.js' */ +/** @import { PendingInvitesApiForProject } from './pending-invites-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -136,7 +136,6 @@ export class MemberApi extends ReadyResource { #makeWebsocket #getReplicationStream #waitForInitialSyncWithPeer - #setShouldListenOverInternet #markInternetPeerAsTrusted #disconnectFromPeer #getProjectSettings @@ -160,11 +159,10 @@ export class MemberApi extends ReadyResource { * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc - * @param {Pick} opts.pendingInvitesApi + * @param {Pick} opts.pendingInvitesApi * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer - * @param {(shouldListen: boolean) => Promise} opts.setShouldListenOverInternet * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {() => Promise} opts.getProjectSettings @@ -182,7 +180,6 @@ export class MemberApi extends ReadyResource { makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, - setShouldListenOverInternet, markInternetPeerAsTrusted, disconnectFromPeer, getProjectSettings, @@ -202,7 +199,6 @@ export class MemberApi extends ReadyResource { this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer - this.#setShouldListenOverInternet = setShouldListenOverInternet this.#markInternetPeerAsTrusted = markInternetPeerAsTrusted this.#disconnectFromPeer = disconnectFromPeer this.#getProjectSettings = getProjectSettings @@ -232,11 +228,6 @@ export class MemberApi extends ReadyResource { inviteeDeviceId: row.inviteeDeviceId ?? undefined, }) } - - // Enable listening if there are pending invites - if (this.#pendingInvitesOverInternet.size > 0) { - await this.#setShouldListenOverInternet(true) - } } /** @@ -264,10 +255,6 @@ export class MemberApi extends ReadyResource { opts, }) - if (this.#pendingInvitesOverInternet.size === 1) { - await this.#setShouldListenOverInternet(true) - } - return url } @@ -283,7 +270,6 @@ export class MemberApi extends ReadyResource { } this.#pendingInvitesOverInternet.clear() await this.#pendingInvitesApi.deleteAll() - await this.#setShouldListenOverInternet(false) return } const { inviteIdString } = parseInviteURL(url) @@ -301,10 +287,6 @@ export class MemberApi extends ReadyResource { this.#pendingInvitesOverInternet.delete(inviteIdString) this.emit('internet-invite-cancelled', inviteIdString) await this.#pendingInvitesApi.delete(inviteIdString) - - if (this.#pendingInvitesOverInternet.size === 0) { - await this.#setShouldListenOverInternet(false) - } } /** diff --git a/src/pending-invites-api.js b/src/pending-invites-api.js index ac0acbb3b..147c0a988 100644 --- a/src/pending-invites-api.js +++ b/src/pending-invites-api.js @@ -1,5 +1,5 @@ -import { eq, sql } from 'drizzle-orm' -import { pendingInvitesTable } from './schema/project.js' +import { and, eq, sql } from 'drizzle-orm' +import { pendingInvitesTable } from './schema/client.js' import { isRoleIdForNewInvite } from './roles.js' import { ensureKnownError, @@ -24,6 +24,7 @@ import { /** * @typedef {object} PendingInviteCreate + * @property {string} projectId * @property {string} inviteId * @property {Buffer} inviteIdBuffer * @property {string} url @@ -35,6 +36,77 @@ import { * @property {string} inviteeDeviceId */ +export class PendingInvitesApiForProject { + #projectId + #pendingInvitesApi + + /** + * + * @param {string} projectId + * @param {PendingInvitesApi} pendingInvitesApi + */ + constructor(projectId, pendingInvitesApi) { + this.#projectId = projectId + this.#pendingInvitesApi = pendingInvitesApi + } + + /** + * Create a new pending invite record + * @param {Omit} data + * @returns {Promise} + */ + async create(data) { + return this.#pendingInvitesApi.create({ + ...data, + projectId: this.#projectId, + }) + } + + /** + * Get a pending invite by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + return this.#pendingInvitesApi.getById(inviteId, this.#projectId) + } + + /** + * Get all pending invites for the project + * @returns {Promise} + */ + async getAll() { + return this.#pendingInvitesApi.getAllForProject(this.#projectId) + } + + /** + * Update a pending invite (e.g., set invitee device ID when redeemed) + * @param {string} inviteId + * @param {PendingInviteUpdate} updates + * @returns {Promise} + */ + async update(inviteId, updates) { + return this.#pendingInvitesApi.update(inviteId, this.#projectId, updates) + } + + /** + * Delete a pending invite + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + return this.#pendingInvitesApi.delete(inviteId) + } + + /** + * Delete all pending invites for the project + * @returns {Promise} + */ + async deleteAll() { + return this.#pendingInvitesApi.deleteAllFrom(this.#projectId) + } +} + /** * API for CRUD operations on pending invites over internet */ @@ -42,20 +114,56 @@ export class PendingInvitesApi { /** @type {BetterSQLite3Database} */ #db #sql + /** @type {(shouldListen: boolean) => Promise} */ + #setShouldListenOverInternet /** * @param {BetterSQLite3Database} db Drizzle database instance + * @param {(shouldListen: boolean) => Promise} setShouldListenOverInternet */ - constructor(db) { + constructor(db, setShouldListenOverInternet) { this.#db = db + this.#setShouldListenOverInternet = setShouldListenOverInternet this.#sql = { getById: db .select() .from(pendingInvitesTable) - .where(eq(pendingInvitesTable.inviteId, sql.placeholder('inviteId'))) + .where( + and( + eq(pendingInvitesTable.inviteId, sql.placeholder('inviteId')), + eq(pendingInvitesTable.projectId, sql.placeholder('projectId')) + ) + ) .limit(1) .prepare(), getAll: db.select().from(pendingInvitesTable).prepare(), + getAllForProject: db + .select() + .from(pendingInvitesTable) + .where(eq(pendingInvitesTable.projectId, sql.placeholder('projectId'))) + .prepare(), + } + + // Run initial check: enable listening if there are existing invites + const count = this.#sql.getAll.all().length + if (count > 0) { + setShouldListenOverInternet(true) + } + } + + /** + * Check if we should be listening over the internet based on + * total invite count across all projects. + * Only calls the callback when transitioning from 0 to 1 (true) + * or from 1 to 0 (false). + * @param {boolean} direction - true if we just added, false if we just removed + */ + async #checkSetShouldListenOverInternet(direction) { + const count = this.#sql.getAll.all().length + if (direction && count === 1) { + await this.#setShouldListenOverInternet(true) + } else if (!direction && count === 0) { + await this.#setShouldListenOverInternet(false) } } @@ -67,6 +175,7 @@ export class PendingInvitesApi { async create(data) { try { await this.#db.insert(pendingInvitesTable).values({ + projectId: data.projectId, inviteId: data.inviteId, inviteIdBuffer: data.inviteIdBuffer, url: data.url, @@ -75,6 +184,8 @@ export class PendingInvitesApi { roleDescription: data.opts.roleDescription, createdAt: Date.now(), }) + + await this.#checkSetShouldListenOverInternet(true) } catch (err) { if (getErrorCode(err) === 'SQLITE_CONSTRAINT_PRIMARYKEY') { throw new PendingInviteAlreadyExistsError({ inviteId: data.inviteId }) @@ -86,10 +197,11 @@ export class PendingInvitesApi { /** * Get a pending invite by invite ID * @param {string} inviteId + * @param {string} projectId * @returns {Promise} */ - async getById(inviteId) { - const row = this.#sql.getById.get({ inviteId }) + async getById(inviteId, projectId) { + const row = this.#sql.getById.get({ inviteId, projectId }) if (!row) return undefined if (!isRoleIdForNewInvite(row.roleId)) { @@ -120,17 +232,44 @@ export class PendingInvitesApi { }) } + /** + * Get all pending invites + * @param {string} projectId + * @returns {Promise} + */ + async getAllForProject(projectId) { + const rows = this.#sql.getAllForProject.all({ + projectId, + }) + + return rows.map((row) => { + if (!isRoleIdForNewInvite(row.roleId)) { + throw new Error(`Invalid roleId in database: ${row.roleId}`) + } + return /** @type {PendingInviteRecord} */ ({ + ...row, + inviteeDeviceId: row.inviteeDeviceId ?? undefined, + }) + }) + } + /** * Update a pending invite (e.g., set invitee device ID when redeemed) * @param {string} inviteId + * @param {string} projectId * @param {PendingInviteUpdate} updates * @returns {Promise} */ - async update(inviteId, updates) { + async update(inviteId, projectId, updates) { await this.#db .update(pendingInvitesTable) .set(updates) - .where(eq(pendingInvitesTable.inviteId, inviteId)) + .where( + and( + eq(pendingInvitesTable.inviteId, inviteId), + eq(pendingInvitesTable.projectId, projectId) + ) + ) } /** @@ -142,6 +281,8 @@ export class PendingInvitesApi { await this.#db .delete(pendingInvitesTable) .where(eq(pendingInvitesTable.inviteId, inviteId)) + + await this.#checkSetShouldListenOverInternet(false) } /** @@ -150,5 +291,18 @@ export class PendingInvitesApi { */ async deleteAll() { await this.#db.delete(pendingInvitesTable) + await this.#setShouldListenOverInternet(false) + } + + /** + * Delete all pending invites in a specific project + * @param {string} projectId + */ + async deleteAllFrom(projectId) { + await this.#db + .delete(pendingInvitesTable) + .where(eq(pendingInvitesTable.projectId, projectId)) + + await this.#checkSetShouldListenOverInternet(false) } } diff --git a/src/schema/client.js b/src/schema/client.js index 91a12c90c..885105bfd 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -49,3 +49,15 @@ export const deviceSettingsTable = sqliteTable('deviceSettings', { (text('deviceInfo', { mode: 'json' })), isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }), }) + +export const pendingInvitesTable = sqliteTable('pendingInvites', { + inviteId: text('inviteId').notNull().primaryKey(), + projectId: text('projectId').notNull(), + inviteIdBuffer: blob('inviteIdBuffer', { mode: 'buffer' }).notNull(), + url: text('url').notNull(), + roleId: text('roleId').notNull(), + roleName: text('roleName'), + roleDescription: text('roleDescription'), + inviteeDeviceId: text('inviteeDeviceId'), + createdAt: int('createdAt').notNull(), +}) diff --git a/src/schema/project.js b/src/schema/project.js index 871bd3c86..a4bacffd7 100644 --- a/src/schema/project.js +++ b/src/schema/project.js @@ -1,6 +1,6 @@ // These schemas are all in a "project" database. Each project in Mapeo has an // independent "project" database. -import { blob, sqliteTable, text, int } from 'drizzle-orm/sqlite-core' +import { blob, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { dereferencedDocSchemas as schemas } from '@comapeo/schema' import { NAMESPACES } from '../constants.js' import { @@ -36,14 +36,3 @@ export const coresTable = sqliteTable('cores', { publicKey: blob('publicKey', { mode: 'buffer' }).notNull(), namespace: text('namespace', { enum: NAMESPACES }).notNull(), }) - -export const pendingInvitesTable = sqliteTable('pendingInvites', { - inviteId: text('inviteId').notNull().primaryKey(), - inviteIdBuffer: blob('inviteIdBuffer', { mode: 'buffer' }).notNull(), - url: text('url').notNull(), - roleId: text('roleId').notNull(), - roleName: text('roleName'), - roleDescription: text('roleDescription'), - inviteeDeviceId: text('inviteeDeviceId'), - createdAt: int('createdAt').notNull(), -}) diff --git a/test-types/data-types.ts b/test-types/data-types.ts index 5a6326b32..9891f3140 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -19,6 +19,7 @@ import { DerivedDocFields } from '../dist/datatype/index.js' import { projectSettingsTable } from '../dist/schema/client.js' import { LocalPeers } from '../dist/local-peers.js' import { Expect, type Equal } from './utils.js' +import { PendingInvitesApi } from '../dist/pending-invites-api.js' type ObservationWithDerivedDocFields = Observation & DerivedDocFields type PresetWithDerivedDocFields = Preset & DerivedDocFields @@ -45,8 +46,10 @@ const mapeoProject = new MapeoProject({ getMediaBaseUrl: async (mediaType: 'blobs' | 'icons') => `http://127.0.0.1:8080/${mediaType}`, localPeers: new LocalPeers(), + pendingInvitesApi: new PendingInvitesApi(drizzle(sqlite), () => + Promise.resolve() + ), getFallbackProjectInfo: () => ({ sendStats: false }), - setShouldListenOverInternet: (_shouldListen) => Promise.resolve(), markInternetPeerAsTrusted: async (_deviceId) => Promise.resolve(true), disconnectFromPeer: async (_deviceId) => Promise.resolve(), }) diff --git a/test/member-api.js b/test/member-api.js index 4c0290fc3..95adc731c 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -51,26 +51,6 @@ test('List pending invites over internet', async () => { assert.ok(persistedUrls.includes(url2), 'url2 is persisted') }) -test('setShouldListenOverInternet called once for multiple invites', async () => { - let callCount = 0 - const { member } = setup({ - setShouldListenOverInternet: async (shouldListen) => { - assert(shouldListen) - callCount++ - }, - }) - - await member.inviteOverInternet({ - roleId: MEMBER_ROLE_ID, - }) - - await member.inviteOverInternet({ - roleId: MEMBER_ROLE_ID, - }) - - assert.equal(callCount, 1, 'only set once') -}) - test('Cancel invite over internet requests', async () => { const { member, pendingInvitesApi } = setup({}) @@ -124,7 +104,7 @@ test('Cancel invite over internet requests', async () => { }) test('Pending invites are loaded from persistence on ready', async () => { - const pendingInvitesApi = new MockPendingInvitesApi() + const pendingInvitesApi = new MockPendingInvitesApiForProject() const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') const deviceId = randomBytes(32).toString('hex') @@ -141,20 +121,13 @@ test('Pending invites are loaded from persistence on ready', async () => { }, }) - let didStartInternet = false - const { member } = setup({ pendingInvitesApi, - setShouldListenOverInternet: async (shouldStart) => { - didStartInternet = shouldStart - }, }) // Wait for member API to be ready (loads pending invites) await member.ready() - assert.ok(didStartInternet, 'Did start listening on internet') - // Verify the pending invite was loaded const pending = await member.pendingInternetInvites() assert.deepEqual(pending, [url], 'Pending invite loaded from persistence') @@ -199,13 +172,13 @@ class MockRoles { /** * In-memory mock of PendingInvitesApi for testing */ -class MockPendingInvitesApi { +class MockPendingInvitesApiForProject { /** @type {Map} */ #invites = new Map() /** * Create a new pending invite record - * @param {PendingInviteCreate} data + * @param {Omit} data * @returns {Promise} */ async create(data) { @@ -279,18 +252,16 @@ class MockPendingInvitesApi { * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => import('../src/types.js').ReplicationStream} [opts.getReplicationStream] * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} [opts.waitForInitialSyncWithPeer] - * @param {(shouldListen: boolean) => Promise} [opts.setShouldListenOverInternet] * @param {(deviceId: string) => Promise} [opts.markInternetPeerAsTrusted] * @param {(deviceId: string) => Promise} [opts.disconnectFromPeer] * @param {() => Promise} [opts.getProjectSettings] * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] - * @param {MockPendingInvitesApi} [opts.pendingInvitesApi] + * @param {MockPendingInvitesApiForProject} [opts.pendingInvitesApi] * @returns */ function setup({ rootKey = Buffer.alloc(16, 1), - setShouldListenOverInternet = () => Promise.resolve(), getProjectSettings = () => Promise.resolve({ name: 'example', sendStats: false }), getDeviceInfo = () => Promise.reject(new Error('Not implemented')), @@ -304,7 +275,7 @@ function setup({ throw new Error('Not implemented') }, markInternetPeerAsTrusted = () => Promise.resolve(true), - pendingInvitesApi = new MockPendingInvitesApi(), + pendingInvitesApi = new MockPendingInvitesApiForProject(), } = {}) { const keyManager = new KeyManager(rootKey) @@ -328,7 +299,6 @@ function setup({ getReplicationStream, waitForInitialSyncWithPeer, disconnectFromPeer, - setShouldListenOverInternet, markInternetPeerAsTrusted, getProjectSettings, getDeviceInfo, diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index 36e8a9afe..93d3ae209 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -2,33 +2,91 @@ import test from 'node:test' import assert from 'node:assert/strict' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' + import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { randomBytes } from 'node:crypto' -import { PendingInvitesApi } from '../src/pending-invites-api.js' +import { + PendingInvitesApi, + PendingInvitesApiForProject, +} from '../src/pending-invites-api.js' import { MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, BLOCKED_ROLE_ID, } from '../src/roles.js' -import { pendingInvitesTable } from '../src/schema/project.js' +import { pendingInvitesTable } from '../src/schema/client.js' import { PendingInviteAlreadyExistsError } from '../src/errors.js' /** @import {BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ +const PROJECT_ID = 'test-project-id' + +/** + * @typedef {object} TestEnv + * @property {PendingInvitesApi} api + * @property {PendingInvitesApiForProject} projectApi + * @property {BetterSQLite3Database} db + * @property {(shouldListen: boolean) => Promise} setShouldListenOverInternet + * @property {() => boolean[]} getShouldListenOverInternet - Get ordered call log of setShouldListenOverInternet + */ + /** - * @returns {{api:PendingInvitesApi, db:BetterSQLite3Database}} + * @typedef {object} SeedPendingInvite + * @property {string} projectId + * @property {string} inviteId + * @property {Buffer} inviteIdBuffer + * @property {string} url + * @property {import('../src/roles.js').RoleIdForNewInvite} roleId */ -function setup() { + +/** + * @param {{ seedPendingInvites?: SeedPendingInvite[] }} [opts] + * @returns {TestEnv} + */ +function setup({ seedPendingInvites = [] } = {}) { const sqlite = new Database(':memory:') const db = drizzle(sqlite) - migrate(db, { - migrationsFolder: new URL('../drizzle/project', import.meta.url).pathname, + migrationsFolder: new URL('../drizzle/client', import.meta.url).pathname, }) - const api = new PendingInvitesApi(db) - - return { api, db } + // Seed pending invites before creating the API + for (const seed of seedPendingInvites) { + db.insert(pendingInvitesTable) + .values({ + projectId: seed.projectId, + inviteId: seed.inviteId, + inviteIdBuffer: seed.inviteIdBuffer, + url: seed.url, + roleId: seed.roleId, + createdAt: Date.now(), + }) + .run() + } + + /** @type {boolean[]} */ + const shouldListenCalls = [] + /** + * @param {boolean} value + */ + async function setShouldListenOverInternet(value) { + shouldListenCalls.push(value) + } + + function getShouldListenOverInternet() { + return shouldListenCalls + } + + const api = new PendingInvitesApi(db, setShouldListenOverInternet) + const projectApi = new PendingInvitesApiForProject(PROJECT_ID, api) + + return { + api, + projectApi, + db, + setShouldListenOverInternet, + getShouldListenOverInternet, + } } test('create() - basic functionality', async () => { @@ -39,6 +97,7 @@ test('create() - basic functionality', async () => { const url = 'https://example.com/invite/abc123' await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url, @@ -49,7 +108,7 @@ test('create() - basic functionality', async () => { }, }) - const retrieved = await api.getById(inviteIdString) + const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert(retrieved, 'invite can be retrieved') assert.equal(retrieved.inviteId, inviteIdString) assert.equal(retrieved.url, url) @@ -68,6 +127,7 @@ test('create() - duplicate inviteId throws', async () => { const url2 = 'https://example.com/invite/second' await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: url1, @@ -78,6 +138,7 @@ test('create() - duplicate inviteId throws', async () => { assert.rejects( api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: url2, @@ -87,17 +148,39 @@ test('create() - duplicate inviteId throws', async () => { }, }), { code: PendingInviteAlreadyExistsError.code }, - 'Second reate throws an error' + 'Second create throws an error' ) }) test('getById() - non-existent invite', async () => { const { api } = setup() - const result = await api.getById('non-existent-id') + const result = await api.getById('non-existent-id', PROJECT_ID) assert.equal(result, undefined, 'returns undefined for non-existent invite') }) +test('getById() - invite from different project not found', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const result = await api.getById(inviteIdString, 'other-project-id') + assert.equal( + result, + undefined, + 'returns undefined for invite in different project' + ) +}) + test('getAll() - empty database', async () => { const { api } = setup() @@ -113,6 +196,7 @@ test('getAll() - multiple invites', async () => { const invite3 = randomBytes(32) await api.create({ + projectId: PROJECT_ID, inviteId: invite1.toString('hex'), inviteIdBuffer: invite1, url: 'https://example.com/1', @@ -120,6 +204,7 @@ test('getAll() - multiple invites', async () => { }) await api.create({ + projectId: PROJECT_ID, inviteId: invite2.toString('hex'), inviteIdBuffer: invite2, url: 'https://example.com/2', @@ -127,6 +212,7 @@ test('getAll() - multiple invites', async () => { }) await api.create({ + projectId: PROJECT_ID, inviteId: invite3.toString('hex'), inviteIdBuffer: invite3, url: 'https://example.com/3', @@ -142,6 +228,48 @@ test('getAll() - multiple invites', async () => { assert.ok(inviteIds.includes(invite3.toString('hex'))) }) +test('getAllForProject() - returns only invites for the project', async () => { + const { api } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + + await api.create({ + projectId: PROJECT_ID, + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.create({ + projectId: 'other-project-id', + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + const invites = await api.getAllForProject(PROJECT_ID) + assert.equal( + invites.length, + 1, + 'returns only invites for the specified project' + ) + assert.equal(invites[0].inviteId, invite1.toString('hex')) +}) + +test('getAllForProject() - empty for project with no invites', async () => { + const { api } = setup() + + const invites = await api.getAllForProject(PROJECT_ID) + assert.deepEqual( + invites, + [], + 'returns empty array when project has no invites' + ) +}) + test('update() - set inviteeDeviceId', async () => { const { api } = setup() @@ -149,29 +277,56 @@ test('update() - set inviteeDeviceId', async () => { const inviteIdString = inviteId.toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', opts: { roleId: MEMBER_ROLE_ID }, }) - const beforeUpdate = await api.getById(inviteIdString) + const beforeUpdate = await api.getById(inviteIdString, PROJECT_ID) assert.equal(beforeUpdate?.inviteeDeviceId, undefined) const inviteeDeviceId = randomBytes(32).toString('hex') - await api.update(inviteIdString, { inviteeDeviceId }) + await api.update(inviteIdString, PROJECT_ID, { inviteeDeviceId }) - const afterUpdate = await api.getById(inviteIdString) + const afterUpdate = await api.getById(inviteIdString, PROJECT_ID) assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) }) +test('update() - scoped to project', async () => { + const { api } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const inviteeDeviceId = randomBytes(32).toString('hex') + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Update with a different project ID should be a no-op + await api.update(inviteIdString, 'other-project-id', { inviteeDeviceId }) + + const afterUpdate = await api.getById(inviteIdString, PROJECT_ID) + assert.equal( + afterUpdate?.inviteeDeviceId, + undefined, + 'invite not updated with wrong project ID' + ) +}) + test('update() - update non-existent invite', async () => { const { api } = setup() const inviteeDeviceId = randomBytes(32).toString('hex') - await api.update('non-existent-id', { inviteeDeviceId }) + await api.update('non-existent-id', PROJECT_ID, { inviteeDeviceId }) - const result = await api.getById('non-existent-id') + const result = await api.getById('non-existent-id', PROJECT_ID) assert.equal(result, undefined, 'no-op for non-existent invite') }) @@ -182,6 +337,7 @@ test('delete() - single invite', async () => { const inviteIdString = inviteId.toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', @@ -190,7 +346,7 @@ test('delete() - single invite', async () => { await api.delete(inviteIdString) - const retrieved = await api.getById(inviteIdString) + const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert.equal(retrieved, undefined, 'invite deleted') const all = await api.getAll() @@ -202,7 +358,7 @@ test('delete() - non-existent invite', async () => { await api.delete('non-existent-id') - const result = await api.getById('non-existent-id') + const result = await api.getById('non-existent-id', PROJECT_ID) assert.equal(result, undefined, 'no-op for non-existent invite') }) @@ -214,6 +370,7 @@ test('deleteAll() - clear all invites', async () => { const invite3 = randomBytes(32) await api.create({ + projectId: PROJECT_ID, inviteId: invite1.toString('hex'), inviteIdBuffer: invite1, url: 'https://example.com/1', @@ -221,6 +378,7 @@ test('deleteAll() - clear all invites', async () => { }) await api.create({ + projectId: PROJECT_ID, inviteId: invite2.toString('hex'), inviteIdBuffer: invite2, url: 'https://example.com/2', @@ -228,6 +386,7 @@ test('deleteAll() - clear all invites', async () => { }) await api.create({ + projectId: PROJECT_ID, inviteId: invite3.toString('hex'), inviteIdBuffer: invite3, url: 'https://example.com/3', @@ -249,6 +408,43 @@ test('deleteAll() - empty database', async () => { assert.equal(all.length, 0, 'no-op on empty database') }) +test('deleteAllFrom() - clears invites only for the project', async () => { + const { api } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + + await api.create({ + projectId: PROJECT_ID, + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.create({ + projectId: 'other-project-id', + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await api.deleteAllFrom(PROJECT_ID) + + const all = await api.getAll() + assert.equal(all.length, 1, 'only other project invites remain') + assert.equal(all[0].inviteId, invite2.toString('hex')) +}) + +test('deleteAllFrom() - empty for project with no invites', async () => { + const { api } = setup() + + await api.deleteAllFrom(PROJECT_ID) + const all = await api.getAll() + assert.equal(all.length, 0, 'no-op on empty project') +}) + test('Role ID validation on read', async () => { const { api, db } = setup() @@ -257,6 +453,7 @@ test('Role ID validation on read', async () => { // Directly insert an invalid roleId into the database using Drizzle, bypassing the API await db.insert(pendingInvitesTable).values({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', @@ -265,15 +462,15 @@ test('Role ID validation on read', async () => { }) await assert.rejects( - async () => await api.getById(inviteIdString), + async () => await api.getById(inviteIdString, PROJECT_ID), /Invalid roleId in database/, 'throws error for invalid roleId on getById' ) await assert.rejects( - async () => await api.getAll(), + async () => await api.getAllForProject(PROJECT_ID), /Invalid roleId in database/, - 'throws error for invalid roleId on getAll' + 'throws error for invalid roleId on getAllForProject' ) }) @@ -284,13 +481,14 @@ test('Buffer persistence', async () => { const inviteIdString = inviteId.toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', opts: { roleId: MEMBER_ROLE_ID }, }) - const retrieved = await api.getById(inviteIdString) + const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert.ok(retrieved?.inviteIdBuffer.equals(inviteId), 'buffer is identical') }) @@ -302,6 +500,7 @@ test('Timestamp verification', async () => { const inviteIdString = inviteId.toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', @@ -309,7 +508,7 @@ test('Timestamp verification', async () => { }) const afterCreate = Date.now() - const retrieved = await api.getById(inviteIdString) + const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert.ok(retrieved, 'able to retrieve') @@ -326,6 +525,7 @@ test('Optional fields', async () => { const inviteIdString = inviteId.toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', @@ -335,7 +535,7 @@ test('Optional fields', async () => { }, }) - const retrieved = await api.getById(inviteIdString) + const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert.equal( retrieved?.roleName, null, @@ -356,15 +556,422 @@ test('create() and getAll() with inviteeDeviceId already set', async () => { const inviteeDeviceId = randomBytes(32).toString('hex') await api.create({ + projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, url: 'https://example.com/invite', opts: { roleId: MEMBER_ROLE_ID }, }) - await api.update(inviteIdString, { inviteeDeviceId }) + await api.update(inviteIdString, PROJECT_ID, { inviteeDeviceId }) const all = await api.getAll() assert.equal(all.length, 1) assert.equal(all[0].inviteeDeviceId, inviteeDeviceId) }) + +// Tests for PendingInvitesApiForProject (scoped wrapper) + +test('PendingInvitesApiForProject - create() auto-injects projectId', async () => { + const { projectApi } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const url = 'https://example.com/invite/abc123' + + await projectApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url, + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const retrieved = await projectApi.getById(inviteIdString) + assert(retrieved, 'invite can be retrieved') + assert.equal(retrieved.inviteId, inviteIdString) + assert.equal(retrieved.url, url) +}) + +test('PendingInvitesApiForProject - getAll() returns scoped invites', async () => { + const { api, projectApi } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + + await projectApi.create({ + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Create an invite for a different project directly + await api.create({ + projectId: 'other-project-id', + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + const invites = await projectApi.getAll() + assert.equal(invites.length, 1, 'returns only scoped invites') + assert.equal(invites[0].inviteId, invite1.toString('hex')) +}) + +test('PendingInvitesApiForProject - update() auto-scopes to project', async () => { + const { projectApi } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await projectApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const inviteeDeviceId = randomBytes(32).toString('hex') + await projectApi.update(inviteIdString, { inviteeDeviceId }) + + const afterUpdate = await projectApi.getById(inviteIdString) + assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) +}) + +test('PendingInvitesApiForProject - delete() removes invite', async () => { + const { projectApi } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await projectApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await projectApi.delete(inviteIdString) + + const retrieved = await projectApi.getById(inviteIdString) + assert.equal(retrieved, undefined, 'invite deleted') +}) + +test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', async () => { + const { api, projectApi } = setup() + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + + await projectApi.create({ + inviteId: invite1.toString('hex'), + inviteIdBuffer: invite1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Create an invite for a different project directly + await api.create({ + projectId: 'other-project-id', + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await projectApi.deleteAll() + + const all = await api.getAll() + assert.equal(all.length, 1, 'only other project invite remains') + assert.equal(all[0].inviteId, invite2.toString('hex')) +}) + +test('PendingInvitesApiForProject - getById() returns scoped invite', async () => { + const { projectApi } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await projectApi.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const retrieved = await projectApi.getById(inviteIdString) + assert(retrieved, 'invite can be retrieved') + assert.equal(retrieved.inviteId, inviteIdString) +}) + +test('PendingInvitesApiForProject - getById() returns undefined for invite in other project', async () => { + const { api, projectApi } = setup() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + // Create an invite in a different project directly + await api.create({ + projectId: 'other-project-id', + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const retrieved = await projectApi.getById(inviteIdString) + assert.equal( + retrieved, + undefined, + 'returns undefined for invite in other project' + ) +}) + +test('PendingInvitesApiForProject - getById() returns undefined for non-existent invite', async () => { + const { projectApi } = setup() + + const result = await projectApi.getById('non-existent-id') + assert.equal(result, undefined, 'returns undefined for non-existent invite') +}) + +test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', async () => { + const { projectApi } = setup() + + await projectApi.delete('non-existent-id') // should not throw + + const all = await projectApi.getAll() + assert.equal(all.length, 0, 'no-op for non-existent invite') +}) + +test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', async () => { + const { api, projectApi } = setup() + + await projectApi.deleteAll() // should not throw + + const all = await api.getAll() + assert.equal(all.length, 0, 'no-op on empty project') +}) + +test('setShouldListenOverInternet - constructor checks existing invites', async () => { + const inviteId = randomBytes(32) + const { getShouldListenOverInternet } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + assert.deepEqual( + getShouldListenOverInternet(), + [true], + 'called with true when invites exist' + ) +}) + +test('setShouldListenOverInternet - constructor does not call when db is empty', async () => { + const { getShouldListenOverInternet } = setup() + + assert.deepEqual( + getShouldListenOverInternet(), + [], + 'never called when db is empty' + ) +}) + +test('setShouldListenOverInternet - create() sets true on first invite', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + assert.deepEqual( + getShouldListenOverInternet(), + [true], + 'called with true after first create' + ) +}) + +test('setShouldListenOverInternet - create() second invite does not call callback', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const inviteId2 = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId2.toString('hex'), + inviteIdBuffer: inviteId2, + url: 'https://example.com/invite/2', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + assert.equal( + getShouldListenOverInternet().length, + 1, + 'callback only called once on second create' + ) +}) + +test('setShouldListenOverInternet - create() after failure does not call callback', async () => { + const { api, getShouldListenOverInternet } = setup() + + // Clear any initial state + await api.deleteAll() + const callsAfterDelete = getShouldListenOverInternet().slice() + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Try duplicate (fails) + await assert.rejects( + api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/dup', + opts: { roleId: MEMBER_ROLE_ID }, + }) + ) + + // Should only count the successful create + assert.equal( + getShouldListenOverInternet().length, + callsAfterDelete.length + 1, + 'duplicate create does not call callback' + ) +}) + +test('setShouldListenOverInternet - delete() sets false when last invite removed', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.delete(inviteId.toString('hex')) + + assert.deepEqual( + getShouldListenOverInternet(), + [true, false], + 'called true on create, false on delete' + ) +}) + +test('setShouldListenOverInternet - delete() does not call when invites remain', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + const inviteId2 = randomBytes(32) + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId2.toString('hex'), + inviteIdBuffer: inviteId2, + url: 'https://example.com/invite/2', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.delete(inviteId.toString('hex')) + // Still 1 invite after delete, no callback call + assert.equal( + getShouldListenOverInternet().length, + 1, + 'only one callback call' + ) +}) + +test('setShouldListenOverInternet - deleteAll() sets false regardless of count', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + await api.deleteAll() + + assert.deepEqual( + getShouldListenOverInternet(), + [true, false], + 'called true on create, false on deleteAll' + ) +}) + +test('setShouldListenOverInternet - deleteAllFrom() sets false only when last invite removed across projects', async () => { + const { api, getShouldListenOverInternet } = setup() + + const inviteId = randomBytes(32) + const inviteId2 = randomBytes(32) + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + opts: { roleId: MEMBER_ROLE_ID }, + }) + await api.create({ + projectId: 'other-project', + inviteId: inviteId2.toString('hex'), + inviteIdBuffer: inviteId2, + url: 'https://example.com/invite/2', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Delete all from first project, other still has invite + await api.deleteAllFrom(PROJECT_ID) + // Still 1 invite (from other project), no callback call + assert.equal(getShouldListenOverInternet().length, 1) + + // Delete all from second project + await api.deleteAllFrom('other-project') + // 0 invites remain + assert.deepEqual( + getShouldListenOverInternet(), + [true, false], + 'called true on create, false on deleteAllFrom last project' + ) +}) From b0595b93f00deb6b275c292d87371db40ca76bfe Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 20 May 2026 13:27:31 -0400 Subject: [PATCH 039/103] chore: move pending-invites-api into invite --- src/{ => invite}/pending-invites-api.js | 10 +++++----- src/mapeo-manager.js | 2 +- src/mapeo-project.js | 4 ++-- src/member-api.js | 2 +- test-types/data-types.ts | 2 +- test/member-api.js | 2 +- test/pending-invites-api.js | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) rename src/{ => invite}/pending-invites-api.js (97%) diff --git a/src/pending-invites-api.js b/src/invite/pending-invites-api.js similarity index 97% rename from src/pending-invites-api.js rename to src/invite/pending-invites-api.js index 147c0a988..b5390678f 100644 --- a/src/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -1,21 +1,21 @@ import { and, eq, sql } from 'drizzle-orm' -import { pendingInvitesTable } from './schema/client.js' -import { isRoleIdForNewInvite } from './roles.js' +import { pendingInvitesTable } from '../schema/client.js' +import { isRoleIdForNewInvite } from '../roles.js' import { ensureKnownError, getErrorCode, PendingInviteAlreadyExistsError, -} from './errors.js' +} from '../errors.js' /** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ -/** @import { InviteOptions } from './member-api.js' */ +/** @import { InviteOptions } from '../member-api.js' */ /** * @typedef {object} PendingInviteRecord * @property {string} inviteId Hex string invite ID (primary key) * @property {Buffer} inviteIdBuffer Binary invite ID * @property {string} url Invite URL - * @property {import('./roles.js').RoleIdForNewInvite} roleId + * @property {import('../roles.js').RoleIdForNewInvite} roleId * @property {string} [roleName] * @property {string} [roleDescription] * @property {string} [inviteeDeviceId] Device ID of invitee (set when redeemed) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 1b5436aa3..74575439c 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -68,7 +68,7 @@ import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' import { parseInviteURL } from './member-api.js' import { pEvent } from 'p-event' -import { PendingInvitesApi } from './pending-invites-api.js' +import { PendingInvitesApi } from './invite/pending-invites-api.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 30b53eddc..de9885bb0 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -49,7 +49,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' -import { PendingInvitesApiForProject } from './pending-invites-api.js' +import { PendingInvitesApiForProject } from './invite/pending-invites-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -177,7 +177,7 @@ export class MapeoProject extends ReadyResource { * @param {(mediaType: 'blobs' | 'icons') => Promise} opts.getMediaBaseUrl * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {import('./local-peers.js').LocalPeers} opts.localPeers - * @param {import('./pending-invites-api.js').PendingInvitesApi} opts.pendingInvitesApi + * @param {import('./invite/pending-invites-api.js').PendingInvitesApi} opts.pendingInvitesApi * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted diff --git a/src/member-api.js b/src/member-api.js index 858fc5e3a..225490309 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -73,7 +73,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ -/** @import { PendingInvitesApiForProject } from './pending-invites-api.js' */ +/** @import { PendingInvitesApiForProject } from './invite/pending-invites-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ diff --git a/test-types/data-types.ts b/test-types/data-types.ts index 9891f3140..eee232c24 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -19,7 +19,7 @@ import { DerivedDocFields } from '../dist/datatype/index.js' import { projectSettingsTable } from '../dist/schema/client.js' import { LocalPeers } from '../dist/local-peers.js' import { Expect, type Equal } from './utils.js' -import { PendingInvitesApi } from '../dist/pending-invites-api.js' +import { PendingInvitesApi } from '../dist/invite/pending-invites-api.js' type ObservationWithDerivedDocFields = Observation & DerivedDocFields type PresetWithDerivedDocFields = Preset & DerivedDocFields diff --git a/test/member-api.js b/test/member-api.js index 95adc731c..2421c3f17 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -10,7 +10,7 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ /** @import { InviteOptions } from '../src/member-api.js' */ -/** @import {PendingInviteRecord, PendingInviteCreate,PendingInviteUpdate} from '../src/pending-invites-api.js' */ +/** @import {PendingInviteRecord, PendingInviteCreate,PendingInviteUpdate} from '../src/invite/pending-invites-api.js' */ test('serialize and parse invite URLs', () => { const testDeviceId = 'foo' diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index 93d3ae209..019fb8b07 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -8,7 +8,7 @@ import { randomBytes } from 'node:crypto' import { PendingInvitesApi, PendingInvitesApiForProject, -} from '../src/pending-invites-api.js' +} from '../src/invite/pending-invites-api.js' import { MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, From c468f290b9c3a140f1dd64740ea39e50d2f1ca1d Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 20 May 2026 14:00:14 -0400 Subject: [PATCH 040/103] chore: denullify pending invite values --- src/invite/pending-invites-api.js | 16 ++++------------ src/member-api.js | 2 -- test/pending-invites-api.js | 4 ++-- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index b5390678f..877f0d4e7 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -6,6 +6,7 @@ import { getErrorCode, PendingInviteAlreadyExistsError, } from '../errors.js' +import { deNullify } from '../utils.js' /** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ /** @import { InviteOptions } from '../member-api.js' */ @@ -208,10 +209,7 @@ export class PendingInvitesApi { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ ({ - ...row, - inviteeDeviceId: row.inviteeDeviceId ?? undefined, - }) + return /** @type {PendingInviteRecord} */ (deNullify(row)) } /** @@ -225,10 +223,7 @@ export class PendingInvitesApi { if (!isRoleIdForNewInvite(row.roleId)) { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ ({ - ...row, - inviteeDeviceId: row.inviteeDeviceId ?? undefined, - }) + return /** @type {PendingInviteRecord} */ (deNullify(row)) }) } @@ -246,10 +241,7 @@ export class PendingInvitesApi { if (!isRoleIdForNewInvite(row.roleId)) { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ ({ - ...row, - inviteeDeviceId: row.inviteeDeviceId ?? undefined, - }) + return /** @type {PendingInviteRecord} */ (deNullify(row)) }) } diff --git a/src/member-api.js b/src/member-api.js index 225490309..3078b375a 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -399,8 +399,6 @@ export class MemberApi extends ReadyResource { await this.cancelInviteOverInternet(pendingInviteId) throw new PeerDisconnectedSinceRedeemingInviteError() } - if (!opts.roleName) delete opts.roleName - if (!opts.roleDescription) delete opts.roleDescription const decision = await this.invite(inviteeDeviceId, opts) return decision diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index 019fb8b07..fbd6a5bda 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -538,12 +538,12 @@ test('Optional fields', async () => { const retrieved = await api.getById(inviteIdString, PROJECT_ID) assert.equal( retrieved?.roleName, - null, + undefined, 'roleName is undefined when not provided' ) assert.equal( retrieved?.roleDescription, - null, + undefined, 'roleDescription is undefined when not provided' ) }) From 67b68a4ba082e725755695ae8d44360835f35891 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 20 May 2026 14:14:12 -0400 Subject: [PATCH 041/103] feat: Trust-gate more RPC methods --- src/local-peers.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/local-peers.js b/src/local-peers.js index fb353e532..0f3ccac53 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -884,6 +884,7 @@ export class LocalPeers extends TypedEmitter { break } case 'InviteCancel': { + if (!peer.isTrusted) return const inviteCancel = parseInviteCancel(value) this.emit('invite-cancel', peer.id, inviteCancel) peer.sendInviteCancelAck(inviteCancel).catch((e) => { @@ -897,6 +898,7 @@ export class LocalPeers extends TypedEmitter { break } case 'InviteResponse': { + if (!peer.isTrusted) return const inviteResponse = parseInviteResponse(value) this.emit('invite-response', peer.id, inviteResponse) peer.sendInviteResponseAck(inviteResponse).catch((e) => { @@ -905,6 +907,7 @@ export class LocalPeers extends TypedEmitter { break } case 'RedeemInviteOverInternet': { + if (!peer.isTrusted) return const redeem = RedeemInviteOverInternet.decode(value) this.emit('invite-over-internet-redeemed', peer.id, redeem) peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { @@ -913,6 +916,7 @@ export class LocalPeers extends TypedEmitter { break } case 'ProjectJoinDetails': { + if (!peer.isTrusted) return const details = parseProjectJoinDetails(value) this.emit('got-project-details', peer.id, details) peer.sendProjectJoinDetailsAck(details).catch((e) => { @@ -934,30 +938,35 @@ export class LocalPeers extends TypedEmitter { break } case 'InviteAck': { + if (!peer.isTrusted) return const ack = InviteAck.decode(value) peer.receiveAck('InviteAck', ack) this.emit('invite-ack', peer.id, ack) break } case 'InviteCancelAck': { + if (!peer.isTrusted) return const ack = InviteCancelAck.decode(value) peer.receiveAck('InviteCancelAck', ack) this.emit('invite-cancel-ack', peer.id, ack) break } case 'InviteResponseAck': { + if (!peer.isTrusted) return const ack = InviteResponseAck.decode(value) peer.receiveAck('InviteResponseAck', ack) this.emit('invite-response-ack', peer.id, ack) break } case 'ProjectJoinDetailsAck': { + if (!peer.isTrusted) return const ack = ProjectJoinDetailsAck.decode(value) peer.receiveAck('ProjectJoinDetailsAck', ack) this.emit('got-project-details-ack', peer.id, ack) break } case 'RedeemInviteOverInternetAck': { + if (!peer.isTrusted) return const ack = RedeemInviteOverInternetAck.decode(value) peer.receiveAck('RedeemInviteOverInternetAck', ack) this.emit('invite-over-internet-redeemed-ack', peer.id, ack) From d3565ac68a1fb458f94b5782711ed9ced14d36ab Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 20 May 2026 14:34:57 -0400 Subject: [PATCH 042/103] fix: Un-trust-gate redeem invite over internet --- src/local-peers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/local-peers.js b/src/local-peers.js index 0f3ccac53..77e892f52 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -907,7 +907,6 @@ export class LocalPeers extends TypedEmitter { break } case 'RedeemInviteOverInternet': { - if (!peer.isTrusted) return const redeem = RedeemInviteOverInternet.decode(value) this.emit('invite-over-internet-redeemed', peer.id, redeem) peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { From c37db5fc40525055586b7b55a53abc0335453347 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 21 May 2026 12:26:46 -0400 Subject: [PATCH 043/103] feat: Clear expired pending invites --- src/invite/pending-invites-api.js | 24 ++++ test/pending-invites-api.js | 184 ++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index 877f0d4e7..3125240a0 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -37,6 +37,8 @@ import { deNullify } from '../utils.js' * @property {string} inviteeDeviceId */ +const INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 + export class PendingInvitesApiForProject { #projectId #pendingInvitesApi @@ -143,6 +145,13 @@ export class PendingInvitesApi { .from(pendingInvitesTable) .where(eq(pendingInvitesTable.projectId, sql.placeholder('projectId'))) .prepare(), + getExpired: db + .select() + .from(pendingInvitesTable) + .where( + sql`${pendingInvitesTable.createdAt} < ${sql.placeholder('cutoff')}` + ) + .prepare(), } // Run initial check: enable listening if there are existing invites @@ -152,6 +161,17 @@ export class PendingInvitesApi { } } + /** + * Delete all pending invites whose createdAt timestamp is older than 24 hours. + */ + async #clearExpired() { + const cutoff = Date.now() - INVITE_EXPIRY_MS + const expired = this.#sql.getExpired.all({ cutoff }) + for (const row of expired) { + await this.delete(row.inviteId) + } + } + /** * Check if we should be listening over the internet based on * total invite count across all projects. @@ -202,6 +222,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getById(inviteId, projectId) { + await this.#clearExpired() const row = this.#sql.getById.get({ inviteId, projectId }) if (!row) return undefined @@ -217,6 +238,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getAll() { + await this.#clearExpired() const rows = this.#sql.getAll.all() return rows.map((row) => { @@ -233,6 +255,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getAllForProject(projectId) { + await this.#clearExpired() const rows = this.#sql.getAllForProject.all({ projectId, }) @@ -253,6 +276,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async update(inviteId, projectId, updates) { + await this.#clearExpired() await this.#db .update(pendingInvitesTable) .set(updates) diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index fbd6a5bda..a54041229 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { eq } from 'drizzle-orm' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' @@ -975,3 +976,186 @@ test('setShouldListenOverInternet - deleteAllFrom() sets false only when last in 'called true on create, false on deleteAllFrom last project' ) }) + +const EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours in ms + +test('clearExpired - expired invites are removed on getAll()', async () => { + const { api, db } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: 'expired-1', + inviteIdBuffer: Buffer.from('expired-1'), + url: 'https://example.com/expired', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + // Directly update the createdAt to older than 24 hours + await db + .update(pendingInvitesTable) + .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) + .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + + const invites = await api.getAll() + assert.equal(invites.length, 0, 'expired invite is not returned') + + // Verify the invite was actually deleted from the DB + const all = await api.getAll() + assert.equal(all.length, 0, 'expired invite is gone after getAll') +}) + +test('clearExpired - expired invites are removed on getAllForProject()', async () => { + const inviteId = randomBytes(32) + const { api, db } = setup({ + seedPendingInvites: [ + { + projectId: 'other-project', + inviteId: 'expired-1', + inviteIdBuffer: Buffer.from('expired-1'), + url: 'https://example.com/expired', + roleId: MEMBER_ROLE_ID, + }, + { + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/fresh', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + // Expire the invite in the other project + await db + .update(pendingInvitesTable) + .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) + .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + + const invites = await api.getAllForProject(PROJECT_ID) + assert.equal(invites.length, 1) + assert.equal( + invites[0].inviteId, + inviteId.toString('hex'), + 'fresh invite remains' + ) +}) + +test('clearExpired - expired invites are removed on getById()', async () => { + const { api, db } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: 'expired-1', + inviteIdBuffer: Buffer.from('expired-1'), + url: 'https://example.com/expired', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + // Directly update the createdAt to older than 24 hours + await db + .update(pendingInvitesTable) + .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) + .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + + const result = await api.getById('expired-1', PROJECT_ID) + assert.equal(result, undefined, 'expired invite is not returned by getById') +}) + +test('clearExpired - expired invites are removed on update()', async () => { + const inviteId = randomBytes(32) + const { api, db } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: 'expired-1', + inviteIdBuffer: Buffer.from('expired-1'), + url: 'https://example.com/expired', + roleId: MEMBER_ROLE_ID, + }, + { + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/fresh', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + // Expire one invite + await db + .update(pendingInvitesTable) + .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) + .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + + const inviteeDeviceId = randomBytes(32).toString('hex') + await api.update(inviteId.toString('hex'), PROJECT_ID, { inviteeDeviceId }) + + // Verify the expired invite was cleaned up during update + const all = await api.getAll() + assert.equal(all.length, 1) + assert.equal( + all[0].inviteId, + inviteId.toString('hex'), + 'only fresh invite remains' + ) + assert.equal( + all[0].inviteeDeviceId, + inviteeDeviceId, + 'fresh invite was updated correctly' + ) +}) + +test('clearExpired - non-expired invites are not affected', async () => { + const inviteId = randomBytes(32) + const { api } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/fresh', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + const invites = await api.getAll() + assert.equal(invites.length, 1, 'fresh invite is returned') + assert.equal(invites[0].inviteId, inviteId.toString('hex')) +}) + +test('clearExpired - sets shouldListenOverInternet to false when all invites expire', async () => { + const { api, db, getShouldListenOverInternet } = setup({ + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: 'expired-1', + inviteIdBuffer: Buffer.from('expired-1'), + url: 'https://example.com/expired', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + // Constructor already called setShouldListenOverInternet(true) because invite exists + assert.deepEqual(getShouldListenOverInternet(), [true]) + + // Expire the invite + await db + .update(pendingInvitesTable) + .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) + .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + + await api.getAll() + + assert.deepEqual( + getShouldListenOverInternet(), + [true, false], + 'called with false when all invites were expired and deleted' + ) +}) From 7b6fe8bd8ac396284e502b6bd139607a58d034c0 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 21 May 2026 14:04:57 -0400 Subject: [PATCH 044/103] feat: pending invites clear expired on init via ready-resource --- src/invite/pending-invites-api.js | 37 +++++++++++++++++++++++++++---- test/pending-invites-api.js | 18 ++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index 3125240a0..852427517 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -1,4 +1,5 @@ import { and, eq, sql } from 'drizzle-orm' +import ReadyResource from 'ready-resource' import { pendingInvitesTable } from '../schema/client.js' import { isRoleIdForNewInvite } from '../roles.js' import { @@ -41,6 +42,7 @@ const INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 export class PendingInvitesApiForProject { #projectId + /** @type {PendingInvitesApi} */ #pendingInvitesApi /** @@ -59,6 +61,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async create(data) { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.create({ ...data, projectId: this.#projectId, @@ -71,6 +74,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async getById(inviteId) { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.getById(inviteId, this.#projectId) } @@ -79,6 +83,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async getAll() { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.getAllForProject(this.#projectId) } @@ -89,6 +94,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async update(inviteId, updates) { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.update(inviteId, this.#projectId, updates) } @@ -98,6 +104,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async delete(inviteId) { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.delete(inviteId) } @@ -106,6 +113,7 @@ export class PendingInvitesApiForProject { * @returns {Promise} */ async deleteAll() { + await this.#pendingInvitesApi.ready() return this.#pendingInvitesApi.deleteAllFrom(this.#projectId) } } @@ -113,7 +121,10 @@ export class PendingInvitesApiForProject { /** * API for CRUD operations on pending invites over internet */ -export class PendingInvitesApi { +/** + * @type {ReadyResource} + */ +export class PendingInvitesApi extends ReadyResource { /** @type {BetterSQLite3Database} */ #db #sql @@ -125,6 +136,7 @@ export class PendingInvitesApi { * @param {(shouldListen: boolean) => Promise} setShouldListenOverInternet */ constructor(db, setShouldListenOverInternet) { + super() this.#db = db this.#setShouldListenOverInternet = setShouldListenOverInternet this.#sql = { @@ -153,11 +165,14 @@ export class PendingInvitesApi { ) .prepare(), } + } - // Run initial check: enable listening if there are existing invites + async _open() { + // Clear expired invites and check if listening should be enabled + await this.#clearExpired() const count = this.#sql.getAll.all().length if (count > 0) { - setShouldListenOverInternet(true) + await this.#setShouldListenOverInternet(true) } } @@ -168,7 +183,13 @@ export class PendingInvitesApi { const cutoff = Date.now() - INVITE_EXPIRY_MS const expired = this.#sql.getExpired.all({ cutoff }) for (const row of expired) { - await this.delete(row.inviteId) + await this.#db + .delete(pendingInvitesTable) + .where(eq(pendingInvitesTable.inviteId, row.inviteId)) + } + // Update the listen state once, after all deletions + if (expired.length > 0) { + await this.#checkSetShouldListenOverInternet(false) } } @@ -194,6 +215,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async create(data) { + await this.ready() try { await this.#db.insert(pendingInvitesTable).values({ projectId: data.projectId, @@ -222,6 +244,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getById(inviteId, projectId) { + await this.ready() await this.#clearExpired() const row = this.#sql.getById.get({ inviteId, projectId }) if (!row) return undefined @@ -238,6 +261,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getAll() { + await this.ready() await this.#clearExpired() const rows = this.#sql.getAll.all() @@ -255,6 +279,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async getAllForProject(projectId) { + await this.ready() await this.#clearExpired() const rows = this.#sql.getAllForProject.all({ projectId, @@ -276,6 +301,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async update(inviteId, projectId, updates) { + await this.ready() await this.#clearExpired() await this.#db .update(pendingInvitesTable) @@ -294,6 +320,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async delete(inviteId) { + await this.ready() await this.#db .delete(pendingInvitesTable) .where(eq(pendingInvitesTable.inviteId, inviteId)) @@ -306,6 +333,7 @@ export class PendingInvitesApi { * @returns {Promise} */ async deleteAll() { + await this.ready() await this.#db.delete(pendingInvitesTable) await this.#setShouldListenOverInternet(false) } @@ -315,6 +343,7 @@ export class PendingInvitesApi { * @param {string} projectId */ async deleteAllFrom(projectId) { + await this.ready() await this.#db .delete(pendingInvitesTable) .where(eq(pendingInvitesTable.projectId, projectId)) diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index a54041229..e2604b883 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -754,9 +754,9 @@ test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', asyn assert.equal(all.length, 0, 'no-op on empty project') }) -test('setShouldListenOverInternet - constructor checks existing invites', async () => { +test('setShouldListenOverInternet - _open() sets true when invites exist', async () => { const inviteId = randomBytes(32) - const { getShouldListenOverInternet } = setup({ + const { api, getShouldListenOverInternet } = setup({ seedPendingInvites: [ { projectId: PROJECT_ID, @@ -768,15 +768,19 @@ test('setShouldListenOverInternet - constructor checks existing invites', async ], }) + await api.ready() + assert.deepEqual( getShouldListenOverInternet(), [true], - 'called with true when invites exist' + 'called with true when invites exist after ready()' ) }) -test('setShouldListenOverInternet - constructor does not call when db is empty', async () => { - const { getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - _open() does not call when db is empty', async () => { + const { api, getShouldListenOverInternet } = setup() + + await api.ready() assert.deepEqual( getShouldListenOverInternet(), @@ -1142,7 +1146,9 @@ test('clearExpired - sets shouldListenOverInternet to false when all invites exp ], }) - // Constructor already called setShouldListenOverInternet(true) because invite exists + await api.ready() + + // _open() already called setShouldListenOverInternet(true) because invite exists assert.deepEqual(getShouldListenOverInternet(), [true]) // Expire the invite From 3383b9542548dc727f563b9de61e363ccadab841 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 21 May 2026 16:49:41 -0400 Subject: [PATCH 045/103] feat: Use timers to clear expired pending invites --- src/invite/pending-invites-api.js | 67 +++++- src/mapeo-manager.js | 1 + test/pending-invites-api.js | 365 ++++++++++++------------------ 3 files changed, 202 insertions(+), 231 deletions(-) diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index 852427517..6b42acdc9 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -38,7 +38,7 @@ import { deNullify } from '../utils.js' * @property {string} inviteeDeviceId */ -const INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 export class PendingInvitesApiForProject { #projectId @@ -130,15 +130,25 @@ export class PendingInvitesApi extends ReadyResource { #sql /** @type {(shouldListen: boolean) => Promise} */ #setShouldListenOverInternet + /** @type {ReturnType | null} */ + #clearExpiredTimer = null + /** @type {number} */ + #expiryMs /** * @param {BetterSQLite3Database} db Drizzle database instance * @param {(shouldListen: boolean) => Promise} setShouldListenOverInternet + * @param {number} [expiryMs] milliseconds before invites expire (default: 24 hours) */ - constructor(db, setShouldListenOverInternet) { + constructor( + db, + setShouldListenOverInternet, + expiryMs = DEFAULT_INVITE_EXPIRY_MS + ) { super() this.#db = db this.#setShouldListenOverInternet = setShouldListenOverInternet + this.#expiryMs = expiryMs this.#sql = { getById: db .select() @@ -164,6 +174,12 @@ export class PendingInvitesApi extends ReadyResource { sql`${pendingInvitesTable.createdAt} < ${sql.placeholder('cutoff')}` ) .prepare(), + getOldest: db + .select() + .from(pendingInvitesTable) + .orderBy(sql`${pendingInvitesTable.createdAt} ASC`) + .limit(1) + .prepare(), } } @@ -174,13 +190,18 @@ export class PendingInvitesApi extends ReadyResource { if (count > 0) { await this.#setShouldListenOverInternet(true) } + this.#scheduleExpired() + } + + async _close() { + this.#cancelScheduleExpired() } /** * Delete all pending invites whose createdAt timestamp is older than 24 hours. */ async #clearExpired() { - const cutoff = Date.now() - INVITE_EXPIRY_MS + const cutoff = Date.now() - this.#expiryMs const expired = this.#sql.getExpired.all({ cutoff }) for (const row of expired) { await this.#db @@ -193,6 +214,34 @@ export class PendingInvitesApi extends ReadyResource { } } + /** + * Cancel the scheduled expiry timer + */ + #cancelScheduleExpired() { + if (this.#clearExpiredTimer !== null) { + clearTimeout(this.#clearExpiredTimer) + this.#clearExpiredTimer = null + } + } + + /** + * Schedule a timeout to clear expired invites a few seconds after the oldest + * invite is expected to expire. Fires once, then reschedules itself. + */ + #scheduleExpired() { + this.#cancelScheduleExpired() + const oldest = this.#sql.getOldest.get() + if (!oldest) return + const expiresAt = oldest.createdAt + this.#expiryMs + const delay = Math.max(0, expiresAt - Date.now()) + this.#clearExpiredTimer = setTimeout(async () => { + this.#clearExpiredTimer = null + await this.#clearExpired() + this.#scheduleExpired() + }, delay) + this.#clearExpiredTimer.unref() + } + /** * Check if we should be listening over the internet based on * total invite count across all projects. @@ -206,6 +255,8 @@ export class PendingInvitesApi extends ReadyResource { await this.#setShouldListenOverInternet(true) } else if (!direction && count === 0) { await this.#setShouldListenOverInternet(false) + // Cancel the scheduled expiry timer since there are no invites left + this.#cancelScheduleExpired() } } @@ -229,6 +280,11 @@ export class PendingInvitesApi extends ReadyResource { }) await this.#checkSetShouldListenOverInternet(true) + // If this is the only invite, schedule expiry cleanup + const count = this.#sql.getAll.all().length + if (count === 1) { + this.#scheduleExpired() + } } catch (err) { if (getErrorCode(err) === 'SQLITE_CONSTRAINT_PRIMARYKEY') { throw new PendingInviteAlreadyExistsError({ inviteId: data.inviteId }) @@ -245,7 +301,6 @@ export class PendingInvitesApi extends ReadyResource { */ async getById(inviteId, projectId) { await this.ready() - await this.#clearExpired() const row = this.#sql.getById.get({ inviteId, projectId }) if (!row) return undefined @@ -262,7 +317,6 @@ export class PendingInvitesApi extends ReadyResource { */ async getAll() { await this.ready() - await this.#clearExpired() const rows = this.#sql.getAll.all() return rows.map((row) => { @@ -280,7 +334,6 @@ export class PendingInvitesApi extends ReadyResource { */ async getAllForProject(projectId) { await this.ready() - await this.#clearExpired() const rows = this.#sql.getAllForProject.all({ projectId, }) @@ -302,7 +355,6 @@ export class PendingInvitesApi extends ReadyResource { */ async update(inviteId, projectId, updates) { await this.ready() - await this.#clearExpired() await this.#db .update(pendingInvitesTable) .set(updates) @@ -336,6 +388,7 @@ export class PendingInvitesApi extends ReadyResource { await this.ready() await this.#db.delete(pendingInvitesTable) await this.#setShouldListenOverInternet(false) + this.#cancelScheduleExpired() } /** diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 74575439c..e9192474e 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -1210,6 +1210,7 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async close() { + await this.#pendingInvitesApi.close() await this.#remoteDiscovery.close() // This added for workers PR // await this.#projectSettingsIndexWriter.close() diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index e2604b883..e93bd7dcd 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -1,6 +1,5 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { eq } from 'drizzle-orm' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' @@ -37,14 +36,16 @@ const PROJECT_ID = 'test-project-id' * @property {string} inviteId * @property {Buffer} inviteIdBuffer * @property {string} url + * @property {number} [createdAt] * @property {import('../src/roles.js').RoleIdForNewInvite} roleId */ /** - * @param {{ seedPendingInvites?: SeedPendingInvite[] }} [opts] + * @param {import('node:test').TestContext} t + * @param {{ seedPendingInvites?: SeedPendingInvite[], expiryMs?: number }} [opts] * @returns {TestEnv} */ -function setup({ seedPendingInvites = [] } = {}) { +function setup(t, { seedPendingInvites = [], expiryMs } = {}) { const sqlite = new Database(':memory:') const db = drizzle(sqlite) migrate(db, { @@ -78,9 +79,11 @@ function setup({ seedPendingInvites = [] } = {}) { return shouldListenCalls } - const api = new PendingInvitesApi(db, setShouldListenOverInternet) + const api = new PendingInvitesApi(db, setShouldListenOverInternet, expiryMs) const projectApi = new PendingInvitesApiForProject(PROJECT_ID, api) + t.after(() => api.close()) + return { api, projectApi, @@ -90,8 +93,8 @@ function setup({ seedPendingInvites = [] } = {}) { } } -test('create() - basic functionality', async () => { - const { api } = setup() +test('create() - basic functionality', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -119,8 +122,8 @@ test('create() - basic functionality', async () => { assert.ok(retrieved.createdAt, 'has createdAt timestamp') }) -test('create() - duplicate inviteId throws', async () => { - const { api } = setup() +test('create() - duplicate inviteId throws', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -153,15 +156,15 @@ test('create() - duplicate inviteId throws', async () => { ) }) -test('getById() - non-existent invite', async () => { - const { api } = setup() +test('getById() - non-existent invite', async (t) => { + const { api } = setup(t) const result = await api.getById('non-existent-id', PROJECT_ID) assert.equal(result, undefined, 'returns undefined for non-existent invite') }) -test('getById() - invite from different project not found', async () => { - const { api } = setup() +test('getById() - invite from different project not found', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -182,15 +185,15 @@ test('getById() - invite from different project not found', async () => { ) }) -test('getAll() - empty database', async () => { - const { api } = setup() +test('getAll() - empty database', async (t) => { + const { api } = setup(t) const invites = await api.getAll() assert.deepEqual(invites, [], 'returns empty array when no invites exist') }) -test('getAll() - multiple invites', async () => { - const { api } = setup() +test('getAll() - multiple invites', async (t) => { + const { api } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -229,8 +232,8 @@ test('getAll() - multiple invites', async () => { assert.ok(inviteIds.includes(invite3.toString('hex'))) }) -test('getAllForProject() - returns only invites for the project', async () => { - const { api } = setup() +test('getAllForProject() - returns only invites for the project', async (t) => { + const { api } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -260,8 +263,8 @@ test('getAllForProject() - returns only invites for the project', async () => { assert.equal(invites[0].inviteId, invite1.toString('hex')) }) -test('getAllForProject() - empty for project with no invites', async () => { - const { api } = setup() +test('getAllForProject() - empty for project with no invites', async (t) => { + const { api } = setup(t) const invites = await api.getAllForProject(PROJECT_ID) assert.deepEqual( @@ -271,8 +274,8 @@ test('getAllForProject() - empty for project with no invites', async () => { ) }) -test('update() - set inviteeDeviceId', async () => { - const { api } = setup() +test('update() - set inviteeDeviceId', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -295,8 +298,8 @@ test('update() - set inviteeDeviceId', async () => { assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) }) -test('update() - scoped to project', async () => { - const { api } = setup() +test('update() - scoped to project', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -321,8 +324,8 @@ test('update() - scoped to project', async () => { ) }) -test('update() - update non-existent invite', async () => { - const { api } = setup() +test('update() - update non-existent invite', async (t) => { + const { api } = setup(t) const inviteeDeviceId = randomBytes(32).toString('hex') await api.update('non-existent-id', PROJECT_ID, { inviteeDeviceId }) @@ -331,8 +334,8 @@ test('update() - update non-existent invite', async () => { assert.equal(result, undefined, 'no-op for non-existent invite') }) -test('delete() - single invite', async () => { - const { api } = setup() +test('delete() - single invite', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -354,8 +357,8 @@ test('delete() - single invite', async () => { assert.equal(all.length, 0, 'invite removed from getAll') }) -test('delete() - non-existent invite', async () => { - const { api } = setup() +test('delete() - non-existent invite', async (t) => { + const { api } = setup(t) await api.delete('non-existent-id') @@ -363,8 +366,8 @@ test('delete() - non-existent invite', async () => { assert.equal(result, undefined, 'no-op for non-existent invite') }) -test('deleteAll() - clear all invites', async () => { - const { api } = setup() +test('deleteAll() - clear all invites', async (t) => { + const { api } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -400,8 +403,8 @@ test('deleteAll() - clear all invites', async () => { assert.equal(all.length, 0, 'all invites deleted') }) -test('deleteAll() - empty database', async () => { - const { api } = setup() +test('deleteAll() - empty database', async (t) => { + const { api } = setup(t) await api.deleteAll() @@ -409,8 +412,8 @@ test('deleteAll() - empty database', async () => { assert.equal(all.length, 0, 'no-op on empty database') }) -test('deleteAllFrom() - clears invites only for the project', async () => { - const { api } = setup() +test('deleteAllFrom() - clears invites only for the project', async (t) => { + const { api } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -438,16 +441,16 @@ test('deleteAllFrom() - clears invites only for the project', async () => { assert.equal(all[0].inviteId, invite2.toString('hex')) }) -test('deleteAllFrom() - empty for project with no invites', async () => { - const { api } = setup() +test('deleteAllFrom() - empty for project with no invites', async (t) => { + const { api } = setup(t) await api.deleteAllFrom(PROJECT_ID) const all = await api.getAll() assert.equal(all.length, 0, 'no-op on empty project') }) -test('Role ID validation on read', async () => { - const { api, db } = setup() +test('Role ID validation on read', async (t) => { + const { api, db } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -475,8 +478,8 @@ test('Role ID validation on read', async () => { ) }) -test('Buffer persistence', async () => { - const { api } = setup() +test('Buffer persistence', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -493,8 +496,8 @@ test('Buffer persistence', async () => { assert.ok(retrieved?.inviteIdBuffer.equals(inviteId), 'buffer is identical') }) -test('Timestamp verification', async () => { - const { api } = setup() +test('Timestamp verification', async (t) => { + const { api } = setup(t) const beforeCreate = Date.now() const inviteId = randomBytes(32) @@ -519,8 +522,8 @@ test('Timestamp verification', async () => { ) }) -test('Optional fields', async () => { - const { api } = setup() +test('Optional fields', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -549,8 +552,8 @@ test('Optional fields', async () => { ) }) -test('create() and getAll() with inviteeDeviceId already set', async () => { - const { api } = setup() +test('create() and getAll() with inviteeDeviceId already set', async (t) => { + const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -573,8 +576,8 @@ test('create() and getAll() with inviteeDeviceId already set', async () => { // Tests for PendingInvitesApiForProject (scoped wrapper) -test('PendingInvitesApiForProject - create() auto-injects projectId', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - create() auto-injects projectId', async (t) => { + const { projectApi } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -593,8 +596,8 @@ test('PendingInvitesApiForProject - create() auto-injects projectId', async () = assert.equal(retrieved.url, url) }) -test('PendingInvitesApiForProject - getAll() returns scoped invites', async () => { - const { api, projectApi } = setup() +test('PendingInvitesApiForProject - getAll() returns scoped invites', async (t) => { + const { api, projectApi } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -620,8 +623,8 @@ test('PendingInvitesApiForProject - getAll() returns scoped invites', async () = assert.equal(invites[0].inviteId, invite1.toString('hex')) }) -test('PendingInvitesApiForProject - update() auto-scopes to project', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - update() auto-scopes to project', async (t) => { + const { projectApi } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -640,8 +643,8 @@ test('PendingInvitesApiForProject - update() auto-scopes to project', async () = assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) }) -test('PendingInvitesApiForProject - delete() removes invite', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - delete() removes invite', async (t) => { + const { projectApi } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -659,8 +662,8 @@ test('PendingInvitesApiForProject - delete() removes invite', async () => { assert.equal(retrieved, undefined, 'invite deleted') }) -test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', async () => { - const { api, projectApi } = setup() +test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', async (t) => { + const { api, projectApi } = setup(t) const invite1 = randomBytes(32) const invite2 = randomBytes(32) @@ -688,8 +691,8 @@ test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', as assert.equal(all[0].inviteId, invite2.toString('hex')) }) -test('PendingInvitesApiForProject - getById() returns scoped invite', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - getById() returns scoped invite', async (t) => { + const { projectApi } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -706,8 +709,8 @@ test('PendingInvitesApiForProject - getById() returns scoped invite', async () = assert.equal(retrieved.inviteId, inviteIdString) }) -test('PendingInvitesApiForProject - getById() returns undefined for invite in other project', async () => { - const { api, projectApi } = setup() +test('PendingInvitesApiForProject - getById() returns undefined for invite in other project', async (t) => { + const { api, projectApi } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') @@ -729,15 +732,15 @@ test('PendingInvitesApiForProject - getById() returns undefined for invite in ot ) }) -test('PendingInvitesApiForProject - getById() returns undefined for non-existent invite', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - getById() returns undefined for non-existent invite', async (t) => { + const { projectApi } = setup(t) const result = await projectApi.getById('non-existent-id') assert.equal(result, undefined, 'returns undefined for non-existent invite') }) -test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', async () => { - const { projectApi } = setup() +test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', async (t) => { + const { projectApi } = setup(t) await projectApi.delete('non-existent-id') // should not throw @@ -745,8 +748,8 @@ test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', assert.equal(all.length, 0, 'no-op for non-existent invite') }) -test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', async () => { - const { api, projectApi } = setup() +test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', async (t) => { + const { api, projectApi } = setup(t) await projectApi.deleteAll() // should not throw @@ -754,9 +757,9 @@ test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', asyn assert.equal(all.length, 0, 'no-op on empty project') }) -test('setShouldListenOverInternet - _open() sets true when invites exist', async () => { +test('setShouldListenOverInternet - _open() sets true when invites exist', async (t) => { const inviteId = randomBytes(32) - const { api, getShouldListenOverInternet } = setup({ + const { api, getShouldListenOverInternet } = setup(t, { seedPendingInvites: [ { projectId: PROJECT_ID, @@ -777,8 +780,8 @@ test('setShouldListenOverInternet - _open() sets true when invites exist', async ) }) -test('setShouldListenOverInternet - _open() does not call when db is empty', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - _open() does not call when db is empty', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) await api.ready() @@ -789,8 +792,8 @@ test('setShouldListenOverInternet - _open() does not call when db is empty', asy ) }) -test('setShouldListenOverInternet - create() sets true on first invite', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - create() sets true on first invite', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) await api.create({ @@ -808,8 +811,8 @@ test('setShouldListenOverInternet - create() sets true on first invite', async ( ) }) -test('setShouldListenOverInternet - create() second invite does not call callback', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - create() second invite does not call callback', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) await api.create({ @@ -836,8 +839,8 @@ test('setShouldListenOverInternet - create() second invite does not call callbac ) }) -test('setShouldListenOverInternet - create() after failure does not call callback', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - create() after failure does not call callback', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) // Clear any initial state await api.deleteAll() @@ -873,8 +876,8 @@ test('setShouldListenOverInternet - create() after failure does not call callbac ) }) -test('setShouldListenOverInternet - delete() sets false when last invite removed', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - delete() sets false when last invite removed', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) await api.create({ @@ -894,8 +897,8 @@ test('setShouldListenOverInternet - delete() sets false when last invite removed ) }) -test('setShouldListenOverInternet - delete() does not call when invites remain', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - delete() does not call when invites remain', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) const inviteId2 = randomBytes(32) @@ -924,8 +927,8 @@ test('setShouldListenOverInternet - delete() does not call when invites remain', ) }) -test('setShouldListenOverInternet - deleteAll() sets false regardless of count', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - deleteAll() sets false regardless of count', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) await api.create({ @@ -945,8 +948,8 @@ test('setShouldListenOverInternet - deleteAll() sets false regardless of count', ) }) -test('setShouldListenOverInternet - deleteAllFrom() sets false only when last invite removed across projects', async () => { - const { api, getShouldListenOverInternet } = setup() +test('setShouldListenOverInternet - deleteAllFrom() sets false only when last invite removed across projects', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) const inviteId = randomBytes(32) const inviteId2 = randomBytes(32) @@ -981,187 +984,101 @@ test('setShouldListenOverInternet - deleteAllFrom() sets false only when last in ) }) -const EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours in ms +const TEST_EXPIRY_MS = 50 // 50ms expiry for testing -test('clearExpired - expired invites are removed on getAll()', async () => { - const { api, db } = setup({ +test('clearExpired - general cleanup via schedule', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, seedPendingInvites: [ { projectId: PROJECT_ID, - inviteId: 'expired-1', - inviteIdBuffer: Buffer.from('expired-1'), - url: 'https://example.com/expired', + inviteId: 'auto-expire-1', + inviteIdBuffer: Buffer.from('auto-expire-1'), + url: 'https://example.com/auto-expire', roleId: MEMBER_ROLE_ID, + createdAt: Date.now() - TEST_EXPIRY_MS + 5, // expires in ~45ms }, ], }) - // Directly update the createdAt to older than 24 hours - await db - .update(pendingInvitesTable) - .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) - .where(eq(pendingInvitesTable.inviteId, 'expired-1')) + await api.ready() + await new Promise((resolve) => setTimeout(resolve, TEST_EXPIRY_MS * 2)) const invites = await api.getAll() - assert.equal(invites.length, 0, 'expired invite is not returned') - - // Verify the invite was actually deleted from the DB - const all = await api.getAll() - assert.equal(all.length, 0, 'expired invite is gone after getAll') -}) - -test('clearExpired - expired invites are removed on getAllForProject()', async () => { - const inviteId = randomBytes(32) - const { api, db } = setup({ - seedPendingInvites: [ - { - projectId: 'other-project', - inviteId: 'expired-1', - inviteIdBuffer: Buffer.from('expired-1'), - url: 'https://example.com/expired', - roleId: MEMBER_ROLE_ID, - }, - { - projectId: PROJECT_ID, - inviteId: inviteId.toString('hex'), - inviteIdBuffer: inviteId, - url: 'https://example.com/fresh', - roleId: MEMBER_ROLE_ID, - }, - ], - }) - - // Expire the invite in the other project - await db - .update(pendingInvitesTable) - .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) - .where(eq(pendingInvitesTable.inviteId, 'expired-1')) - - const invites = await api.getAllForProject(PROJECT_ID) - assert.equal(invites.length, 1) - assert.equal( - invites[0].inviteId, - inviteId.toString('hex'), - 'fresh invite remains' - ) + assert.equal(invites.length, 0, 'invite was auto-cleaned by scheduled expiry') }) -test('clearExpired - expired invites are removed on getById()', async () => { - const { api, db } = setup({ - seedPendingInvites: [ - { - projectId: PROJECT_ID, - inviteId: 'expired-1', - inviteIdBuffer: Buffer.from('expired-1'), - url: 'https://example.com/expired', - roleId: MEMBER_ROLE_ID, - }, - ], - }) - - // Directly update the createdAt to older than 24 hours - await db - .update(pendingInvitesTable) - .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) - .where(eq(pendingInvitesTable.inviteId, 'expired-1')) +test('scheduleExpired - creates first invite schedules expiry', async (t) => { + const { api } = setup(t, { expiryMs: TEST_EXPIRY_MS }) - const result = await api.getById('expired-1', PROJECT_ID) - assert.equal(result, undefined, 'expired invite is not returned by getById') -}) - -test('clearExpired - expired invites are removed on update()', async () => { + await api.ready() const inviteId = randomBytes(32) - const { api, db } = setup({ - seedPendingInvites: [ - { - projectId: PROJECT_ID, - inviteId: 'expired-1', - inviteIdBuffer: Buffer.from('expired-1'), - url: 'https://example.com/expired', - roleId: MEMBER_ROLE_ID, - }, - { - projectId: PROJECT_ID, - inviteId: inviteId.toString('hex'), - inviteIdBuffer: inviteId, - url: 'https://example.com/fresh', - roleId: MEMBER_ROLE_ID, - }, - ], + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/fresh', + opts: { roleId: MEMBER_ROLE_ID }, }) - // Expire one invite - await db - .update(pendingInvitesTable) - .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) - .where(eq(pendingInvitesTable.inviteId, 'expired-1')) - - const inviteeDeviceId = randomBytes(32).toString('hex') - await api.update(inviteId.toString('hex'), PROJECT_ID, { inviteeDeviceId }) + // Wait for invite to expire and scheduled cleanup to fire + await new Promise((resolve) => setTimeout(resolve, TEST_EXPIRY_MS + 200)) - // Verify the expired invite was cleaned up during update - const all = await api.getAll() - assert.equal(all.length, 1) - assert.equal( - all[0].inviteId, - inviteId.toString('hex'), - 'only fresh invite remains' - ) + const invites = await api.getAll() assert.equal( - all[0].inviteeDeviceId, - inviteeDeviceId, - 'fresh invite was updated correctly' + invites.length, + 0, + 'singular invite was auto-cleaned by scheduled expiry' ) }) -test('clearExpired - non-expired invites are not affected', async () => { - const inviteId = randomBytes(32) - const { api } = setup({ +test('scheduleExpired - cancelling timer on close', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, seedPendingInvites: [ { projectId: PROJECT_ID, - inviteId: inviteId.toString('hex'), - inviteIdBuffer: inviteId, - url: 'https://example.com/fresh', + inviteId: 'close-1', + inviteIdBuffer: Buffer.from('close-1'), + url: 'https://example.com/close', roleId: MEMBER_ROLE_ID, + createdAt: Date.now() - TEST_EXPIRY_MS + 5, }, ], }) + await api.ready() + api.close() + const invites = await api.getAll() - assert.equal(invites.length, 1, 'fresh invite is returned') - assert.equal(invites[0].inviteId, inviteId.toString('hex')) + assert.equal(invites.length, 1, 'invite still exists (close cancelled timer)') }) -test('clearExpired - sets shouldListenOverInternet to false when all invites expire', async () => { - const { api, db, getShouldListenOverInternet } = setup({ +test('scheduleExpired - deleting last invite cancels timer', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, seedPendingInvites: [ { projectId: PROJECT_ID, - inviteId: 'expired-1', - inviteIdBuffer: Buffer.from('expired-1'), - url: 'https://example.com/expired', + inviteId: 'delete-cancels-1', + inviteIdBuffer: Buffer.from('delete-cancels-1'), + url: 'https://example.com/delete-cancels', roleId: MEMBER_ROLE_ID, }, ], }) await api.ready() + await api.delete('delete-cancels-1') - // _open() already called setShouldListenOverInternet(true) because invite exists - assert.deepEqual(getShouldListenOverInternet(), [true]) - - // Expire the invite - await db - .update(pendingInvitesTable) - .set({ createdAt: Date.now() - EXPIRY_MS - 1000 }) - .where(eq(pendingInvitesTable.inviteId, 'expired-1')) - - await api.getAll() + const invites = await api.getAll() + assert.equal(invites.length, 0, 'invite was deleted') - assert.deepEqual( - getShouldListenOverInternet(), - [true, false], - 'called with false when all invites were expired and deleted' + await new Promise((resolve) => setTimeout(resolve, 200)) + const stillGone = await api.getAll() + assert.equal( + stillGone.length, + 0, + 'no timer firing after delete (none to check against)' ) }) From b6ec012ec6289d3b462b7f8f28f2093f82df3e40 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 25 May 2026 10:03:58 -0400 Subject: [PATCH 046/103] chore: Remove in memory pending invites map --- src/member-api.js | 165 +++++++++++++++++---------------------------- test/member-api.js | 4 -- 2 files changed, 62 insertions(+), 107 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 3078b375a..35d68dc82 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -12,7 +12,7 @@ import { Logger } from './logger.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' -import ReadyResource from 'ready-resource' +import { TypedEmitter } from 'tiny-typed-emitter' import { AlreadyBlockedError, DeviceIdNotForServerError, @@ -56,8 +56,6 @@ import { export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] -/** @import { TypedEmitter } from 'tiny-typed-emitter' */ - /** * @import { * DeviceInfo, @@ -126,9 +124,9 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] */ /** - * @type {ReadyResource & TypedEmitter} + * @extends {TypedEmitter} */ -export class MemberApi extends ReadyResource { +export class MemberApi extends TypedEmitter { #ownDeviceId #roles #encryptionKeys @@ -149,9 +147,6 @@ export class MemberApi extends ReadyResource { /** @type {Map} */ #outboundInvitesByDevice = new Map() - /** @type {Map} */ - #pendingInvitesOverInternet = new Map() - /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string @@ -214,41 +209,17 @@ export class MemberApi extends ReadyResource { this.#rpc.on('peer-remove', (peer) => this.#handlePeerRemove(peer)) } - async _open() { - // Load pending invites from database - const persistedInvites = await this.#pendingInvitesApi.getAll() - for (const row of persistedInvites) { - this.#pendingInvitesOverInternet.set(row.inviteId, { - inviteId: row.inviteIdBuffer, - url: row.url, - opts: { - roleId: row.roleId, - roleName: row.roleName, - roleDescription: row.roleDescription, - }, - inviteeDeviceId: row.inviteeDeviceId ?? undefined, - }) - } - } - /** * Start inviting somone over the internet. Returns a URL for the recipient to load. * @param {InviteOptions} opts */ async inviteOverInternet(opts) { - await this.ready() const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') const deviceId = this.#getSwarmPublicKey().toString('hex') const url = makeInviteURL(inviteIdString, deviceId) - this.#pendingInvitesOverInternet.set(inviteIdString, { - inviteId, - url, - opts, - }) - await this.#pendingInvitesApi.create({ inviteId: inviteIdString, inviteIdBuffer: inviteId, @@ -264,12 +235,11 @@ export class MemberApi extends ReadyResource { * @param {string} [url] */ async cancelInviteOverInternet(url) { - await this.ready() if (!url) { - for (const inviteId of this.#pendingInvitesOverInternet.keys()) { - this.emit('internet-invite-cancelled', inviteId) + const invites = await this.#pendingInvitesApi.getAll() + for (const invite of invites) { + this.emit('internet-invite-cancelled', invite.inviteId) } - this.#pendingInvitesOverInternet.clear() await this.#pendingInvitesApi.deleteAll() return } @@ -282,10 +252,9 @@ export class MemberApi extends ReadyResource { * @param {string} inviteIdString */ async #cancelInviteOverInternetById(inviteIdString) { - if (!this.#pendingInvitesOverInternet.has(inviteIdString)) { + if (!(await this.#pendingInvitesApi.getById(inviteIdString))) { throw new InvalidInternetInviteURLError() } - this.#pendingInvitesOverInternet.delete(inviteIdString) this.emit('internet-invite-cancelled', inviteIdString) await this.#pendingInvitesApi.delete(inviteIdString) } @@ -295,8 +264,8 @@ export class MemberApi extends ReadyResource { * @returns {Promise} */ async pendingInternetInvites() { - await this.ready() - return [...this.#pendingInvitesOverInternet.values()].map(({ url }) => url) + const invites = await this.#pendingInvitesApi.getAll() + return invites.map(({ url }) => url) } /** @@ -304,14 +273,12 @@ export class MemberApi extends ReadyResource { * @param {PeerInfoDisconnected} peer */ async #handlePeerRemove(peer) { - for (const [ - pendingInviteId, - pendingInvite, - ] of this.#pendingInvitesOverInternet.entries()) { - if (pendingInvite.inviteeDeviceId === peer.deviceId) { - // TODO: Handle errors? Emit event saying this happened? - this.#cancelInviteOverInternetById(pendingInviteId).catch((e) => { - this.#l.log('Error: Unable to cancel invite', pendingInviteId, e) + if (!peer) return + const invites = await this.#pendingInvitesApi.getAll() + for (const invite of invites) { + if (invite.inviteeDeviceId === peer.deviceId) { + this.#cancelInviteOverInternetById(invite.inviteId).catch((e) => { + this.#l.log('Error: Unable to cancel invite', invite.inviteId, e) }) break } @@ -327,32 +294,34 @@ export class MemberApi extends ReadyResource { const inviteIdString = inviteId.toString('hex') this.#l.log('Got incoming invite redeem', inviteIdString.slice(0, 7)) - try { - for (const [ - pendingInviteId, - pendingInvite, - ] of this.#pendingInvitesOverInternet.entries()) { - if (pendingInviteId !== inviteIdString) continue - - if (pendingInvite.inviteeDeviceId) { - this.emit( - 'internet-invite-redeem-error', - new InviteAlreadyRedeemedError(), - peerId, - inviteIdString - ) - await this.#disconnectFromPeer(peerId) - return - } + const invite = await this.#pendingInvitesApi.getById(inviteIdString) + if (!invite) { + this.emit( + 'internet-invite-redeem-error', + new UnknownInviteIDRedeemAttemptError(), + peerId, + inviteIdString + ) + await this.#disconnectFromPeer(peerId) + return + } - pendingInvite.inviteeDeviceId = peerId - await this.#pendingInvitesApi.update(inviteIdString, { - inviteeDeviceId: peerId, - }) + if (invite.inviteeDeviceId) { + this.emit( + 'internet-invite-redeem-error', + new InviteAlreadyRedeemedError(), + peerId, + inviteIdString + ) + await this.#disconnectFromPeer(peerId) + return + } - this.emit('internet-invite-redeemed', peerId, inviteIdString) - return - } + try { + await this.#pendingInvitesApi.update(inviteIdString, { + inviteeDeviceId: peerId, + }) + this.emit('internet-invite-redeemed', peerId, inviteIdString) } catch (e) { this.emit( 'internet-invite-redeem-error', @@ -361,17 +330,7 @@ export class MemberApi extends ReadyResource { inviteIdString ) await this.#disconnectFromPeer(peerId) - return } - - this.emit( - 'internet-invite-redeem-error', - new UnknownInviteIDRedeemAttemptError(), - peerId, - inviteIdString - ) - - await this.#disconnectFromPeer(peerId) } /** @@ -380,31 +339,31 @@ export class MemberApi extends ReadyResource { * @returns {Promise} */ async acceptRedeemedInvite(inviteId) { - await this.ready() - for (const [ - pendingInviteId, - { opts, inviteeDeviceId }, - ] of this.#pendingInvitesOverInternet.entries()) { - if (pendingInviteId !== inviteId) continue - if (!inviteeDeviceId) { - throw new InviteNotYetRedeemedError() - } - - const stillConnected = await this.#markInternetPeerAsTrusted( - inviteeDeviceId - ) + const pendingInvite = await this.#pendingInvitesApi.getById(inviteId) + if (!pendingInvite) { + throw new UnknownInviteIDError() + } + const { inviteeDeviceId, roleId, roleName, roleDescription } = pendingInvite + if (!inviteeDeviceId) { + throw new InviteNotYetRedeemedError() + } - // If they aren't connected after redeeming, we should mark it as cancelled - if (!stillConnected) { - await this.cancelInviteOverInternet(pendingInviteId) - throw new PeerDisconnectedSinceRedeemingInviteError() - } - const decision = await this.invite(inviteeDeviceId, opts) + const stillConnected = await this.#markInternetPeerAsTrusted( + inviteeDeviceId + ) - return decision + // If they aren't connected after redeeming, we should mark it as cancelled + if (!stillConnected) { + await this.cancelInviteOverInternet(inviteId) + throw new PeerDisconnectedSinceRedeemingInviteError() } + const decision = await this.invite(inviteeDeviceId, { + roleId, + roleName, + roleDescription, + }) - throw new UnknownInviteIDError() + return decision } /** diff --git a/test/member-api.js b/test/member-api.js index 2421c3f17..fe94f6758 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -125,10 +125,6 @@ test('Pending invites are loaded from persistence on ready', async () => { pendingInvitesApi, }) - // Wait for member API to be ready (loads pending invites) - await member.ready() - - // Verify the pending invite was loaded const pending = await member.pendingInternetInvites() assert.deepEqual(pending, [url], 'Pending invite loaded from persistence') }) From a93aa859297306d5c028b3b381c54764703e1bf0 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 25 May 2026 10:33:55 -0400 Subject: [PATCH 047/103] chore: Use swarmPublicKEy instead of deviceId in invite links --- src/mapeo-manager.js | 9 ++++----- src/member-api.js | 21 ++++++++++++--------- test/member-api.js | 12 ++++++------ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index e9192474e..cc446b5a9 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -751,13 +751,12 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async joinProjectOverInternet(url, { timeout = 60_000 } = {}) { - const { deviceId: swarmPublicKeyHex, inviteIdString } = parseInviteURL(url) + const { swarmPublicKey, inviteIdString } = parseInviteURL(url) const inviteId = Buffer.from(inviteIdString, 'hex') - const connection = await this.#remoteDiscovery.connectPeer( - swarmPublicKeyHex, - { timeout } - ) + const connection = await this.#remoteDiscovery.connectPeer(swarmPublicKey, { + timeout, + }) const onClose = pEvent(connection, 'close').then( () => { throw new InviteRedeemConnectionClosedError() diff --git a/src/member-api.js b/src/member-api.js index 35d68dc82..70a6bfc26 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -216,9 +216,9 @@ export class MemberApi extends TypedEmitter { async inviteOverInternet(opts) { const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') - const deviceId = this.#getSwarmPublicKey().toString('hex') + const swarmPublicKey = this.#getSwarmPublicKey().toString('hex') - const url = makeInviteURL(inviteIdString, deviceId) + const url = makeInviteURL(inviteIdString, swarmPublicKey) await this.#pendingInvitesApi.create({ inviteId: inviteIdString, @@ -986,7 +986,7 @@ async function parseAddServerResponse(response) { /** * @param {string} url - * @returns {{inviteIdString: string, deviceId: string}} + * @returns {{inviteIdString: string, swarmPublicKey: string}} */ export function parseInviteURL(url) { const { hash } = new URL(url) @@ -994,22 +994,25 @@ export function parseInviteURL(url) { const params = new URLSearchParams(hash.slice(1)) const inviteIdString = params.get('i') - const deviceId = params.get('d') + const swarmPublicKey = params.get('d') - if (typeof inviteIdString !== 'string' || typeof deviceId !== 'string') { + if ( + typeof inviteIdString !== 'string' || + typeof swarmPublicKey !== 'string' + ) { throw new MissingInviteAndDeviceParamsError() } - return { inviteIdString, deviceId } + return { inviteIdString, swarmPublicKey } } /** * * @param {string} inviteIdString - * @param {string} deviceId + * @param {string} swarmPublicKey * @returns {string} */ -export function makeInviteURL(inviteIdString, deviceId) { - const url = INTERNET_INVITE_PAGE + `#i=${inviteIdString}&d=${deviceId}` +export function makeInviteURL(inviteIdString, swarmPublicKey) { + const url = INTERNET_INVITE_PAGE + `#i=${inviteIdString}&d=${swarmPublicKey}` return url } diff --git a/test/member-api.js b/test/member-api.js index fe94f6758..c417d93c2 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -13,15 +13,15 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import {PendingInviteRecord, PendingInviteCreate,PendingInviteUpdate} from '../src/invite/pending-invites-api.js' */ test('serialize and parse invite URLs', () => { - const testDeviceId = 'foo' + const testSwarmPublicKey = 'foo' const testInviteId = 'bar' - const url = makeInviteURL(testInviteId, testDeviceId) + const url = makeInviteURL(testInviteId, testSwarmPublicKey) - const { inviteIdString, deviceId } = parseInviteURL(url) + const { inviteIdString, swarmPublicKey } = parseInviteURL(url) assert.equal(inviteIdString, testInviteId) - assert.equal(deviceId, testDeviceId) + assert.equal(swarmPublicKey, testSwarmPublicKey) }) test('List pending invites over internet', async () => { @@ -107,8 +107,8 @@ test('Pending invites are loaded from persistence on ready', async () => { const pendingInvitesApi = new MockPendingInvitesApiForProject() const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') - const deviceId = randomBytes(32).toString('hex') - const url = makeInviteURL(inviteIdString, deviceId) + const swarmPublicKey = randomBytes(32).toString('hex') + const url = makeInviteURL(inviteIdString, swarmPublicKey) // Pre-populate the mock with a pending invite await pendingInvitesApi.create({ From 05d126818ceed644adff0625f904dcd579ada8ee Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 25 May 2026 10:38:14 -0400 Subject: [PATCH 048/103] chore: Fix timeout for handshake read chunk to be 10s --- src/discovery/remote-discovery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 2655eae37..961e1a232 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -246,7 +246,7 @@ async function readChunk(stream, length) { if (!data) { try { await pEvent(stream, 'readable', { - timeout: 10_00, + timeout: 10_000, rejectionEvents: ['error', 'close'], }) data = stream.read() From 6a9bf285a5356c87ed19e0568d65a597997cb0d2 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 25 May 2026 13:09:16 -0400 Subject: [PATCH 049/103] fix: Clear member api listeners on project close --- src/mapeo-project.js | 2 ++ src/member-api.js | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index de9885bb0..f07326113 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -630,9 +630,11 @@ export class MapeoProject extends ReadyResource { } /** + * Clear up resources via ready-resource */ async _close() { this.#l.log('closing project %h', this.#projectId) + await this.#memberApi.close() const dataStorePromises = [] for (const dataStore of Object.values(this.#dataStores)) { dataStorePromises.push(dataStore.close()) diff --git a/src/member-api.js b/src/member-api.js index 70a6bfc26..daf5849ce 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -206,7 +206,11 @@ export class MemberApi extends TypedEmitter { this.#rpc.on('invite-over-internet-redeemed', (peerId, redeem) => this.#handleRedeemInviteOverInternet(peerId, redeem) ) - this.#rpc.on('peer-remove', (peer) => this.#handlePeerRemove(peer)) + this.#rpc.on('peer-remove', this.#handlePeerRemove) + } + + async close() { + this.#rpc.removeListener('peer-remove', this.#handlePeerRemove) } /** @@ -272,7 +276,7 @@ export class MemberApi extends TypedEmitter { * * @param {PeerInfoDisconnected} peer */ - async #handlePeerRemove(peer) { + #handlePeerRemove = async (peer) => { if (!peer) return const invites = await this.#pendingInvitesApi.getAll() for (const invite of invites) { From f319964e66ddb7c4d4aad26810bf049c4591e9b9 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 26 May 2026 14:26:48 -0400 Subject: [PATCH 050/103] feat: Allow invite to be reused until it naturally expires --- src/discovery/remote-discovery.js | 3 +- src/invite/pending-invites-api.js | 37 ---------- src/member-api.js | 73 +++++++++++------- src/schema/client.js | 1 - test-e2e/invite-over-internet.js | 115 ++++++++++++++++++++++------- test/discovery/remote-discovery.js | 84 +++++++++++++++++++++ test/member-api.js | 25 +------ test/pending-invites-api.js | 87 +--------------------- 8 files changed, 227 insertions(+), 198 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 961e1a232..1f7139d86 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -68,7 +68,7 @@ export class RemoteDiscovery extends TypedEmitter { const swarm = new Hyperswarm({ keyPair: this.#deriveSwarmIdentityKeypair(), - maxPeers: 4, + maxPeers: 16, }) // @ts-expect-error Hyperswarm lacks the expected utility class to mark the stream as opened swarm.on('connection', this.#handleHyperswarmConnection.bind(this)) @@ -102,6 +102,7 @@ export class RemoteDiscovery extends TypedEmitter { * Close all connections and stop listening */ async stop() { + this.#l.log('Suspending swarm') await this.#loading await this.#swarm?.suspend() } diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index 6b42acdc9..0b5abbfb2 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -20,7 +20,6 @@ import { deNullify } from '../utils.js' * @property {import('../roles.js').RoleIdForNewInvite} roleId * @property {string} [roleName] * @property {string} [roleDescription] - * @property {string} [inviteeDeviceId] Device ID of invitee (set when redeemed) * @property {number} createdAt Timestamp when created */ @@ -33,11 +32,6 @@ import { deNullify } from '../utils.js' * @property {InviteOptions} opts */ -/** - * @typedef {object} PendingInviteUpdate - * @property {string} inviteeDeviceId - */ - const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 export class PendingInvitesApiForProject { @@ -87,17 +81,6 @@ export class PendingInvitesApiForProject { return this.#pendingInvitesApi.getAllForProject(this.#projectId) } - /** - * Update a pending invite (e.g., set invitee device ID when redeemed) - * @param {string} inviteId - * @param {PendingInviteUpdate} updates - * @returns {Promise} - */ - async update(inviteId, updates) { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.update(inviteId, this.#projectId, updates) - } - /** * Delete a pending invite * @param {string} inviteId @@ -346,26 +329,6 @@ export class PendingInvitesApi extends ReadyResource { }) } - /** - * Update a pending invite (e.g., set invitee device ID when redeemed) - * @param {string} inviteId - * @param {string} projectId - * @param {PendingInviteUpdate} updates - * @returns {Promise} - */ - async update(inviteId, projectId, updates) { - await this.ready() - await this.#db - .update(pendingInvitesTable) - .set(updates) - .where( - and( - eq(pendingInvitesTable.inviteId, inviteId), - eq(pendingInvitesTable.projectId, projectId) - ) - ) - } - /** * Delete a pending invite * @param {string} inviteId diff --git a/src/member-api.js b/src/member-api.js index daf5849ce..2fc926ad8 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -147,6 +147,10 @@ export class MemberApi extends TypedEmitter { /** @type {Map} */ #outboundInvitesByDevice = new Map() + /** Track which device IDs have redeemed each invite (by inviteId) */ + /** @type {Map>} */ + #redeemedInvites = new Map() + /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string @@ -155,7 +159,7 @@ export class MemberApi extends TypedEmitter { * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc - * @param {Pick} opts.pendingInvitesApi + * @param {Pick} opts.pendingInvitesApi * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer @@ -259,6 +263,7 @@ export class MemberApi extends TypedEmitter { if (!(await this.#pendingInvitesApi.getById(inviteIdString))) { throw new InvalidInternetInviteURLError() } + this.#redeemedInvites.delete(inviteIdString) this.emit('internet-invite-cancelled', inviteIdString) await this.#pendingInvitesApi.delete(inviteIdString) } @@ -273,33 +278,40 @@ export class MemberApi extends TypedEmitter { } /** - * + * When a peer disconnects, remove them from the redeemed invites set. * @param {PeerInfoDisconnected} peer */ #handlePeerRemove = async (peer) => { if (!peer) return - const invites = await this.#pendingInvitesApi.getAll() - for (const invite of invites) { - if (invite.inviteeDeviceId === peer.deviceId) { - this.#cancelInviteOverInternetById(invite.inviteId).catch((e) => { - this.#l.log('Error: Unable to cancel invite', invite.inviteId, e) - }) + for (const deviceIds of this.#redeemedInvites.values()) { + if (deviceIds.has(peer.deviceId)) { + deviceIds.delete(peer.deviceId) break } } } /** - * + * Handle an incoming redeem attempt from the RPC layer. * @param {string} peerId * @param {RedeemInviteOverInternet} redeem */ async #handleRedeemInviteOverInternet(peerId, { inviteId }) { const inviteIdString = inviteId.toString('hex') - this.#l.log('Got incoming invite redeem', inviteIdString.slice(0, 7)) + this.#l.log( + 'Got incoming invite redeem', + inviteIdString.slice(0, 7), + 'from', + peerId + ) const invite = await this.#pendingInvitesApi.getById(inviteIdString) if (!invite) { + this.#l.log( + 'Incoming invite was invalid, disconnecting', + inviteIdString.slice(0, 7) + ) + this.emit( 'internet-invite-redeem-error', new UnknownInviteIDRedeemAttemptError(), @@ -310,7 +322,12 @@ export class MemberApi extends TypedEmitter { return } - if (invite.inviteeDeviceId) { + const redeemedSet = this.#redeemedInvites.get(inviteIdString) + if (redeemedSet?.has(peerId)) { + this.#l.log( + 'Incoming invite was already redeemed, disconnecting', + inviteIdString.slice(0, 7) + ) this.emit( 'internet-invite-redeem-error', new InviteAlreadyRedeemedError(), @@ -322,9 +339,11 @@ export class MemberApi extends TypedEmitter { } try { - await this.#pendingInvitesApi.update(inviteIdString, { - inviteeDeviceId: peerId, - }) + if (!redeemedSet) { + this.#redeemedInvites.set(inviteIdString, new Set([peerId])) + } else { + redeemedSet.add(peerId) + } this.emit('internet-invite-redeemed', peerId, inviteIdString) } catch (e) { this.emit( @@ -338,30 +357,30 @@ export class MemberApi extends TypedEmitter { } /** - * Accept a device's attempt at redeeming an invite - * @param {string} inviteId + * Accept a specific device's attempt at redeeming an invite. + * @param {object} opts + * @param {string} opts.inviteId + * @param {string} opts.deviceId * @returns {Promise} */ - async acceptRedeemedInvite(inviteId) { + async acceptRedeemedInvite({ inviteId, deviceId }) { + const redeemedSet = this.#redeemedInvites.get(inviteId) + if (!redeemedSet || !redeemedSet.has(deviceId)) { + throw new InviteNotYetRedeemedError() + } + const pendingInvite = await this.#pendingInvitesApi.getById(inviteId) if (!pendingInvite) { throw new UnknownInviteIDError() } - const { inviteeDeviceId, roleId, roleName, roleDescription } = pendingInvite - if (!inviteeDeviceId) { - throw new InviteNotYetRedeemedError() - } - const stillConnected = await this.#markInternetPeerAsTrusted( - inviteeDeviceId - ) + const stillConnected = await this.#markInternetPeerAsTrusted(deviceId) - // If they aren't connected after redeeming, we should mark it as cancelled if (!stillConnected) { - await this.cancelInviteOverInternet(inviteId) throw new PeerDisconnectedSinceRedeemingInviteError() } - const decision = await this.invite(inviteeDeviceId, { + const { roleId, roleName, roleDescription } = pendingInvite + const decision = await this.invite(deviceId, { roleId, roleName, roleDescription, diff --git a/src/schema/client.js b/src/schema/client.js index 885105bfd..7e9d731a0 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -58,6 +58,5 @@ export const pendingInvitesTable = sqliteTable('pendingInvites', { roleId: text('roleId').notNull(), roleName: text('roleName'), roleDescription: text('roleDescription'), - inviteeDeviceId: text('inviteeDeviceId'), createdAt: int('createdAt').notNull(), }) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index c4b47f794..263e126ef 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -10,7 +10,7 @@ import { TimeoutError, InviteRedeemConnectionClosedError, ensureKnownError, - UnknownInviteIDError, + InviteNotYetRedeemedError, } from '../src/errors.js' import crypto from 'node:crypto' import { temporaryDirectory } from 'tempy' @@ -46,7 +46,10 @@ test('invite over internet and join from URL', async (t) => { // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptRedeemedInvite(inviteId) + const reason = await project.$member.acceptRedeemedInvite({ + inviteId, + deviceId, + }) assert.equal(reason, InviteResponse_Decision.ACCEPT) @@ -57,7 +60,7 @@ test('invite over internet and join from URL', async (t) => { // TODO: Test that initial sync happened }) -test.only('invite over internet, close, reopen, and join from URL', async (t) => { +test('invite over internet, close, reopen, and join from URL', async (t) => { const dbFolder = temporaryDirectory() const coreStorage = temporaryDirectory() const directories = [dbFolder, coreStorage] @@ -120,7 +123,10 @@ test.only('invite over internet, close, reopen, and join from URL', async (t) => // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptRedeemedInvite(inviteId) + const reason = await project.$member.acceptRedeemedInvite({ + inviteId, + deviceId, + }) assert.equal(reason, InviteResponse_Decision.ACCEPT) @@ -129,6 +135,69 @@ test.only('invite over internet, close, reopen, and join from URL', async (t) => assert.equal(gotProjectId, projectId, 'joined expected project') }) +test('invite over internet can be redeemed by multiple peers', async (t) => { + const managers = await createManagers(3, t) + const [invitor, invitee1, invitee2] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.inviteOverInternet({ + roleId: MEMBER_ROLE_ID, + }) + + // First invitee joins + const onFirstInviteRedeemAttempt = pEvent( + project.$member, + 'internet-invite-redeemed', + { multiArgs: true, timeout: 5000 } + ) + const onFirstInvited = invitee1.joinProjectOverInternet(url) + + const [firstDeviceId, firstInviteId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onFirstInviteRedeemAttempt) + ) + assert.equal(firstDeviceId, invitee1.deviceId) + const firstReason = await project.$member.acceptRedeemedInvite({ + inviteId: firstInviteId, + deviceId: firstDeviceId, + }) + assert.equal(firstReason, InviteResponse_Decision.ACCEPT) + await onFirstInvited + + // Second invitee joins via the same URL + const onSecondInviteRedeemAttempt = pEvent( + project.$member, + 'internet-invite-redeemed', + { multiArgs: true, timeout: 5000 } + ) + const onSecondInvited = invitee2.joinProjectOverInternet(url) + + const [secondDeviceId, secondInviteId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onSecondInviteRedeemAttempt) + ) + assert.equal( + secondDeviceId, + invitee2.deviceId, + 'Second invitee redeemed successfully' + ) + assert.equal(secondInviteId, firstInviteId, 'Invite ID is the same for both') + const secondReason = await project.$member.acceptRedeemedInvite({ + inviteId: secondInviteId, + deviceId: secondDeviceId, + }) + assert.equal(secondReason, InviteResponse_Decision.ACCEPT) + await onSecondInvited + + // Verify invite is still pending for future redeemers + const pending = await project.$member.pendingInternetInvites() + assert.deepEqual(pending, [url], 'Invite still pending after two redeems') +}) + test('invite over internet errors if invitor deviceID is invalid', async (t) => { const managers = await createManagers(2, t) const [invitor, invitee] = managers @@ -178,11 +247,9 @@ test('invite over internet errors if inviter closes before accepting', async (t) const onInviteRedeemAttempt = pEvent( project.$member, 'internet-invite-redeemed', - { multiArgs: true, timeout: 5000 } + { multiArgs: true, timeout: 50000 } ) - const onInviteCancelled = pEvent(project.$member, 'internet-invite-cancelled') - const onInvited = invitee.joinProjectOverInternet(url) const [deviceId, attemptedRedeemId] = /** @type {[string, string]} */ ( @@ -191,26 +258,24 @@ test('invite over internet errors if inviter closes before accepting', async (t) assert.equal(deviceId, invitee.deviceId) - // Close the invitor before accepting - invitor.close() - - // The invitee's join should fail because the invitor disconnected - await assert.rejects( - onInvited, - (err) => - ensureKnownError(err).code === InviteRedeemConnectionClosedError.code - ) - - const cancelledId = await onInviteCancelled - assert.equal(cancelledId, attemptedRedeemId, 'redeemed invite got cancelled') - - const pending = await project.$member.pendingInternetInvites() - - assert.equal(pending.length, 0, 'Invite got cancelled on fail') + await Promise.all([ + // The invitee's join should fail because the invitor disconnected + assert.rejects( + onInvited, + (err) => + ensureKnownError(err).code === InviteRedeemConnectionClosedError.code + ), + // Close the invitor before accepting + invitor.close(), + ]) await assert.rejects( - () => project.$member.acceptRedeemedInvite(attemptedRedeemId), - (err) => ensureKnownError(err).code === UnknownInviteIDError.code, + () => + project.$member.acceptRedeemedInvite({ + inviteId: attemptedRedeemId, + deviceId: invitee.deviceId, + }), + (err) => ensureKnownError(err).code === InviteNotYetRedeemedError.code, 'Accepting after a disconnect causes an error' ) }) diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 654cfd266..5d8a38a61 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -279,6 +279,90 @@ test('RemoteDiscovery - connectPeer returns same socket for duplicate connection ) }) +test('RemoteDiscovery - connect two peers to a third peer', async (t) => { + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const identityKeypair2 = new KeyManager( + Buffer.alloc(16, 2) + ).getIdentityKeypair() + const identityKeypair3 = new KeyManager( + Buffer.alloc(16, 3) + ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 5)).getIdentityKeypair() + const swarmKeypair3 = new KeyManager(Buffer.alloc(16, 6)).getIdentityKeypair() + + // Peer 1 is the "host" - two peers will connect to it + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, + }) + const remoteDiscovery2 = new RemoteDiscovery({ + identityKeypair: identityKeypair2, + deriveSwarmIdentityKeypair: () => swarmKeypair2, + }) + const remoteDiscovery3 = new RemoteDiscovery({ + identityKeypair: identityKeypair3, + deriveSwarmIdentityKeypair: () => swarmKeypair3, + }) + + t.after(() => + Promise.all([ + remoteDiscovery1.close(), + remoteDiscovery2.close(), + remoteDiscovery3.close(), + ]) + ) + + await Promise.all([ + remoteDiscovery1.start(), + remoteDiscovery2.start(), + remoteDiscovery3.start(), + ]) + + const swarmPublicKey1Hex = swarmKeypair1.publicKey.toString('hex') + + // Listen for two inbound connections on peer 1 + const onConnectionFromPeer2 = pEvent(remoteDiscovery1, 'connection') + + // Peer 2 connects to peer 1 + const connectionPromise2 = remoteDiscovery2.connectPeer(swarmPublicKey1Hex) + const outboundStream2 = await connectionPromise2 + const inboundStream2 = await onConnectionFromPeer2 + + assert.ok( + inboundStream2.handshakePublicKey.equals(identityKeypair2.publicKey), + 'peer 1 should see peer 2 identity' + ) + assert.ok( + outboundStream2.handshakePublicKey.equals(identityKeypair1.publicKey), + 'peer 2 should see peer 1 identity' + ) + + // Now peer 3 also connects to peer 1 + const onConnectionFromPeer3 = pEvent(remoteDiscovery1, 'connection', { + timeout: 5000, + }) + const connectionPromise3 = remoteDiscovery3.connectPeer(swarmPublicKey1Hex) + const outboundStream3 = await connectionPromise3 + const inboundStream3 = await onConnectionFromPeer3 + + assert.ok( + inboundStream3.handshakePublicKey.equals(identityKeypair3.publicKey), + 'peer 1 should see peer 3 identity' + ) + assert.ok( + outboundStream3.handshakePublicKey.equals(identityKeypair1.publicKey), + 'peer 3 should see peer 1 identity' + ) + + inboundStream2.end() + outboundStream2.end() + inboundStream3.end() + outboundStream3.end() +}) + /** * @param {Error} e */ diff --git a/test/member-api.js b/test/member-api.js index c417d93c2..c161d2d87 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -10,7 +10,7 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ /** @import { InviteOptions } from '../src/member-api.js' */ -/** @import {PendingInviteRecord, PendingInviteCreate,PendingInviteUpdate} from '../src/invite/pending-invites-api.js' */ +/** @import { PendingInviteRecord, PendingInviteCreate } from '../src/invite/pending-invites-api.js' */ test('serialize and parse invite URLs', () => { const testSwarmPublicKey = 'foo' @@ -195,10 +195,7 @@ class MockPendingInvitesApiForProject { * @returns {Promise} */ async getById(inviteId) { - const invite = this.#invites.get(inviteId) - return invite - ? { ...invite, inviteeDeviceId: invite.inviteeDeviceId ?? undefined } - : undefined + return this.#invites.get(inviteId) } /** @@ -206,23 +203,7 @@ class MockPendingInvitesApiForProject { * @returns {Promise} */ async getAll() { - return Array.from(this.#invites.values()).map((invite) => ({ - ...invite, - inviteeDeviceId: invite.inviteeDeviceId ?? undefined, - })) - } - - /** - * Update a pending invite (e.g., set invitee device ID when redeemed) - * @param {string} inviteId - * @param {PendingInviteUpdate} updates - * @returns {Promise} - */ - async update(inviteId, updates) { - const invite = this.#invites.get(inviteId) - if (invite) { - Object.assign(invite, updates) - } + return [...this.#invites.values()] } /** diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index e93bd7dcd..6728bcd03 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -274,66 +274,6 @@ test('getAllForProject() - empty for project with no invites', async (t) => { ) }) -test('update() - set inviteeDeviceId', async (t) => { - const { api } = setup(t) - - const inviteId = randomBytes(32) - const inviteIdString = inviteId.toString('hex') - - await api.create({ - projectId: PROJECT_ID, - inviteId: inviteIdString, - inviteIdBuffer: inviteId, - url: 'https://example.com/invite', - opts: { roleId: MEMBER_ROLE_ID }, - }) - - const beforeUpdate = await api.getById(inviteIdString, PROJECT_ID) - assert.equal(beforeUpdate?.inviteeDeviceId, undefined) - - const inviteeDeviceId = randomBytes(32).toString('hex') - await api.update(inviteIdString, PROJECT_ID, { inviteeDeviceId }) - - const afterUpdate = await api.getById(inviteIdString, PROJECT_ID) - assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) -}) - -test('update() - scoped to project', async (t) => { - const { api } = setup(t) - - const inviteId = randomBytes(32) - const inviteIdString = inviteId.toString('hex') - const inviteeDeviceId = randomBytes(32).toString('hex') - - await api.create({ - projectId: PROJECT_ID, - inviteId: inviteIdString, - inviteIdBuffer: inviteId, - url: 'https://example.com/invite', - opts: { roleId: MEMBER_ROLE_ID }, - }) - - // Update with a different project ID should be a no-op - await api.update(inviteIdString, 'other-project-id', { inviteeDeviceId }) - - const afterUpdate = await api.getById(inviteIdString, PROJECT_ID) - assert.equal( - afterUpdate?.inviteeDeviceId, - undefined, - 'invite not updated with wrong project ID' - ) -}) - -test('update() - update non-existent invite', async (t) => { - const { api } = setup(t) - - const inviteeDeviceId = randomBytes(32).toString('hex') - await api.update('non-existent-id', PROJECT_ID, { inviteeDeviceId }) - - const result = await api.getById('non-existent-id', PROJECT_ID) - assert.equal(result, undefined, 'no-op for non-existent invite') -}) - test('delete() - single invite', async (t) => { const { api } = setup(t) @@ -552,12 +492,11 @@ test('Optional fields', async (t) => { ) }) -test('create() and getAll() with inviteeDeviceId already set', async (t) => { +test('create() and getAll()', async (t) => { const { api } = setup(t) const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') - const inviteeDeviceId = randomBytes(32).toString('hex') await api.create({ projectId: PROJECT_ID, @@ -567,11 +506,9 @@ test('create() and getAll() with inviteeDeviceId already set', async (t) => { opts: { roleId: MEMBER_ROLE_ID }, }) - await api.update(inviteIdString, PROJECT_ID, { inviteeDeviceId }) - const all = await api.getAll() assert.equal(all.length, 1) - assert.equal(all[0].inviteeDeviceId, inviteeDeviceId) + assert.equal(all[0].inviteId, inviteIdString) }) // Tests for PendingInvitesApiForProject (scoped wrapper) @@ -623,26 +560,6 @@ test('PendingInvitesApiForProject - getAll() returns scoped invites', async (t) assert.equal(invites[0].inviteId, invite1.toString('hex')) }) -test('PendingInvitesApiForProject - update() auto-scopes to project', async (t) => { - const { projectApi } = setup(t) - - const inviteId = randomBytes(32) - const inviteIdString = inviteId.toString('hex') - - await projectApi.create({ - inviteId: inviteIdString, - inviteIdBuffer: inviteId, - url: 'https://example.com/invite', - opts: { roleId: MEMBER_ROLE_ID }, - }) - - const inviteeDeviceId = randomBytes(32).toString('hex') - await projectApi.update(inviteIdString, { inviteeDeviceId }) - - const afterUpdate = await projectApi.getById(inviteIdString) - assert.equal(afterUpdate?.inviteeDeviceId, inviteeDeviceId) -}) - test('PendingInvitesApiForProject - delete() removes invite', async (t) => { const { projectApi } = setup(t) From 88dccaab1c07517d4b306037dce7ef6dfc5c295d Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 26 May 2026 15:38:50 -0400 Subject: [PATCH 051/103] chore: Rename PendingInvites to InviteLinks --- ...y_boy.sql => 0005_long_cassandra_nova.sql} | 5 +- drizzle/client/meta/0005_snapshot.json | 25 ++-- drizzle/client/meta/_journal.json | 4 +- src/errors.js | 6 +- src/invite/pending-invites-api.js | 128 +++++++++--------- src/mapeo-manager.js | 19 ++- src/mapeo-project.js | 11 +- src/member-api.js | 36 ++--- src/schema/client.js | 2 +- test-e2e/invite-over-internet.js | 16 +-- test-types/data-types.ts | 6 +- test/member-api.js | 71 +++++----- test/pending-invites-api.js | 42 +++--- 13 files changed, 175 insertions(+), 196 deletions(-) rename drizzle/client/{0005_massive_pretty_boy.sql => 0005_long_cassandra_nova.sql} (68%) diff --git a/drizzle/client/0005_massive_pretty_boy.sql b/drizzle/client/0005_long_cassandra_nova.sql similarity index 68% rename from drizzle/client/0005_massive_pretty_boy.sql rename to drizzle/client/0005_long_cassandra_nova.sql index d5cf586ac..e4eec53ed 100644 --- a/drizzle/client/0005_massive_pretty_boy.sql +++ b/drizzle/client/0005_long_cassandra_nova.sql @@ -1,11 +1,10 @@ -CREATE TABLE `pendingInvites` ( +CREATE TABLE `inviteLinks` ( `inviteId` text PRIMARY KEY NOT NULL, - `projectId` text KEY NOT NULL, + `projectId` text NOT NULL, `inviteIdBuffer` blob NOT NULL, `url` text NOT NULL, `roleId` text NOT NULL, `roleName` text, `roleDescription` text, - `inviteeDeviceId` text, `createdAt` integer NOT NULL ); diff --git a/drizzle/client/meta/0005_snapshot.json b/drizzle/client/meta/0005_snapshot.json index 30577d3e7..20d812e31 100644 --- a/drizzle/client/meta/0005_snapshot.json +++ b/drizzle/client/meta/0005_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "4241faac-fbc2-4482-b712-125a963b69c0", + "id": "03e80a08-1d4e-4389-865b-8c3fa7822ad9", "prevId": "2dec4710-70ac-4857-b889-2bc3586695ee", "tables": { "deviceSettings": { @@ -43,20 +43,20 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "pendingInvites": { - "name": "pendingInvites", + "inviteLinks": { + "name": "inviteLinks", "columns": { - "projectId": { - "name": "projectId", + "inviteId": { + "name": "inviteId", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "inviteId": { - "name": "inviteId", + "projectId": { + "name": "projectId", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, @@ -95,13 +95,6 @@ "notNull": false, "autoincrement": false }, - "inviteeDeviceId": { - "name": "inviteeDeviceId", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "createdAt": { "name": "createdAt", "type": "integer", diff --git a/drizzle/client/meta/_journal.json b/drizzle/client/meta/_journal.json index eedf7b7e1..60161481e 100644 --- a/drizzle/client/meta/_journal.json +++ b/drizzle/client/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "6", - "when": 1779209357699, - "tag": "0005_massive_pretty_boy", + "when": 1779823832990, + "tag": "0005_long_cassandra_nova", "breakpoints": true } ] diff --git a/src/errors.js b/src/errors.js index 08e7626f0..5692d7149 100644 --- a/src/errors.js +++ b/src/errors.js @@ -581,9 +581,9 @@ export const MissingInviteAndDeviceParamsError = createErrorClass({ status: 400, }) -export const PendingInviteAlreadyExistsError = createErrorClass({ - code: 'PENDING_INVITE_ALREADY_EXISTS_ERROR', - message: 'Pending invite with ID {inviteId} already exists', +export const InviteLinkAlreadyExistsError = createErrorClass({ + code: 'INVITE_LINK_ALREADY_EXISTS_ERROR', + message: 'Invite link with ID {inviteId} already exists', status: 409, }) diff --git a/src/invite/pending-invites-api.js b/src/invite/pending-invites-api.js index 0b5abbfb2..668ebe337 100644 --- a/src/invite/pending-invites-api.js +++ b/src/invite/pending-invites-api.js @@ -1,11 +1,11 @@ import { and, eq, sql } from 'drizzle-orm' import ReadyResource from 'ready-resource' -import { pendingInvitesTable } from '../schema/client.js' +import { inviteLinksTable } from '../schema/client.js' import { isRoleIdForNewInvite } from '../roles.js' import { ensureKnownError, getErrorCode, - PendingInviteAlreadyExistsError, + InviteLinkAlreadyExistsError, } from '../errors.js' import { deNullify } from '../utils.js' @@ -13,7 +13,7 @@ import { deNullify } from '../utils.js' /** @import { InviteOptions } from '../member-api.js' */ /** - * @typedef {object} PendingInviteRecord + * @typedef {object} InviteLinkRecord * @property {string} inviteId Hex string invite ID (primary key) * @property {Buffer} inviteIdBuffer Binary invite ID * @property {string} url Invite URL @@ -24,7 +24,7 @@ import { deNullify } from '../utils.js' */ /** - * @typedef {object} PendingInviteCreate + * @typedef {object} InviteLinkCreate * @property {string} projectId * @property {string} inviteId * @property {Buffer} inviteIdBuffer @@ -34,80 +34,80 @@ import { deNullify } from '../utils.js' const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 -export class PendingInvitesApiForProject { +export class InviteLinksApiForProject { #projectId - /** @type {PendingInvitesApi} */ - #pendingInvitesApi + /** @type {InviteLinksApi} */ + #inviteLinksApi /** * * @param {string} projectId - * @param {PendingInvitesApi} pendingInvitesApi + * @param {InviteLinksApi} inviteLinksApi */ - constructor(projectId, pendingInvitesApi) { + constructor(projectId, inviteLinksApi) { this.#projectId = projectId - this.#pendingInvitesApi = pendingInvitesApi + this.#inviteLinksApi = inviteLinksApi } /** - * Create a new pending invite record - * @param {Omit} data + * Create a new invite link record + * @param {Omit} data * @returns {Promise} */ async create(data) { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.create({ + await this.#inviteLinksApi.ready() + return this.#inviteLinksApi.create({ ...data, projectId: this.#projectId, }) } /** - * Get a pending invite by invite ID + * Get an invite link by invite ID * @param {string} inviteId - * @returns {Promise} + * @returns {Promise} */ async getById(inviteId) { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.getById(inviteId, this.#projectId) + await this.#inviteLinksApi.ready() + return this.#inviteLinksApi.getById(inviteId, this.#projectId) } /** - * Get all pending invites for the project - * @returns {Promise} + * Get all invite links for the project + * @returns {Promise} */ async getAll() { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.getAllForProject(this.#projectId) + await this.#inviteLinksApi.ready() + return this.#inviteLinksApi.getAllForProject(this.#projectId) } /** - * Delete a pending invite + * Delete an invite link * @param {string} inviteId * @returns {Promise} */ async delete(inviteId) { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.delete(inviteId) + await this.#inviteLinksApi.ready() + return this.#inviteLinksApi.delete(inviteId) } /** - * Delete all pending invites for the project + * Delete all invite links for the project * @returns {Promise} */ async deleteAll() { - await this.#pendingInvitesApi.ready() - return this.#pendingInvitesApi.deleteAllFrom(this.#projectId) + await this.#inviteLinksApi.ready() + return this.#inviteLinksApi.deleteAllFrom(this.#projectId) } } /** - * API for CRUD operations on pending invites over internet + * API for CRUD operations on invite links over internet */ /** * @type {ReadyResource} */ -export class PendingInvitesApi extends ReadyResource { +export class InviteLinksApi extends ReadyResource { /** @type {BetterSQLite3Database} */ #db #sql @@ -135,32 +135,32 @@ export class PendingInvitesApi extends ReadyResource { this.#sql = { getById: db .select() - .from(pendingInvitesTable) + .from(inviteLinksTable) .where( and( - eq(pendingInvitesTable.inviteId, sql.placeholder('inviteId')), - eq(pendingInvitesTable.projectId, sql.placeholder('projectId')) + eq(inviteLinksTable.inviteId, sql.placeholder('inviteId')), + eq(inviteLinksTable.projectId, sql.placeholder('projectId')) ) ) .limit(1) .prepare(), - getAll: db.select().from(pendingInvitesTable).prepare(), + getAll: db.select().from(inviteLinksTable).prepare(), getAllForProject: db .select() - .from(pendingInvitesTable) - .where(eq(pendingInvitesTable.projectId, sql.placeholder('projectId'))) + .from(inviteLinksTable) + .where(eq(inviteLinksTable.projectId, sql.placeholder('projectId'))) .prepare(), getExpired: db .select() - .from(pendingInvitesTable) + .from(inviteLinksTable) .where( - sql`${pendingInvitesTable.createdAt} < ${sql.placeholder('cutoff')}` + sql`${inviteLinksTable.createdAt} < ${sql.placeholder('cutoff')}` ) .prepare(), getOldest: db .select() - .from(pendingInvitesTable) - .orderBy(sql`${pendingInvitesTable.createdAt} ASC`) + .from(inviteLinksTable) + .orderBy(sql`${inviteLinksTable.createdAt} ASC`) .limit(1) .prepare(), } @@ -181,15 +181,15 @@ export class PendingInvitesApi extends ReadyResource { } /** - * Delete all pending invites whose createdAt timestamp is older than 24 hours. + * Delete all invite links whose createdAt timestamp is older than 24 hours. */ async #clearExpired() { const cutoff = Date.now() - this.#expiryMs const expired = this.#sql.getExpired.all({ cutoff }) for (const row of expired) { await this.#db - .delete(pendingInvitesTable) - .where(eq(pendingInvitesTable.inviteId, row.inviteId)) + .delete(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, row.inviteId)) } // Update the listen state once, after all deletions if (expired.length > 0) { @@ -244,14 +244,14 @@ export class PendingInvitesApi extends ReadyResource { } /** - * Create a new pending invite record - * @param {PendingInviteCreate} data + * Create a new invite link record + * @param {InviteLinkCreate} data * @returns {Promise} */ async create(data) { await this.ready() try { - await this.#db.insert(pendingInvitesTable).values({ + await this.#db.insert(inviteLinksTable).values({ projectId: data.projectId, inviteId: data.inviteId, inviteIdBuffer: data.inviteIdBuffer, @@ -270,17 +270,17 @@ export class PendingInvitesApi extends ReadyResource { } } catch (err) { if (getErrorCode(err) === 'SQLITE_CONSTRAINT_PRIMARYKEY') { - throw new PendingInviteAlreadyExistsError({ inviteId: data.inviteId }) + throw new InviteLinkAlreadyExistsError({ inviteId: data.inviteId }) } throw ensureKnownError(err) } } /** - * Get a pending invite by invite ID + * Get a invite link by invite ID * @param {string} inviteId * @param {string} projectId - * @returns {Promise} + * @returns {Promise} */ async getById(inviteId, projectId) { await this.ready() @@ -291,12 +291,12 @@ export class PendingInvitesApi extends ReadyResource { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ (deNullify(row)) + return /** @type {InviteLinkRecord} */ (deNullify(row)) } /** - * Get all pending invites - * @returns {Promise} + * Get all invite links + * @returns {Promise} */ async getAll() { await this.ready() @@ -306,14 +306,14 @@ export class PendingInvitesApi extends ReadyResource { if (!isRoleIdForNewInvite(row.roleId)) { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ (deNullify(row)) + return /** @type {InviteLinkRecord} */ (deNullify(row)) }) } /** - * Get all pending invites + * Get all invite links * @param {string} projectId - * @returns {Promise} + * @returns {Promise} */ async getAllForProject(projectId) { await this.ready() @@ -325,44 +325,44 @@ export class PendingInvitesApi extends ReadyResource { if (!isRoleIdForNewInvite(row.roleId)) { throw new Error(`Invalid roleId in database: ${row.roleId}`) } - return /** @type {PendingInviteRecord} */ (deNullify(row)) + return /** @type {InviteLinkRecord} */ (deNullify(row)) }) } /** - * Delete a pending invite + * Delete a invite link * @param {string} inviteId * @returns {Promise} */ async delete(inviteId) { await this.ready() await this.#db - .delete(pendingInvitesTable) - .where(eq(pendingInvitesTable.inviteId, inviteId)) + .delete(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteId)) await this.#checkSetShouldListenOverInternet(false) } /** - * Delete all pending invites + * Delete all invite links * @returns {Promise} */ async deleteAll() { await this.ready() - await this.#db.delete(pendingInvitesTable) + await this.#db.delete(inviteLinksTable) await this.#setShouldListenOverInternet(false) this.#cancelScheduleExpired() } /** - * Delete all pending invites in a specific project + * Delete all invite links in a specific project * @param {string} projectId */ async deleteAllFrom(projectId) { await this.ready() await this.#db - .delete(pendingInvitesTable) - .where(eq(pendingInvitesTable.projectId, projectId)) + .delete(inviteLinksTable) + .where(eq(inviteLinksTable.projectId, projectId)) await this.#checkSetShouldListenOverInternet(false) } diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index cc446b5a9..a351adb20 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -68,7 +68,7 @@ import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' import { parseInviteURL } from './member-api.js' import { pEvent } from 'p-event' -import { PendingInvitesApi } from './invite/pending-invites-api.js' +import { InviteLinksApi } from './invite/pending-invites-api.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ @@ -161,7 +161,7 @@ export class MapeoManager extends TypedEmitter { #projectMigrationsFolder #deviceId #localPeers - #pendingInvitesApi + #inviteLinks #invite #fastify #localDiscovery @@ -307,13 +307,10 @@ export class MapeoManager extends TypedEmitter { }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) - this.#pendingInvitesApi = new PendingInvitesApi( - this.#db, - (shouldListen) => { - if (shouldListen) return this.#remoteDiscovery.start() - else return this.#remoteDiscovery.stop() - } - ) + this.#inviteLinks = new InviteLinksApi(this.#db, (shouldListen) => { + if (shouldListen) return this.#remoteDiscovery.start() + else return this.#remoteDiscovery.stop() + }) } get deviceId() { @@ -642,7 +639,7 @@ export class MapeoManager extends TypedEmitter { sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, - pendingInvitesApi: this.#pendingInvitesApi, + inviteLinks: this.#inviteLinks, logger: this.#loggerBase, getMediaBaseUrl: this.#getMediaBaseUrl.bind(this), isArchiveDevice, @@ -1209,7 +1206,7 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async close() { - await this.#pendingInvitesApi.close() + await this.#inviteLinks.close() await this.#remoteDiscovery.close() // This added for workers PR // await this.#projectSettingsIndexWriter.close() diff --git a/src/mapeo-project.js b/src/mapeo-project.js index f07326113..c55884e2b 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -49,7 +49,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' -import { PendingInvitesApiForProject } from './invite/pending-invites-api.js' +import { InviteLinksApiForProject } from './invite/pending-invites-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -177,7 +177,7 @@ export class MapeoProject extends ReadyResource { * @param {(mediaType: 'blobs' | 'icons') => Promise} opts.getMediaBaseUrl * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {import('./local-peers.js').LocalPeers} opts.localPeers - * @param {import('./invite/pending-invites-api.js').PendingInvitesApi} opts.pendingInvitesApi + * @param {import('./invite/pending-invites-api.js').InviteLinksApi} opts.inviteLinks * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted @@ -198,7 +198,7 @@ export class MapeoProject extends ReadyResource { getMediaBaseUrl, makeWebsocket = (url) => new WebSocket(url), localPeers, - pendingInvitesApi, + inviteLinks, logger, isArchiveDevice, getFallbackProjectInfo, @@ -429,10 +429,7 @@ export class MapeoProject extends ReadyResource { encryptionKeys, projectKey, rpc: localPeers, - pendingInvitesApi: new PendingInvitesApiForProject( - this.#projectId, - pendingInvitesApi - ), + inviteLinks: new InviteLinksApiForProject(this.#projectId, inviteLinks), getSwarmPublicKey: this.#getSwarmPublicKey, makeWebsocket, getReplicationStream, diff --git a/src/member-api.js b/src/member-api.js index 2fc926ad8..22b8e6090 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -71,7 +71,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ -/** @import { PendingInvitesApiForProject } from './invite/pending-invites-api.js' */ +/** @import { InviteLinksApiForProject } from './invite/pending-invites-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -141,7 +141,7 @@ export class MemberApi extends TypedEmitter { #getDeviceInfo #setDeviceInfo #getSwarmPublicKey - #pendingInvitesApi + #inviteLinks #l /** @type {Map} */ @@ -159,7 +159,7 @@ export class MemberApi extends TypedEmitter { * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc - * @param {Pick} opts.pendingInvitesApi + * @param {Pick} opts.inviteLinks * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer @@ -176,7 +176,7 @@ export class MemberApi extends TypedEmitter { encryptionKeys, projectKey, rpc, - pendingInvitesApi, + inviteLinks, makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, @@ -195,7 +195,7 @@ export class MemberApi extends TypedEmitter { this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey this.#rpc = rpc - this.#pendingInvitesApi = pendingInvitesApi + this.#inviteLinks = inviteLinks this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer @@ -221,14 +221,14 @@ export class MemberApi extends TypedEmitter { * Start inviting somone over the internet. Returns a URL for the recipient to load. * @param {InviteOptions} opts */ - async inviteOverInternet(opts) { + async createInviteLink(opts) { const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') const swarmPublicKey = this.#getSwarmPublicKey().toString('hex') const url = makeInviteURL(inviteIdString, swarmPublicKey) - await this.#pendingInvitesApi.create({ + await this.#inviteLinks.create({ inviteId: inviteIdString, inviteIdBuffer: inviteId, url, @@ -242,38 +242,38 @@ export class MemberApi extends TypedEmitter { * Cancel an invite over internet attempt. Omit the specific URL to cancel all instances * @param {string} [url] */ - async cancelInviteOverInternet(url) { + async cancelInviteLink(url) { if (!url) { - const invites = await this.#pendingInvitesApi.getAll() + const invites = await this.#inviteLinks.getAll() for (const invite of invites) { this.emit('internet-invite-cancelled', invite.inviteId) } - await this.#pendingInvitesApi.deleteAll() + await this.#inviteLinks.deleteAll() return } const { inviteIdString } = parseInviteURL(url) - await this.#cancelInviteOverInternetById(inviteIdString) + await this.#cancelInviteLinkById(inviteIdString) } /** * Cancel an invite over internet attempt. * @param {string} inviteIdString */ - async #cancelInviteOverInternetById(inviteIdString) { - if (!(await this.#pendingInvitesApi.getById(inviteIdString))) { + async #cancelInviteLinkById(inviteIdString) { + if (!(await this.#inviteLinks.getById(inviteIdString))) { throw new InvalidInternetInviteURLError() } this.#redeemedInvites.delete(inviteIdString) this.emit('internet-invite-cancelled', inviteIdString) - await this.#pendingInvitesApi.delete(inviteIdString) + await this.#inviteLinks.delete(inviteIdString) } /** * Get the list of pending invites over the internet * @returns {Promise} */ - async pendingInternetInvites() { - const invites = await this.#pendingInvitesApi.getAll() + async listInviteLinks() { + const invites = await this.#inviteLinks.getAll() return invites.map(({ url }) => url) } @@ -305,7 +305,7 @@ export class MemberApi extends TypedEmitter { peerId ) - const invite = await this.#pendingInvitesApi.getById(inviteIdString) + const invite = await this.#inviteLinks.getById(inviteIdString) if (!invite) { this.#l.log( 'Incoming invite was invalid, disconnecting', @@ -369,7 +369,7 @@ export class MemberApi extends TypedEmitter { throw new InviteNotYetRedeemedError() } - const pendingInvite = await this.#pendingInvitesApi.getById(inviteId) + const pendingInvite = await this.#inviteLinks.getById(inviteId) if (!pendingInvite) { throw new UnknownInviteIDError() } diff --git a/src/schema/client.js b/src/schema/client.js index 7e9d731a0..957740309 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -50,7 +50,7 @@ export const deviceSettingsTable = sqliteTable('deviceSettings', { isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }), }) -export const pendingInvitesTable = sqliteTable('pendingInvites', { +export const inviteLinksTable = sqliteTable('inviteLinks', { inviteId: text('inviteId').notNull().primaryKey(), projectId: text('projectId').notNull(), inviteIdBuffer: blob('inviteIdBuffer', { mode: 'buffer' }).notNull(), diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 263e126ef..9d0d576bb 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -26,7 +26,7 @@ test('invite over internet and join from URL', async (t) => { }) const project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) @@ -91,7 +91,7 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { }) let project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) @@ -103,7 +103,7 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { project = await invitor.getProject(projectId) - const pending = await project.$member.pendingInternetInvites() + const pending = await project.$member.listInviteLinks() assert.deepEqual(pending, [url], 'Pending internet invites loaded on reload') @@ -146,7 +146,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { }) const project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) @@ -194,7 +194,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { await onSecondInvited // Verify invite is still pending for future redeemers - const pending = await project.$member.pendingInternetInvites() + const pending = await project.$member.listInviteLinks() assert.deepEqual(pending, [url], 'Invite still pending after two redeems') }) @@ -209,7 +209,7 @@ test('invite over internet errors if invitor deviceID is invalid', async (t) => }) const project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) @@ -240,7 +240,7 @@ test('invite over internet errors if inviter closes before accepting', async (t) }) const project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) @@ -291,7 +291,7 @@ test('invite over internet errors if invitee uses random invalid inviteId', asyn }) const project = await invitor.getProject(projectId) - const url = await project.$member.inviteOverInternet({ + const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) diff --git a/test-types/data-types.ts b/test-types/data-types.ts index eee232c24..519585582 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -19,7 +19,7 @@ import { DerivedDocFields } from '../dist/datatype/index.js' import { projectSettingsTable } from '../dist/schema/client.js' import { LocalPeers } from '../dist/local-peers.js' import { Expect, type Equal } from './utils.js' -import { PendingInvitesApi } from '../dist/invite/pending-invites-api.js' +import { InviteLinksApi } from '../dist/invite/pending-invites-api.js' type ObservationWithDerivedDocFields = Observation & DerivedDocFields type PresetWithDerivedDocFields = Preset & DerivedDocFields @@ -46,9 +46,7 @@ const mapeoProject = new MapeoProject({ getMediaBaseUrl: async (mediaType: 'blobs' | 'icons') => `http://127.0.0.1:8080/${mediaType}`, localPeers: new LocalPeers(), - pendingInvitesApi: new PendingInvitesApi(drizzle(sqlite), () => - Promise.resolve() - ), + inviteLinks: new InviteLinksApi(drizzle(sqlite), () => Promise.resolve()), getFallbackProjectInfo: () => ({ sendStats: false }), markInternetPeerAsTrusted: async (_deviceId) => Promise.resolve(true), disconnectFromPeer: async (_deviceId) => Promise.resolve(), diff --git a/test/member-api.js b/test/member-api.js index c161d2d87..cb2875e3f 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -9,8 +9,7 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ -/** @import { InviteOptions } from '../src/member-api.js' */ -/** @import { PendingInviteRecord, PendingInviteCreate } from '../src/invite/pending-invites-api.js' */ +/** @import { InviteLinkCreate, InviteLinkRecord } from '../src/invite/pending-invites-api.js' */ test('serialize and parse invite URLs', () => { const testSwarmPublicKey = 'foo' @@ -25,17 +24,17 @@ test('serialize and parse invite URLs', () => { }) test('List pending invites over internet', async () => { - const { member, pendingInvitesApi } = setup({}) + const { member, inviteLinks } = setup({}) - const url1 = await member.inviteOverInternet({ + const url1 = await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) - const url2 = await member.inviteOverInternet({ + const url2 = await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) - const pending = await member.pendingInternetInvites() + const pending = await member.listInviteLinks() assert.deepEqual( pending.sort(), @@ -44,7 +43,7 @@ test('List pending invites over internet', async () => { ) // Verify persistence - const persisted = await pendingInvitesApi.getAll() + const persisted = await inviteLinks.getAll() assert.equal(persisted.length, 2, 'Two invites persisted') const persistedUrls = persisted.map((p) => p.url) assert.ok(persistedUrls.includes(url1), 'url1 is persisted') @@ -52,66 +51,62 @@ test('List pending invites over internet', async () => { }) test('Cancel invite over internet requests', async () => { - const { member, pendingInvitesApi } = setup({}) + const { member, inviteLinks } = setup({}) - const url1 = await member.inviteOverInternet({ + const url1 = await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) - const url2 = await member.inviteOverInternet({ + const url2 = await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) // Verify both invites are persisted - let persisted = await pendingInvitesApi.getAll() + let persisted = await inviteLinks.getAll() assert.equal(persisted.length, 2, 'Two invites persisted initially') - await member.cancelInviteOverInternet(url1) + await member.cancelInviteLink(url1) - assert.deepEqual( - await member.pendingInternetInvites(), - [url2], - 'One URL left' - ) + assert.deepEqual(await member.listInviteLinks(), [url2], 'One URL left') // Verify only url2 remains in persistence - persisted = await pendingInvitesApi.getAll() + persisted = await inviteLinks.getAll() assert.equal(persisted.length, 1, 'One invite remains after cancel') assert.equal(persisted[0].url, url2, 'url2 is still persisted') - await member.inviteOverInternet({ + await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) - await member.inviteOverInternet({ + await member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) // Verify 3 invites now persisted - persisted = await pendingInvitesApi.getAll() + persisted = await inviteLinks.getAll() assert.equal( persisted.length, 3, 'Three invites persisted after adding two more' ) - await member.cancelInviteOverInternet() + await member.cancelInviteLink() - assert.deepEqual(await member.pendingInternetInvites(), [], 'No URLs left') + assert.deepEqual(await member.listInviteLinks(), [], 'No URLs left') // Verify persistence is cleared - persisted = await pendingInvitesApi.getAll() + persisted = await inviteLinks.getAll() assert.equal(persisted.length, 0, 'All invites removed from persistence') }) test('Pending invites are loaded from persistence on ready', async () => { - const pendingInvitesApi = new MockPendingInvitesApiForProject() + const inviteLinks = new MockInviteLinksApiForProject() const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') const swarmPublicKey = randomBytes(32).toString('hex') const url = makeInviteURL(inviteIdString, swarmPublicKey) // Pre-populate the mock with a pending invite - await pendingInvitesApi.create({ + await inviteLinks.create({ inviteId: inviteIdString, inviteIdBuffer: inviteId, url, @@ -122,10 +117,10 @@ test('Pending invites are loaded from persistence on ready', async () => { }) const { member } = setup({ - pendingInvitesApi, + inviteLinks, }) - const pending = await member.pendingInternetInvites() + const pending = await member.listInviteLinks() assert.deepEqual(pending, [url], 'Pending invite loaded from persistence') }) @@ -166,15 +161,15 @@ class MockRoles { } /** - * In-memory mock of PendingInvitesApi for testing + * In-memory mock of InviteLinksApi for testing */ -class MockPendingInvitesApiForProject { - /** @type {Map} */ +class MockInviteLinksApiForProject { + /** @type {Map} */ #invites = new Map() /** * Create a new pending invite record - * @param {Omit} data + * @param {Omit} data * @returns {Promise} */ async create(data) { @@ -192,7 +187,7 @@ class MockPendingInvitesApiForProject { /** * Get a pending invite by invite ID * @param {string} inviteId - * @returns {Promise} + * @returns {Promise} */ async getById(inviteId) { return this.#invites.get(inviteId) @@ -200,7 +195,7 @@ class MockPendingInvitesApiForProject { /** * Get all pending invites - * @returns {Promise} + * @returns {Promise} */ async getAll() { return [...this.#invites.values()] @@ -234,7 +229,7 @@ class MockPendingInvitesApiForProject { * @param {() => Promise} [opts.getProjectSettings] * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] - * @param {MockPendingInvitesApiForProject} [opts.pendingInvitesApi] + * @param {MockInviteLinksApiForProject} [opts.inviteLinks] * @returns */ function setup({ @@ -252,7 +247,7 @@ function setup({ throw new Error('Not implemented') }, markInternetPeerAsTrusted = () => Promise.resolve(true), - pendingInvitesApi = new MockPendingInvitesApiForProject(), + inviteLinks = new MockInviteLinksApiForProject(), } = {}) { const keyManager = new KeyManager(rootKey) @@ -271,7 +266,7 @@ function setup({ roles, encryptionKeys, projectKey, - pendingInvitesApi, + inviteLinks, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer, @@ -288,6 +283,6 @@ function setup({ roles, member, projectKey, - pendingInvitesApi, + inviteLinks, } } diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index 6728bcd03..4c40e4363 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -6,16 +6,16 @@ import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { randomBytes } from 'node:crypto' import { - PendingInvitesApi, - PendingInvitesApiForProject, + InviteLinksApi, + InviteLinksApiForProject, } from '../src/invite/pending-invites-api.js' import { MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, BLOCKED_ROLE_ID, } from '../src/roles.js' -import { pendingInvitesTable } from '../src/schema/client.js' -import { PendingInviteAlreadyExistsError } from '../src/errors.js' +import { inviteLinksTable } from '../src/schema/client.js' +import { InviteLinkAlreadyExistsError } from '../src/errors.js' /** @import {BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ @@ -23,8 +23,8 @@ const PROJECT_ID = 'test-project-id' /** * @typedef {object} TestEnv - * @property {PendingInvitesApi} api - * @property {PendingInvitesApiForProject} projectApi + * @property {InviteLinksApi} api + * @property {InviteLinksApiForProject} projectApi * @property {BetterSQLite3Database} db * @property {(shouldListen: boolean) => Promise} setShouldListenOverInternet * @property {() => boolean[]} getShouldListenOverInternet - Get ordered call log of setShouldListenOverInternet @@ -54,7 +54,7 @@ function setup(t, { seedPendingInvites = [], expiryMs } = {}) { // Seed pending invites before creating the API for (const seed of seedPendingInvites) { - db.insert(pendingInvitesTable) + db.insert(inviteLinksTable) .values({ projectId: seed.projectId, inviteId: seed.inviteId, @@ -79,8 +79,8 @@ function setup(t, { seedPendingInvites = [], expiryMs } = {}) { return shouldListenCalls } - const api = new PendingInvitesApi(db, setShouldListenOverInternet, expiryMs) - const projectApi = new PendingInvitesApiForProject(PROJECT_ID, api) + const api = new InviteLinksApi(db, setShouldListenOverInternet, expiryMs) + const projectApi = new InviteLinksApiForProject(PROJECT_ID, api) t.after(() => api.close()) @@ -151,7 +151,7 @@ test('create() - duplicate inviteId throws', async (t) => { roleName: 'Coordinator', }, }), - { code: PendingInviteAlreadyExistsError.code }, + { code: InviteLinkAlreadyExistsError.code }, 'Second create throws an error' ) }) @@ -396,7 +396,7 @@ test('Role ID validation on read', async (t) => { const inviteIdString = inviteId.toString('hex') // Directly insert an invalid roleId into the database using Drizzle, bypassing the API - await db.insert(pendingInvitesTable).values({ + await db.insert(inviteLinksTable).values({ projectId: PROJECT_ID, inviteId: inviteIdString, inviteIdBuffer: inviteId, @@ -511,9 +511,9 @@ test('create() and getAll()', async (t) => { assert.equal(all[0].inviteId, inviteIdString) }) -// Tests for PendingInvitesApiForProject (scoped wrapper) +// Tests for InviteLinksApiForProject (scoped wrapper) -test('PendingInvitesApiForProject - create() auto-injects projectId', async (t) => { +test('InviteLinksApiForProject - create() auto-injects projectId', async (t) => { const { projectApi } = setup(t) const inviteId = randomBytes(32) @@ -533,7 +533,7 @@ test('PendingInvitesApiForProject - create() auto-injects projectId', async (t) assert.equal(retrieved.url, url) }) -test('PendingInvitesApiForProject - getAll() returns scoped invites', async (t) => { +test('InviteLinksApiForProject - getAll() returns scoped invites', async (t) => { const { api, projectApi } = setup(t) const invite1 = randomBytes(32) @@ -560,7 +560,7 @@ test('PendingInvitesApiForProject - getAll() returns scoped invites', async (t) assert.equal(invites[0].inviteId, invite1.toString('hex')) }) -test('PendingInvitesApiForProject - delete() removes invite', async (t) => { +test('InviteLinksApiForProject - delete() removes invite', async (t) => { const { projectApi } = setup(t) const inviteId = randomBytes(32) @@ -579,7 +579,7 @@ test('PendingInvitesApiForProject - delete() removes invite', async (t) => { assert.equal(retrieved, undefined, 'invite deleted') }) -test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', async (t) => { +test('InviteLinksApiForProject - deleteAll() removes only scoped invites', async (t) => { const { api, projectApi } = setup(t) const invite1 = randomBytes(32) @@ -608,7 +608,7 @@ test('PendingInvitesApiForProject - deleteAll() removes only scoped invites', as assert.equal(all[0].inviteId, invite2.toString('hex')) }) -test('PendingInvitesApiForProject - getById() returns scoped invite', async (t) => { +test('InviteLinksApiForProject - getById() returns scoped invite', async (t) => { const { projectApi } = setup(t) const inviteId = randomBytes(32) @@ -626,7 +626,7 @@ test('PendingInvitesApiForProject - getById() returns scoped invite', async (t) assert.equal(retrieved.inviteId, inviteIdString) }) -test('PendingInvitesApiForProject - getById() returns undefined for invite in other project', async (t) => { +test('InviteLinksApiForProject - getById() returns undefined for invite in other project', async (t) => { const { api, projectApi } = setup(t) const inviteId = randomBytes(32) @@ -649,14 +649,14 @@ test('PendingInvitesApiForProject - getById() returns undefined for invite in ot ) }) -test('PendingInvitesApiForProject - getById() returns undefined for non-existent invite', async (t) => { +test('InviteLinksApiForProject - getById() returns undefined for non-existent invite', async (t) => { const { projectApi } = setup(t) const result = await projectApi.getById('non-existent-id') assert.equal(result, undefined, 'returns undefined for non-existent invite') }) -test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', async (t) => { +test('InviteLinksApiForProject - delete() is no-op for non-existent invite', async (t) => { const { projectApi } = setup(t) await projectApi.delete('non-existent-id') // should not throw @@ -665,7 +665,7 @@ test('PendingInvitesApiForProject - delete() is no-op for non-existent invite', assert.equal(all.length, 0, 'no-op for non-existent invite') }) -test('PendingInvitesApiForProject - deleteAll() is no-op on empty project', async (t) => { +test('InviteLinksApiForProject - deleteAll() is no-op on empty project', async (t) => { const { api, projectApi } = setup(t) await projectApi.deleteAll() // should not throw From 2b2defcfe47fb1e8506fd0a3fce6d2dc5a343e20 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 26 May 2026 16:01:14 -0400 Subject: [PATCH 052/103] chore: refactor invite link params --- src/member-api.js | 16 ++++++++++------ test/member-api.js | 7 +++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 22b8e6090..ed202a5ff 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -104,6 +104,12 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {InvitePeerInfo} [peerInfo] */ +/** + * @typedef {object} InviteLinkParams + * @property {string} inviteIdString + * @property {string} swarmPublicKey + */ + /** * @typedef {( * typeof InviteResponse_Decision.ACCEPT | @@ -226,7 +232,7 @@ export class MemberApi extends TypedEmitter { const inviteIdString = inviteId.toString('hex') const swarmPublicKey = this.#getSwarmPublicKey().toString('hex') - const url = makeInviteURL(inviteIdString, swarmPublicKey) + const url = makeInviteURL({ inviteIdString, swarmPublicKey }) await this.#inviteLinks.create({ inviteId: inviteIdString, @@ -1009,7 +1015,7 @@ async function parseAddServerResponse(response) { /** * @param {string} url - * @returns {{inviteIdString: string, swarmPublicKey: string}} + * @returns {InviteLinkParams} */ export function parseInviteURL(url) { const { hash } = new URL(url) @@ -1029,12 +1035,10 @@ export function parseInviteURL(url) { } /** - * - * @param {string} inviteIdString - * @param {string} swarmPublicKey + * @param {InviteLinkParams} opts * @returns {string} */ -export function makeInviteURL(inviteIdString, swarmPublicKey) { +export function makeInviteURL({ inviteIdString, swarmPublicKey }) { const url = INTERNET_INVITE_PAGE + `#i=${inviteIdString}&d=${swarmPublicKey}` return url diff --git a/test/member-api.js b/test/member-api.js index cb2875e3f..b1fff56a1 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -15,7 +15,10 @@ test('serialize and parse invite URLs', () => { const testSwarmPublicKey = 'foo' const testInviteId = 'bar' - const url = makeInviteURL(testInviteId, testSwarmPublicKey) + const url = makeInviteURL({ + inviteIdString: testInviteId, + swarmPublicKey: testSwarmPublicKey, + }) const { inviteIdString, swarmPublicKey } = parseInviteURL(url) @@ -103,7 +106,7 @@ test('Pending invites are loaded from persistence on ready', async () => { const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') const swarmPublicKey = randomBytes(32).toString('hex') - const url = makeInviteURL(inviteIdString, swarmPublicKey) + const url = makeInviteURL({ inviteIdString, swarmPublicKey }) // Pre-populate the mock with a pending invite await inviteLinks.create({ From 987e67233dd5c8f252606c4f6ac15a4e50e51f54 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 26 May 2026 16:05:23 -0400 Subject: [PATCH 053/103] chore: move pending invites api to invite links api --- src/invite/{pending-invites-api.js => invite-links-api.js} | 0 src/mapeo-manager.js | 2 +- src/mapeo-project.js | 4 ++-- src/member-api.js | 2 +- test-types/data-types.ts | 2 +- test/member-api.js | 2 +- test/pending-invites-api.js | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename src/invite/{pending-invites-api.js => invite-links-api.js} (100%) diff --git a/src/invite/pending-invites-api.js b/src/invite/invite-links-api.js similarity index 100% rename from src/invite/pending-invites-api.js rename to src/invite/invite-links-api.js diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index a351adb20..5d34b6941 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -68,7 +68,7 @@ import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' import { parseInviteURL } from './member-api.js' import { pEvent } from 'p-event' -import { InviteLinksApi } from './invite/pending-invites-api.js' +import { InviteLinksApi } from './invite/invite-links-api.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ diff --git a/src/mapeo-project.js b/src/mapeo-project.js index c55884e2b..e6d667d23 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -49,7 +49,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' -import { InviteLinksApiForProject } from './invite/pending-invites-api.js' +import { InviteLinksApiForProject } from './invite/invite-links-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -177,7 +177,7 @@ export class MapeoProject extends ReadyResource { * @param {(mediaType: 'blobs' | 'icons') => Promise} opts.getMediaBaseUrl * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {import('./local-peers.js').LocalPeers} opts.localPeers - * @param {import('./invite/pending-invites-api.js').InviteLinksApi} opts.inviteLinks + * @param {import('./invite/invite-links-api.js').InviteLinksApi} opts.inviteLinks * @param {boolean} opts.isArchiveDevice Whether this device is an archive device * @param {() => import('./schema/client.js').ProjectInfo | undefined} opts.getFallbackProjectInfo * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted diff --git a/src/member-api.js b/src/member-api.js index ed202a5ff..7a7e8ab06 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -71,7 +71,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ -/** @import { InviteLinksApiForProject } from './invite/pending-invites-api.js' */ +/** @import { InviteLinksApiForProject } from './invite/invite-links-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ diff --git a/test-types/data-types.ts b/test-types/data-types.ts index 519585582..2172d8f0e 100644 --- a/test-types/data-types.ts +++ b/test-types/data-types.ts @@ -19,7 +19,7 @@ import { DerivedDocFields } from '../dist/datatype/index.js' import { projectSettingsTable } from '../dist/schema/client.js' import { LocalPeers } from '../dist/local-peers.js' import { Expect, type Equal } from './utils.js' -import { InviteLinksApi } from '../dist/invite/pending-invites-api.js' +import { InviteLinksApi } from '../dist/invite/invite-links-api.js' type ObservationWithDerivedDocFields = Observation & DerivedDocFields type PresetWithDerivedDocFields = Preset & DerivedDocFields diff --git a/test/member-api.js b/test/member-api.js index b1fff56a1..3a92c4417 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -9,7 +9,7 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ -/** @import { InviteLinkCreate, InviteLinkRecord } from '../src/invite/pending-invites-api.js' */ +/** @import { InviteLinkCreate, InviteLinkRecord } from '../src/invite/invite-links-api.js' */ test('serialize and parse invite URLs', () => { const testSwarmPublicKey = 'foo' diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index 4c40e4363..aa4540c21 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -8,7 +8,7 @@ import { randomBytes } from 'node:crypto' import { InviteLinksApi, InviteLinksApiForProject, -} from '../src/invite/pending-invites-api.js' +} from '../src/invite/invite-links-api.js' import { MEMBER_ROLE_ID, COORDINATOR_ROLE_ID, From 36b910acff447085755d3187eb941d1d7af52eec Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 26 May 2026 17:07:34 -0400 Subject: [PATCH 054/103] feat: add expiresAt to invite links, expose in listInviteLinks --- ...a_nova.sql => 0005_nifty_misty_knight.sql} | 3 ++- drizzle/client/meta/0005_snapshot.json | 9 ++++++- drizzle/client/meta/_journal.json | 4 +-- src/invite/invite-links-api.js | 11 ++++---- src/member-api.js | 10 ++++++-- src/schema/client.js | 1 + test-e2e/invite-over-internet.js | 12 +++++++-- test/member-api.js | 21 +++++++++++++--- test/pending-invites-api.js | 25 ++++++++----------- 9 files changed, 65 insertions(+), 31 deletions(-) rename drizzle/client/{0005_long_cassandra_nova.sql => 0005_nifty_misty_knight.sql} (78%) diff --git a/drizzle/client/0005_long_cassandra_nova.sql b/drizzle/client/0005_nifty_misty_knight.sql similarity index 78% rename from drizzle/client/0005_long_cassandra_nova.sql rename to drizzle/client/0005_nifty_misty_knight.sql index e4eec53ed..797054ccf 100644 --- a/drizzle/client/0005_long_cassandra_nova.sql +++ b/drizzle/client/0005_nifty_misty_knight.sql @@ -6,5 +6,6 @@ CREATE TABLE `inviteLinks` ( `roleId` text NOT NULL, `roleName` text, `roleDescription` text, - `createdAt` integer NOT NULL + `createdAt` integer NOT NULL, + `expiresAt` integer NOT NULL ); diff --git a/drizzle/client/meta/0005_snapshot.json b/drizzle/client/meta/0005_snapshot.json index 20d812e31..4dc417f88 100644 --- a/drizzle/client/meta/0005_snapshot.json +++ b/drizzle/client/meta/0005_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "03e80a08-1d4e-4389-865b-8c3fa7822ad9", + "id": "ca036e4c-dc1d-4c34-b08d-6062267c243b", "prevId": "2dec4710-70ac-4857-b889-2bc3586695ee", "tables": { "deviceSettings": { @@ -101,6 +101,13 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, "indexes": {}, diff --git a/drizzle/client/meta/_journal.json b/drizzle/client/meta/_journal.json index 60161481e..4e13ad744 100644 --- a/drizzle/client/meta/_journal.json +++ b/drizzle/client/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "6", - "when": 1779823832990, - "tag": "0005_long_cassandra_nova", + "when": 1779827153551, + "tag": "0005_nifty_misty_knight", "breakpoints": true } ] diff --git a/src/invite/invite-links-api.js b/src/invite/invite-links-api.js index 668ebe337..6c6b6cfee 100644 --- a/src/invite/invite-links-api.js +++ b/src/invite/invite-links-api.js @@ -21,6 +21,7 @@ import { deNullify } from '../utils.js' * @property {string} [roleName] * @property {string} [roleDescription] * @property {number} createdAt Timestamp when created + * @property {number} expiresAt Timestamp when the invite expires */ /** @@ -32,7 +33,7 @@ import { deNullify } from '../utils.js' * @property {InviteOptions} opts */ -const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 +export const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 export class InviteLinksApiForProject { #projectId @@ -154,7 +155,7 @@ export class InviteLinksApi extends ReadyResource { .select() .from(inviteLinksTable) .where( - sql`${inviteLinksTable.createdAt} < ${sql.placeholder('cutoff')}` + sql`${inviteLinksTable.expiresAt} < ${sql.placeholder('cutoff')}` ) .prepare(), getOldest: db @@ -184,7 +185,7 @@ export class InviteLinksApi extends ReadyResource { * Delete all invite links whose createdAt timestamp is older than 24 hours. */ async #clearExpired() { - const cutoff = Date.now() - this.#expiryMs + const cutoff = Date.now() const expired = this.#sql.getExpired.all({ cutoff }) for (const row of expired) { await this.#db @@ -215,8 +216,7 @@ export class InviteLinksApi extends ReadyResource { this.#cancelScheduleExpired() const oldest = this.#sql.getOldest.get() if (!oldest) return - const expiresAt = oldest.createdAt + this.#expiryMs - const delay = Math.max(0, expiresAt - Date.now()) + const delay = Math.max(0, oldest.expiresAt - Date.now()) this.#clearExpiredTimer = setTimeout(async () => { this.#clearExpiredTimer = null await this.#clearExpired() @@ -260,6 +260,7 @@ export class InviteLinksApi extends ReadyResource { roleName: data.opts.roleName, roleDescription: data.opts.roleDescription, createdAt: Date.now(), + expiresAt: Date.now() + this.#expiryMs, }) await this.#checkSetShouldListenOverInternet(true) diff --git a/src/member-api.js b/src/member-api.js index 7a7e8ab06..8ba1c73c0 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -71,6 +71,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @import { projectSettingsTable } from './schema/client.js' */ /** @import { ReplicationStream, MapeoValueMap } from './types.js' */ /** @import { PeerInfoDisconnected } from './local-peers.js' */ +/** @import { InviteLinkRecord } from './invite/invite-links-api.js' */ /** @import { InviteLinksApiForProject } from './invite/invite-links-api.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ @@ -276,11 +277,16 @@ export class MemberApi extends TypedEmitter { /** * Get the list of pending invites over the internet - * @returns {Promise} + * @returns {Promise[]>} */ async listInviteLinks() { const invites = await this.#inviteLinks.getAll() - return invites.map(({ url }) => url) + return invites.map(({ url, inviteId, createdAt, expiresAt }) => ({ + url, + inviteId, + createdAt, + expiresAt, + })) } /** diff --git a/src/schema/client.js b/src/schema/client.js index 957740309..2cb9f783b 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -59,4 +59,5 @@ export const inviteLinksTable = sqliteTable('inviteLinks', { roleName: text('roleName'), roleDescription: text('roleDescription'), createdAt: int('createdAt').notNull(), + expiresAt: int('expiresAt').notNull(), }) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 9d0d576bb..51684ab7f 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -105,7 +105,11 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { const pending = await project.$member.listInviteLinks() - assert.deepEqual(pending, [url], 'Pending internet invites loaded on reload') + assert.deepEqual( + pending.map((p) => p.url), + [url], + 'Pending internet invites loaded on reload' + ) const onInviteRedeemAttempt = pEvent( project.$member, @@ -195,7 +199,11 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { // Verify invite is still pending for future redeemers const pending = await project.$member.listInviteLinks() - assert.deepEqual(pending, [url], 'Invite still pending after two redeems') + assert.deepEqual( + pending.map((p) => p.url), + [url], + 'Invite still pending after two redeems' + ) }) test('invite over internet errors if invitor deviceID is invalid', async (t) => { diff --git a/test/member-api.js b/test/member-api.js index 3a92c4417..066d69404 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -40,7 +40,7 @@ test('List pending invites over internet', async () => { const pending = await member.listInviteLinks() assert.deepEqual( - pending.sort(), + pending.map((p) => p.url).sort(), [url1, url2].sort(), 'Both pending URLs returned' ) @@ -70,7 +70,11 @@ test('Cancel invite over internet requests', async () => { await member.cancelInviteLink(url1) - assert.deepEqual(await member.listInviteLinks(), [url2], 'One URL left') + assert.deepEqual( + await member.listInviteLinks().then((r) => r.map((p) => p.url)), + [url2], + 'One URL left' + ) // Verify only url2 remains in persistence persisted = await inviteLinks.getAll() @@ -94,7 +98,11 @@ test('Cancel invite over internet requests', async () => { await member.cancelInviteLink() - assert.deepEqual(await member.listInviteLinks(), [], 'No URLs left') + assert.deepEqual( + await member.listInviteLinks().then((r) => r.map((p) => p.url)), + [], + 'No URLs left' + ) // Verify persistence is cleared persisted = await inviteLinks.getAll() @@ -124,7 +132,11 @@ test('Pending invites are loaded from persistence on ready', async () => { }) const pending = await member.listInviteLinks() - assert.deepEqual(pending, [url], 'Pending invite loaded from persistence') + assert.deepEqual( + pending.map((p) => p.url), + [url], + 'Pending invite loaded from persistence' + ) }) class MockLocalPeers extends LocalPeers { @@ -184,6 +196,7 @@ class MockInviteLinksApiForProject { roleName: data.opts.roleName, roleDescription: data.opts.roleDescription, createdAt: Date.now(), + expiresAt: Date.now() + 24 * 60 * 60 * 1000, }) } diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index aa4540c21..bb35dfb45 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -6,6 +6,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { randomBytes } from 'node:crypto' import { + DEFAULT_INVITE_EXPIRY_MS, InviteLinksApi, InviteLinksApiForProject, } from '../src/invite/invite-links-api.js' @@ -18,6 +19,7 @@ import { inviteLinksTable } from '../src/schema/client.js' import { InviteLinkAlreadyExistsError } from '../src/errors.js' /** @import {BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ +/** @import {InviteLinkRecord} from '../src/invite/invite-links-api.js' */ const PROJECT_ID = 'test-project-id' @@ -31,13 +33,7 @@ const PROJECT_ID = 'test-project-id' */ /** - * @typedef {object} SeedPendingInvite - * @property {string} projectId - * @property {string} inviteId - * @property {Buffer} inviteIdBuffer - * @property {string} url - * @property {number} [createdAt] - * @property {import('../src/roles.js').RoleIdForNewInvite} roleId + * @typedef {Omit & {createdAt?: number, expiresAt?: number, projectId: string}} SeedPendingInvite */ /** @@ -45,7 +41,10 @@ const PROJECT_ID = 'test-project-id' * @param {{ seedPendingInvites?: SeedPendingInvite[], expiryMs?: number }} [opts] * @returns {TestEnv} */ -function setup(t, { seedPendingInvites = [], expiryMs } = {}) { +function setup( + t, + { seedPendingInvites = [], expiryMs = DEFAULT_INVITE_EXPIRY_MS } = {} +) { const sqlite = new Database(':memory:') const db = drizzle(sqlite) migrate(db, { @@ -56,12 +55,9 @@ function setup(t, { seedPendingInvites = [], expiryMs } = {}) { for (const seed of seedPendingInvites) { db.insert(inviteLinksTable) .values({ - projectId: seed.projectId, - inviteId: seed.inviteId, - inviteIdBuffer: seed.inviteIdBuffer, - url: seed.url, - roleId: seed.roleId, - createdAt: Date.now(), + ...seed, + createdAt: seed.createdAt ?? Date.now(), + expiresAt: seed.expiresAt ?? Date.now() + expiryMs, }) .run() } @@ -403,6 +399,7 @@ test('Role ID validation on read', async (t) => { url: 'https://example.com/invite', roleId: 'invalid-role-id', createdAt: Date.now(), + expiresAt: Date.now() + DEFAULT_INVITE_EXPIRY_MS, }) await assert.rejects( From 72e99a5aaa10fc13e5b9f305b64de97292f59469 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 27 May 2026 11:29:24 -0400 Subject: [PATCH 055/103] fix: Filter incoming invites by invite over internet invitor device ID --- src/mapeo-manager.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 5d34b6941..334372215 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -766,10 +766,13 @@ export class MapeoManager extends TypedEmitter { // It's okay if this rejection never gets handled onClose.catch(noop) try { - const onInvited = pEvent(this.#invite, 'invite-received') - // Use the identity key from the handshake, not the swarm key from the URL const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') + + const onInvited = pEvent(this.#invite, 'invite-received', { + filter: (invite) => invite.invitorDeviceId === identityPublicKeyHex, + }) + await Promise.race([ this.#localPeers.sendRedeemInviteOverInternet(identityPublicKeyHex, { inviteId, From 955379696549fe38155074d791cec6c6527dd7ab Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 27 May 2026 13:07:53 -0400 Subject: [PATCH 056/103] feat: Use HyperDHT testnet for RemoteDiscovery tests --- src/discovery/remote-discovery.js | 11 ++++++++++- test/discovery/remote-discovery.js | 17 +++++++++++++++++ types/hyperswarm.d.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 1f7139d86..b66dd8793 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -45,6 +45,7 @@ export class RemoteDiscovery extends TypedEmitter { #loading = null #identityKeypair #deriveSwarmIdentityKeypair + #swarmOpts /** @type {Set} */ #shouldTrustKeys = new Set() /** @type {Set} */ @@ -55,12 +56,19 @@ export class RemoteDiscovery extends TypedEmitter { * @param {Keypair} opts.identityKeypair * @param {() => Keypair} opts.deriveSwarmIdentityKeypair * @param {Logger} [opts.logger] + * @param {object} [opts.swarm] - Optional Hyperswarm constructor overrides (e.g. { dht }) */ - constructor({ identityKeypair, deriveSwarmIdentityKeypair, logger }) { + constructor({ + identityKeypair, + deriveSwarmIdentityKeypair, + logger, + swarm: swarmOpts, + }) { super() this.#l = Logger.create('RemoteDiscovery', logger) this.#identityKeypair = identityKeypair this.#deriveSwarmIdentityKeypair = deriveSwarmIdentityKeypair + this.#swarmOpts = swarmOpts } async #initSwarm() { @@ -69,6 +77,7 @@ export class RemoteDiscovery extends TypedEmitter { const swarm = new Hyperswarm({ keyPair: this.#deriveSwarmIdentityKeypair(), maxPeers: 16, + ...this.#swarmOpts, }) // @ts-expect-error Hyperswarm lacks the expected utility class to mark the stream as opened swarm.on('connection', this.#handleHyperswarmConnection.bind(this)) diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 5d8a38a61..030d06bf6 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -1,3 +1,5 @@ +import createTestnet from 'hyperdht/testnet.js' + import test from 'node:test' import assert from 'node:assert/strict' import { KeyManager, keyToPublicId } from '@mapeo/crypto' @@ -20,6 +22,11 @@ import { Duplex, Transform } from 'streamx' /** @import {OpenedNoiseStream} from '../../src/lib/noise-secret-stream-helpers.js'*/ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => { + const testnet = await createTestnet(3) + t.after(async () => { + await testnet.destroy() + }) + const identityKeypair1 = new KeyManager( Buffer.alloc(16, 1) ).getIdentityKeypair() @@ -32,10 +39,12 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, deriveSwarmIdentityKeypair: () => swarmKeypair1, + swarm: { dht: testnet.nodes[0] }, }) const remoteDiscovery2 = new RemoteDiscovery({ identityKeypair: identityKeypair2, deriveSwarmIdentityKeypair: () => swarmKeypair2, + swarm: { dht: testnet.nodes[1] }, }) t.after(() => @@ -280,6 +289,11 @@ test('RemoteDiscovery - connectPeer returns same socket for duplicate connection }) test('RemoteDiscovery - connect two peers to a third peer', async (t) => { + const testnet = await createTestnet(3) + t.after(async () => { + await testnet.destroy() + }) + const identityKeypair1 = new KeyManager( Buffer.alloc(16, 1) ).getIdentityKeypair() @@ -297,14 +311,17 @@ test('RemoteDiscovery - connect two peers to a third peer', async (t) => { const remoteDiscovery1 = new RemoteDiscovery({ identityKeypair: identityKeypair1, deriveSwarmIdentityKeypair: () => swarmKeypair1, + swarm: { dht: testnet.nodes[0] }, }) const remoteDiscovery2 = new RemoteDiscovery({ identityKeypair: identityKeypair2, deriveSwarmIdentityKeypair: () => swarmKeypair2, + swarm: { dht: testnet.nodes[1] }, }) const remoteDiscovery3 = new RemoteDiscovery({ identityKeypair: identityKeypair3, deriveSwarmIdentityKeypair: () => swarmKeypair3, + swarm: { dht: testnet.nodes[2] }, }) t.after(() => diff --git a/types/hyperswarm.d.ts b/types/hyperswarm.d.ts index b84586b66..5ec36da9f 100644 --- a/types/hyperswarm.d.ts +++ b/types/hyperswarm.d.ts @@ -40,3 +40,31 @@ declare module 'hyperswarm' { destroy(): Promise } } + +declare module 'hyperdht' { + export default class HyperDHT {} +} + +declare module 'hyperdht/testnet.js' { + interface TestnetOpts { + teardown?: TestnetTearDownFn + host?: string + port?: number + } + + type TestnetTearDownFn = ( + opts: TestnetOpts, + onFinishTeardown: () => Promise + ) => void | Promise + + class TestNet { + nodes: HyperDHT[] + bootstrap: string[] + destroy(): Promise + } + + export default function createTestnet( + size?: number, + opts?: TestnetOpts | TestnetTearDownFn + ): Promise +} From f10b666a435d8b1e4628c90e81f5ba2cab26c8e4 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 27 May 2026 13:52:35 -0400 Subject: [PATCH 057/103] feat: Use testnet for invite over internet tests --- src/mapeo-manager.js | 3 +++ test-e2e/invite-over-internet.js | 20 +++++++++++++++----- test-e2e/utils.js | 14 +++++++++++--- types/hyperswarm.d.ts | 9 ++++++++- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 334372215..9cf82c98a 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -186,6 +186,7 @@ export class MapeoManager extends TypedEmitter { * @param {string} [opts.defaultOnlineStyleUrl] URL for an online-hosted StyleJSON asset. * @param {boolean} [opts.defaultIsArchiveDevice] Whether the node is an archive device by default * @param {(url: string) => WebSocket} [opts.makeWebsocket] + * @param {import('hyperswarm').SwarmOpts} [opts.swarm] */ constructor({ rootKey, @@ -194,6 +195,7 @@ export class MapeoManager extends TypedEmitter { clientMigrationsFolder, coreStorage, fastify, + swarm, defaultConfigPath, customMapPath, fallbackMapPath = DEFAULT_FALLBACK_MAP_FILE_PATH, @@ -303,6 +305,7 @@ export class MapeoManager extends TypedEmitter { identityKeypair: this.#keyManager.getIdentityKeypair(), // ephemeral swarm identity each run deriveSwarmIdentityKeypair: () => this.#keyManager.deriveSwarmIdentity(), + swarm, logger, }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 51684ab7f..c93f69994 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -16,7 +16,9 @@ import crypto from 'node:crypto' import { temporaryDirectory } from 'tempy' test('invite over internet and join from URL', async (t) => { - const managers = await createManagers(2, t) + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) const [invitor, invitee] = managers const projectId = await invitor.createProject({ @@ -140,7 +142,9 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { }) test('invite over internet can be redeemed by multiple peers', async (t) => { - const managers = await createManagers(3, t) + const managers = await createManagers(3, t, 'device_type_unspecified', { + useTestnet: true, + }) const [invitor, invitee1, invitee2] = managers const projectId = await invitor.createProject({ @@ -207,7 +211,9 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { }) test('invite over internet errors if invitor deviceID is invalid', async (t) => { - const managers = await createManagers(2, t) + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) const [invitor, invitee] = managers const projectId = await invitor.createProject({ @@ -238,7 +244,9 @@ test('invite over internet errors if invitor deviceID is invalid', async (t) => }) test('invite over internet errors if inviter closes before accepting', async (t) => { - const managers = await createManagers(2, t) + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) const [invitor, invitee] = managers const projectId = await invitor.createProject({ @@ -289,7 +297,9 @@ test('invite over internet errors if inviter closes before accepting', async (t) }) test('invite over internet errors if invitee uses random invalid inviteId', async (t) => { - const managers = await createManagers(2, t) + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) const [invitor, invitee] = managers const projectId = await invitor.createProject({ diff --git a/test-e2e/utils.js b/test-e2e/utils.js index f038af8fd..545cd01e2 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -29,6 +29,7 @@ import { kDataTypes } from '../src/mapeo-project.js' import { kGetIconBlob } from '../src/icon-api.js' import { execa } from 'execa' import { ExhaustivenessError } from '../src/errors.js' +import createTestnet from 'hyperdht/testnet.js' /** @import { InvitePeerInfo, MemberApi } from '../src/member-api.js' */ /** @import { PublicPeerInfo } from '../src/mapeo-manager.js' */ @@ -222,22 +223,29 @@ export async function waitForPeers( * @param {T} count * @param {import('node:test').TestContext} t * @param {import('../src/generated/rpc.js').DeviceInfo['deviceType']} [deviceType] - * @param {Partial[0]>} [overrides] + * @param {Partial[0]> & {useTestnet?: boolean}} [overrides] * @returns {Promise>} */ export async function createManagers( count, t, deviceType = 'device_type_unspecified', - overrides = {} + { useTestnet, ...overrides } = {} ) { + let testnet = null + if (useTestnet) { + testnet = await createTestnet(count) + } // @ts-ignore return Promise.all( Array(count) .fill(null) .map(async (_, i) => { + const opts = testnet + ? { ...overrides, swarm: { dht: testnet.nodes[0] } } + : overrides const name = 'device' + i + (deviceType ? `-${deviceType}` : '') - const manager = createManager(name, t, overrides) + const manager = createManager(name, t, opts) await manager.setDeviceInfo({ name, deviceType }) return manager }) diff --git a/types/hyperswarm.d.ts b/types/hyperswarm.d.ts index 5ec36da9f..f509d2616 100644 --- a/types/hyperswarm.d.ts +++ b/types/hyperswarm.d.ts @@ -16,8 +16,15 @@ declare module 'hyperswarm' { interface SwarmEvents { connection(socket: NoiseSecretStream, PeerInfo: PeerInfo): void } + + interface SwarmOpts { + keyPair?: Keypair + maxPeers?: number + dht?: HyperDHT + } + export default class Hyperswarm extends TypedEmitter { - constructor({ keyPair: Keypair, maxPeers: number }) + constructor(swarmOpts: SwarmOpts) get peers(): Map get connections(): Set listen(): Promise From 38e9de298b9eba9e68c82ea085ed28ea4d0b0201 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 27 May 2026 16:27:28 -0400 Subject: [PATCH 058/103] fix: Track id from noise streams correctly. --- src/blob-store/index.js | 4 ++-- src/member-api.js | 7 +------ src/sync/peer-sync-controller.js | 21 +++++++++++---------- src/sync/sync-api.js | 10 ++++++---- test-e2e/invite-over-internet.js | 9 +++++++-- test-e2e/utils.js | 5 +++++ 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/blob-store/index.js b/src/blob-store/index.js index 525e91d3e..45c0e7c1c 100644 --- a/src/blob-store/index.js +++ b/src/blob-store/index.js @@ -14,6 +14,7 @@ import { DriveNotFoundError, } from '../errors.js' import ensureError from 'ensure-error' +import { peerIdFromNoise } from '../local-peers.js' /** @import Hyperdrive from 'hyperdrive' */ /** @import { JsonObject } from 'type-fest' */ @@ -135,8 +136,7 @@ export class BlobStore extends ReadyResource { * @param {import('../types.js').HypercorePeer & { protomux: import('protomux') }} peer */ #handlePeerRemove = (peer) => { - const peerKey = peer.protomux.stream.remotePublicKey - const peerId = peerKey.toString('hex') + const peerId = peerIdFromNoise(peer.protomux.stream) this.#entriesStreams.get(peerId)?.destroy() this.#entriesStreams.delete(peerId) } diff --git a/src/member-api.js b/src/member-api.js index 8ba1c73c0..725126b9a 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -310,12 +310,7 @@ export class MemberApi extends TypedEmitter { */ async #handleRedeemInviteOverInternet(peerId, { inviteId }) { const inviteIdString = inviteId.toString('hex') - this.#l.log( - 'Got incoming invite redeem', - inviteIdString.slice(0, 7), - 'from', - peerId - ) + this.#l.log('Got incoming invite redeem %S from %S', inviteIdString, peerId) const invite = await this.#inviteLinks.getById(inviteIdString) if (!invite) { diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index e9040d7bd..3ce8f329e 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -4,6 +4,7 @@ import { Logger } from '../logger.js' import { createMap, noop } from '../utils.js' import { unreplicate } from '../lib/hypercore-helpers.js' import { ExhaustivenessError } from '../errors.js' +import { peerIdFromNoise } from '../local-peers.js' /** @import { CoreRecord } from '../core-manager/index.js' */ /** @import { Role } from '../roles.js' */ /** @import { SyncEnabledState } from './sync-api.js' */ @@ -46,9 +47,7 @@ export class PeerSyncController { * @param {Logger} [opts.logger] */ constructor({ protomux, coreManager, syncState, roles, logger }) { - const logPrefix = `[${protomux.stream.remotePublicKey - ?.toString('hex') - .slice(0, 7)}] ` + const logPrefix = `[${peerIdFromNoise(protomux.stream).slice(0, 7)}] ` this.#log = Logger.create('peer', logger, { prefix: logPrefix }).log this.#coreManager = coreManager this.#protomux = protomux @@ -71,12 +70,8 @@ export class PeerSyncController { this.#roles.off('update', this.#handleRolesUpdate) } - get peerKey() { - return this.#protomux.stream.remotePublicKey - } - get peerId() { - return this.peerKey.toString('hex') + return peerIdFromNoise(this.#protomux.stream) } get syncCapability() { @@ -182,6 +177,11 @@ export class PeerSyncController { * @param {Set} roleDocIds */ #handleRolesUpdate = (roleDocIds) => { + this.#log({ + roleDocIds, + peerId: this.peerId, + has: roleDocIds.has(this.peerId), + }) if (!this.peerId) return if (!roleDocIds.has(this.peerId)) return this.#refreshSyncCapability().catch(noop) @@ -209,8 +209,9 @@ export class PeerSyncController { * @returns {Promise} */ async #readAndCacheSyncCapability() { + console.trace(this.peerId, 'readAndCacheSyncCapability') try { - this.#log('reading role for %h', this.peerId) + this.#log('reading role for %S', this.peerId) const cap = await this.#roles.getRole(this.peerId) // Copy: ROLES.*.sync is a shared object reference. this.#syncCapability = { ...cap.sync } @@ -300,7 +301,7 @@ export class PeerSyncController { /** @type {(peer: any) => void} */ const handlePeerRemove = (peer) => { - if (!peer.remotePublicKey.equals(this.peerKey)) return + if (!peer.remotePublicKey.equals(Buffer.from(this.peerId))) return core.off('peer-remove', handlePeerRemove) this.#log( 'peer-remove %h from %s core %k', diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index f2268b425..00f77f4e1 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -13,6 +13,8 @@ import { getOwn } from '../lib/get-own.js' import { wsCoreReplicator } from '../lib/ws-core-replicator.js' import { NO_ROLE_ID } from '../roles.js' import { AutoStopTimeoutError, ExhaustivenessError } from '../errors.js' +import { peerIdFromNoise } from '../local-peers.js' + /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ /** @import * as http from 'node:http' */ /** @import { CoreOwnership } from '../core-ownership.js' */ @@ -529,8 +531,8 @@ export class SyncApi extends TypedEmitter { const { protomux } = peer if (this.#peerSyncControllers.has(protomux)) { this.#l.log( - 'Unexpected existing peer sync controller for peer %h', - protomux.stream.remotePublicKey + 'Unexpected existing peer sync controller for peer %S', + peerIdFromNoise(protomux.stream) ) return } @@ -567,12 +569,12 @@ export class SyncApi extends TypedEmitter { * @param {{ protomux: import('protomux'), remotePublicKey: Buffer }} peer */ #handlePeerDisconnect = (peer) => { - const { protomux } = peer + const { protomux, remotePublicKey } = peer const psc = this.#peerSyncControllers.get(protomux) if (!psc) { this.#l.log( 'Unexpected no existing peer sync controller for peer %h', - protomux.stream.remotePublicKey + remotePublicKey ) return } diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index c93f69994..58e48420e 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -15,7 +15,7 @@ import { import crypto from 'node:crypto' import { temporaryDirectory } from 'tempy' -test('invite over internet and join from URL', async (t) => { +test.only('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, }) @@ -59,7 +59,12 @@ test('invite over internet and join from URL', async (t) => { assert.equal(gotProjectId, projectId, 'joined expected project') - // TODO: Test that initial sync happened + // Test that initial sync happened by seeing if the auth cores got exchanged + const inviteeProject = await invitee.getProject(gotProjectId) + + const members = await inviteeProject.$member.getMany() + + assert.equal(members.length, 2, 'Seeing both members after initial sync') }) test('invite over internet, close, reopen, and join from URL', async (t) => { diff --git a/test-e2e/utils.js b/test-e2e/utils.js index 545cd01e2..00ec5ece3 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -33,6 +33,7 @@ import createTestnet from 'hyperdht/testnet.js' /** @import { InvitePeerInfo, MemberApi } from '../src/member-api.js' */ /** @import { PublicPeerInfo } from '../src/mapeo-manager.js' */ +/** @import { TestNet } from 'hyperdht/testnet.js' */ const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) .pathname @@ -232,9 +233,13 @@ export async function createManagers( deviceType = 'device_type_unspecified', { useTestnet, ...overrides } = {} ) { + /** + * @type {TestNet|null} + */ let testnet = null if (useTestnet) { testnet = await createTestnet(count) + t.after(() => testnet?.destroy()) } // @ts-ignore return Promise.all( From 19f9f8b20dd1b130c811392cb33cdd58933dd872 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 27 May 2026 16:56:08 -0400 Subject: [PATCH 059/103] fix: Pull peer id from noise stream instead of peer remotePublicKey, HypercorePeer has opened noise stream --- src/blob-store/index.js | 4 ++-- src/core-manager/index.js | 5 +++-- src/local-peers.js | 1 + src/sync/core-sync-state.js | 6 +++--- src/sync/peer-sync-controller.js | 11 +++-------- src/sync/sync-api.js | 17 ++++++++--------- src/types.ts | 3 ++- 7 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/blob-store/index.js b/src/blob-store/index.js index 45c0e7c1c..a2b7f1bbe 100644 --- a/src/blob-store/index.js +++ b/src/blob-store/index.js @@ -124,7 +124,7 @@ export class BlobStore extends ReadyResource { /** * Bound to `this` * This will be called whenever a peer is successfully added to the creatorcore - * @param {import('../types.js').HypercorePeer & { protomux: import('protomux') }} peer + * @param {import('../types.js').HypercorePeer } peer */ #handlePeerAdd = (peer) => { const downloadFilter = getBlobDownloadFilter(this.#isArchiveDevice) @@ -133,7 +133,7 @@ export class BlobStore extends ReadyResource { /** * Bound to `this` - * @param {import('../types.js').HypercorePeer & { protomux: import('protomux') }} peer + * @param {import('../types.js').HypercorePeer} peer */ #handlePeerRemove = (peer) => { const peerId = peerIdFromNoise(peer.protomux.stream) diff --git a/src/core-manager/index.js b/src/core-manager/index.js index f5e924a22..20ac63b71 100644 --- a/src/core-manager/index.js +++ b/src/core-manager/index.js @@ -19,6 +19,7 @@ import { InvalidProjectKeyError, InvalidProjectSecretKeyError, } from '../errors.js' +import { peerIdFromNoise } from '../local-peers.js' /** @import Hypercore from 'hypercore' */ /** @import { BlobFilter, GenericBlobFilter, HypercorePeer, Namespace } from '../types.js' */ @@ -401,7 +402,7 @@ export class CoreManager extends ReadyResource { const { start, discoveryKey, bitfield, namespace } = msg if (namespace === 'UNRECOGNIZED') return /** @type {string} */ - const peerId = peer.remotePublicKey.toString('hex') + const peerId = peerIdFromNoise(peer.protomux.stream) const coreDiscoveryId = discoveryKey.toString('hex') this.emit('peer-have', namespace, { coreDiscoveryId, @@ -416,7 +417,7 @@ export class CoreManager extends ReadyResource { * @param {HypercorePeer} peer */ #handleDownloadIntentMessage(blobFilter, peer) { - const peerId = peer.remotePublicKey.toString('hex') + const peerId = peerIdFromNoise(peer.protomux.stream) this.emit('peer-download-intent', blobFilter, peerId) } diff --git a/src/local-peers.js b/src/local-peers.js index 77e892f52..c13d26815 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -32,6 +32,7 @@ import { InvalidProjectJoinDetailsError, MapShareNotSupportedByPeerError, } from './errors.js' + /** @import NoiseStream from '@hyperswarm/secret-stream' */ /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */ /** @import {DeferredPromise} from 'p-defer' */ diff --git a/src/sync/core-sync-state.js b/src/sync/core-sync-state.js index c3a24cb1b..aa93df295 100644 --- a/src/sync/core-sync-state.js +++ b/src/sync/core-sync-state.js @@ -1,9 +1,9 @@ -import { keyToId } from '../utils.js' import RemoteBitfield, { BITS_PER_PAGE, } from '../core-manager/remote-bitfield.js' import { Logger } from '../logger.js' import { InvalidBitfieldIndexError } from '../errors.js' +import { peerIdFromNoise } from '../local-peers.js' /** @import { HypercorePeer, HypercoreRemoteBitfield, Namespace } from '../types.js' */ /** @@ -263,7 +263,7 @@ export class CoreSyncState { * @param {HypercorePeer} peer */ #onPeerAdd = (peer) => { - const peerId = keyToId(peer.remotePublicKey) + const peerId = peerIdFromNoise(peer.protomux.stream) // Update state to ensure this peer is in the state correctly const peerState = this.#getOrCreatePeerState(peerId) @@ -301,7 +301,7 @@ export class CoreSyncState { * @param {HypercorePeer} peer */ #onPeerRemove = (peer) => { - const peerId = keyToId(peer.remotePublicKey) + const peerId = peerIdFromNoise(peer.protomux.stream) const peerState = this.#remoteStates.get(peerId) if (!peerState) return peerState.status = 'stopped' diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 3ce8f329e..dded070ef 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -209,7 +209,6 @@ export class PeerSyncController { * @returns {Promise} */ async #readAndCacheSyncCapability() { - console.trace(this.peerId, 'readAndCacheSyncCapability') try { this.#log('reading role for %S', this.peerId) const cap = await this.#roles.getRole(this.peerId) @@ -301,14 +300,10 @@ export class PeerSyncController { /** @type {(peer: any) => void} */ const handlePeerRemove = (peer) => { - if (!peer.remotePublicKey.equals(Buffer.from(this.peerId))) return + const peerId = peerIdFromNoise(peer.protomux.stream) + if (peerId !== this.peerId) return core.off('peer-remove', handlePeerRemove) - this.#log( - 'peer-remove %h from %s core %k', - peer.remotePublicKey, - namespace, - core.key - ) + this.#log('peer-remove %S from %s core %k', peerId, namespace, core.key) } core.on('peer-remove', handlePeerRemove) } diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 00f77f4e1..2b5bcd0fb 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -8,18 +8,17 @@ import { NAMESPACES, PRESYNC_NAMESPACES, } from '../constants.js' -import { keyToId, noop } from '../utils.js' import { getOwn } from '../lib/get-own.js' import { wsCoreReplicator } from '../lib/ws-core-replicator.js' import { NO_ROLE_ID } from '../roles.js' import { AutoStopTimeoutError, ExhaustivenessError } from '../errors.js' import { peerIdFromNoise } from '../local-peers.js' +import { noop } from '../utils.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ /** @import * as http from 'node:http' */ /** @import { CoreOwnership } from '../core-ownership.js' */ -/** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ -/** @import { BlobFilter, ReplicationStream } from '../types.js' */ +/** @import { HypercorePeer, ReplicationStream } from '../types.js' */ export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') @@ -525,7 +524,7 @@ export class SyncApi extends TypedEmitter { * will then handle validation of role records to ensure that the peer is * actually still part of the project. * - * @param {import('../types.js').HypercorePeer & { protomux: import('protomux') }} peer + * @param {import('../types.js').HypercorePeer} peer */ #handlePeerAdd = (peer) => { const { protomux } = peer @@ -566,21 +565,21 @@ export class SyncApi extends TypedEmitter { * Called when a peer is removed from the creator core, e.g. when the * connection is terminated. * - * @param {{ protomux: import('protomux'), remotePublicKey: Buffer }} peer + * @param {Pick} peer */ #handlePeerDisconnect = (peer) => { - const { protomux, remotePublicKey } = peer + const { protomux } = peer + const peerId = peerIdFromNoise(protomux.stream) const psc = this.#peerSyncControllers.get(protomux) if (!psc) { this.#l.log( - 'Unexpected no existing peer sync controller for peer %h', - remotePublicKey + 'Unexpected no existing peer sync controller for peer %S', + peerId ) return } psc.dispose() this.#peerSyncControllers.delete(protomux) - const peerId = keyToId(peer.remotePublicKey) this.#pscByPeerId.delete(peerId) this.#pendingDiscoveryKeys.delete(protomux) this[kSyncState].disconnectPeer(peerId) diff --git a/src/types.ts b/src/types.ts index da81cd347..9eb974661 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ import { DefaultListener, ListenerSignature } from 'tiny-typed-emitter' import type { NAMESPACES } from './constants.js' import type { Readable } from 'stream' import type { HyperdriveEntry } from 'hyperdrive' +import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' export type Namespace = (typeof NAMESPACES)[number] @@ -126,7 +127,7 @@ export type HypercoreRemoteBitfield = { * TODO: Contribute these types upstream. */ export type HypercorePeer = { - protomux: Protomux + protomux: Protomux remotePublicKey: Buffer remoteBitfield: HypercoreRemoteBitfield onbitfield: (options: { start: number; bitfield: Buffer }) => void From 8b413a8cceac88db89c0f2db2e4bf67d7e203834 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 1 Jun 2026 17:18:15 -0400 Subject: [PATCH 060/103] feat: Encode more data in invite links, z32 for buffers instead of hex --- package-lock.json | 34 +++++++------- package.json | 5 ++ src/errors.js | 6 +-- src/invite/invite-urls.js | 97 +++++++++++++++++++++++++++++++++++++++ src/mapeo-manager.js | 2 +- src/member-api.js | 68 +++++++++++---------------- test/member-api.js | 49 ++++++++++++++------ types/z32.d.ts | 7 +++ 8 files changed, 193 insertions(+), 75 deletions(-) create mode 100644 src/invite/invite-urls.js create mode 100644 types/z32.d.ts diff --git a/package-lock.json b/package-lock.json index dfaa58cac..867ad2548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "ws": "^8.18.0", "xstate": "^5.19.2", "yauzl-promise": "^4.0.0", + "z32": "^1.1.0", "zip-stream-promise": "^1.0.2" }, "devDependencies": { @@ -305,6 +306,19 @@ "zip-stream-promise": "^1.0.2" } }, + "node_modules/@comapeo/cloud/node_modules/@comapeo/schema": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-2.2.0.tgz", + "integrity": "sha512-SbD/Q0BsirAFSj2LETqwgyx/Om62lUVT3R05k9vK5Ld+u+VNn1Xsg2nRblf6Xh9mP1U3rBYJvjjuYQ1lPUYmLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comapeo/geometry": "^1.1.1", + "compact-encoding": "^2.12.0", + "protobufjs": "^7.2.5", + "type-fest": "^4.26.0" + } + }, "node_modules/@comapeo/cloud/node_modules/@mapeo/crypto": { "version": "1.0.0-alpha.10", "resolved": "https://registry.npmjs.org/@mapeo/crypto/-/crypto-1.0.0-alpha.10.tgz", @@ -325,19 +339,6 @@ "z32": "^1.0.0" } }, - "node_modules/@comapeo/cloud/node_modules/@comapeo/schema": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-2.2.0.tgz", - "integrity": "sha512-SbD/Q0BsirAFSj2LETqwgyx/Om62lUVT3R05k9vK5Ld+u+VNn1Xsg2nRblf6Xh9mP1U3rBYJvjjuYQ1lPUYmLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@comapeo/geometry": "^1.1.1", - "compact-encoding": "^2.12.0", - "protobufjs": "^7.2.5", - "type-fest": "^4.26.0" - } - }, "node_modules/@comapeo/cloud/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -12346,9 +12347,10 @@ } }, "node_modules/z32": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/z32/-/z32-1.0.1.tgz", - "integrity": "sha512-Uytfqf6VEVchHKZDw0NRdCViOARHP84uzvOw0CXCMLOwhgHZUL9XibpEPLLQN10mCVLxOlGCQWbkV7km7yNYcw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/z32/-/z32-1.1.0.tgz", + "integrity": "sha512-1WUHy+VS6d0HPNspDxvLssBbeQjXMjSnpv0vH82vRAUfg847NmX3OXozp/hRP5jPhxBbrVzrgvAt+UsGNzRFQQ==", + "license": "MIT", "dependencies": { "b4a": "^1.5.3" } diff --git a/package.json b/package.json index a3d11ff15..26b914977 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "./schema.js": { "types": "./dist/schema.d.ts", "default": "./src/schema.js" + }, + "./invite-urls.js": { + "types": "./dist/invite/invite-urls.d.ts", + "default": "./src/invite/invite-urls.js" } }, "type": "module", @@ -240,6 +244,7 @@ "ws": "^8.18.0", "xstate": "^5.19.2", "yauzl-promise": "^4.0.0", + "z32": "^1.1.0", "zip-stream-promise": "^1.0.2" }, "overrides": { diff --git a/src/errors.js b/src/errors.js index 5692d7149..aa8c5f7a6 100644 --- a/src/errors.js +++ b/src/errors.js @@ -575,9 +575,9 @@ export const UnknownInviteIDError = createErrorClass({ status: 404, }) -export const MissingInviteAndDeviceParamsError = createErrorClass({ - code: 'MISSING_INVITE_AND_DEVICE_PARAMS_ERROR', - message: 'Missing invite and device parameters from URL', +export const MissingInviteURLParameter = createErrorClass({ + code: 'MISSING_INVITE_URL_PARAMETER', + message: 'Invite URL is missing the {paramName} parameter', status: 400, }) diff --git a/src/invite/invite-urls.js b/src/invite/invite-urls.js new file mode 100644 index 000000000..28d0f67c9 --- /dev/null +++ b/src/invite/invite-urls.js @@ -0,0 +1,97 @@ +/** + * @typedef {object} InviteLinkParams + * @property {string} inviteIdString + * @property {string} swarmPublicKey + * @property {string} invitorName + * @property {string} projectName + * @property {number} expiresAt + */ + +import { MissingInviteURLParameter } from '../errors.js' +import z32 from 'z32' +import b4a from 'b4a' + +/** + * @type {Record} + */ +export const URL_PARAM_MAPPINGS = { + inviteIdString: 'i', + swarmPublicKey: 'd', + invitorName: 'n', + projectName: 'p', + expiresAt: 'e', +} + +export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' + +/** + * @param {string} url + * @returns {InviteLinkParams} + */ +export function parseInviteURL(url) { + const { hash } = new URL(url) + + const params = new URLSearchParams(hash.slice(1)) + + /** + * @type {Partial} + */ + const opts = {} + + for (const [optName, paramName] of Object.entries(URL_PARAM_MAPPINGS)) { + const value = params.get(paramName) + if (!value) throw new MissingInviteURLParameter({ paramName }) + if (optName === 'expiresAt') { + // Convert timestamp from seconds string to milliseconds number + opts[optName] = parseInt(value) * 1000 + } else if (optName === 'inviteIdString' || optName === 'swarmPublicKey') { + // Decode z32 and convert to hex + // @ts-ignore Type narrowing on computed key not precise enough + opts[/**@type {keyof InviteLinkParams}*/ (optName)] = b4a.toString( + z32.decode(value), + 'hex' + ) + } else { + // @ts-ignore It complains about the use of Partial + opts[/**@type {keyof InviteLinkParams}*/ (optName)] = value + } + } + + return validateGotAllURLOpts(opts) +} + +/** + * @param {InviteLinkParams} opts + * @returns {string} + */ +export function makeInviteURL(opts) { + const params = new URLSearchParams() + for (const [optName, paramName] of Object.entries(URL_PARAM_MAPPINGS)) { + let value = opts[/**@type {keyof InviteLinkParams}*/ (optName)] + if (typeof value === 'number') { + // Convert milliseconds timestamp to seconds and then to a string + value = Math.floor(value / 1000).toString() + } else if (optName === 'inviteIdString' || optName === 'swarmPublicKey') { + value = z32.encode(b4a.from(value, 'hex')) + } + params.set(paramName, value) + } + const url = INTERNET_INVITE_PAGE + `#` + params.toString() + + return url +} + +/** + * + * @param {Partial} opts + * @returns {InviteLinkParams} + */ +function validateGotAllURLOpts(opts) { + for (const paramName in opts) { + if (!opts[/**@type {keyof InviteLinkParams}*/ (paramName)]) { + throw new MissingInviteURLParameter({ paramName }) + } + } + // @ts-ignore + return opts +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 9cf82c98a..9dd0a66b3 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -66,9 +66,9 @@ import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' -import { parseInviteURL } from './member-api.js' import { pEvent } from 'p-event' import { InviteLinksApi } from './invite/invite-links-api.js' +import { parseInviteURL } from './invite/invite-urls.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ diff --git a/src/member-api.js b/src/member-api.js index 725126b9a..9dc19ebbc 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -39,7 +39,6 @@ import { InviteNotYetRedeemedError, PeerDisconnectedSinceRedeemingInviteError, UnknownInviteIDError, - MissingInviteAndDeviceParamsError, CannotBlockSelfError, } from './errors.js' import { wsCoreReplicator } from './lib/ws-core-replicator.js' @@ -52,8 +51,9 @@ import { ROLES, isRoleIdForNewInvite, } from './roles.js' +import { DEFAULT_INVITE_EXPIRY_MS } from './invite/invite-links-api.js' +import { makeInviteURL, parseInviteURL } from './invite/invite-urls.js' -export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** @@ -89,6 +89,10 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {string} selfHostedServerDetails.baseUrl */ +/** + * @typedef {Pick} MemberDeviceInfo + */ + /** * @typedef {object} InvitePeerInfo * @prop {DeviceInfo['name']} name @@ -105,12 +109,6 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {InvitePeerInfo} [peerInfo] */ -/** - * @typedef {object} InviteLinkParams - * @property {string} inviteIdString - * @property {string} swarmPublicKey - */ - /** * @typedef {( * typeof InviteResponse_Decision.ACCEPT | @@ -173,7 +171,7 @@ export class MemberApi extends TypedEmitter { * @param {(deviceId: string) => Promise} opts.markInternetPeerAsTrusted * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {() => Promise} opts.getProjectSettings - * @param {(deviceId: string) => Promise} opts.getDeviceInfo + * @param {(deviceId: string) => Promise} opts.getDeviceInfo * @param {(deviceId: string, deviceInfo: NewDeviceInfo) => Promise} opts.setDeviceInfo * @param {Logger} [opts.logger] */ @@ -232,8 +230,27 @@ export class MemberApi extends TypedEmitter { const inviteId = opts.__testOnlyInviteId || crypto.randomBytes(32) const inviteIdString = inviteId.toString('hex') const swarmPublicKey = this.#getSwarmPublicKey().toString('hex') + const project = await this.#getProjectSettings() + const projectName = project.name + if (!projectName) { + throw new InvalidProjectNameError() + } - const url = makeInviteURL({ inviteIdString, swarmPublicKey }) + const { name: invitorName } = await this.getById(this.#ownDeviceId) + + if (!invitorName) { + throw new UnexpectedError( + 'Internal error trying to read own device name for this invite' + ) + } + + const url = makeInviteURL({ + inviteIdString, + swarmPublicKey, + invitorName, + projectName, + expiresAt: Date.now() + DEFAULT_INVITE_EXPIRY_MS, + }) await this.#inviteLinks.create({ inviteId: inviteIdString, @@ -1013,34 +1030,3 @@ async function parseAddServerResponse(response) { `Failed to add server peer due to HTTP status code ${response.status}` ) } - -/** - * @param {string} url - * @returns {InviteLinkParams} - */ -export function parseInviteURL(url) { - const { hash } = new URL(url) - - const params = new URLSearchParams(hash.slice(1)) - - const inviteIdString = params.get('i') - const swarmPublicKey = params.get('d') - - if ( - typeof inviteIdString !== 'string' || - typeof swarmPublicKey !== 'string' - ) { - throw new MissingInviteAndDeviceParamsError() - } - return { inviteIdString, swarmPublicKey } -} - -/** - * @param {InviteLinkParams} opts - * @returns {string} - */ -export function makeInviteURL({ inviteIdString, swarmPublicKey }) { - const url = INTERNET_INVITE_PAGE + `#i=${inviteIdString}&d=${swarmPublicKey}` - - return url -} diff --git a/test/member-api.js b/test/member-api.js index 066d69404..e13125046 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -3,27 +3,37 @@ import assert from 'node:assert/strict' import { randomBytes } from 'node:crypto' import { KeyManager } from '@mapeo/crypto' -import { makeInviteURL, MemberApi, parseInviteURL } from '../src/member-api.js' +import { MemberApi } from '../src/member-api.js' import { LocalPeers } from '../src/local-peers.js' -import { MEMBER_ROLE_ID } from '../src/roles.js' +import { MEMBER_ROLE_ID, ROLES } from '../src/roles.js' +import { DeviceInfo_DeviceType } from '../src/generated/rpc.js' +import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ /** @import { InviteLinkCreate, InviteLinkRecord } from '../src/invite/invite-links-api.js' */ -test('serialize and parse invite URLs', () => { - const testSwarmPublicKey = 'foo' - const testInviteId = 'bar' +const MAX_URL_LENGTH = 256 - const url = makeInviteURL({ +test('serialize and parse invite URLs', () => { + const testSwarmPublicKey = randomBytes(32).toString('hex') + const testInviteId = randomBytes(32).toString('hex') + // Timestamp gets rounded to seconds anyway + const expiresAt = Math.round(Date.now() / 1000) * 1000 + const params = { inviteIdString: testInviteId, swarmPublicKey: testSwarmPublicKey, - }) + invitorName: 'some name here', + projectName: 'my cool project', + expiresAt, + } + const url = makeInviteURL(params) - const { inviteIdString, swarmPublicKey } = parseInviteURL(url) + assert(url.length < MAX_URL_LENGTH, 'URL smaller than the max') - assert.equal(inviteIdString, testInviteId) - assert.equal(swarmPublicKey, testSwarmPublicKey) + const parsedParams = parseInviteURL(url) + + assert.deepEqual(parsedParams, params, 'got same params that got passed in') }) test('List pending invites over internet', async () => { @@ -114,7 +124,13 @@ test('Pending invites are loaded from persistence on ready', async () => { const inviteId = randomBytes(32) const inviteIdString = inviteId.toString('hex') const swarmPublicKey = randomBytes(32).toString('hex') - const url = makeInviteURL({ inviteIdString, swarmPublicKey }) + const url = makeInviteURL({ + inviteIdString, + swarmPublicKey, + invitorName: 'hello', + projectName: 'world', + expiresAt: Date.now(), + }) // Pre-populate the mock with a pending invite await inviteLinks.create({ @@ -156,7 +172,7 @@ class MockRoles { * @returns {Promise} */ async getRole(_deviceId) { - throw new Error('Not Implemented') + return ROLES[MEMBER_ROLE_ID] } /** * @returns {Promise>} Map of deviceId to Role @@ -243,7 +259,7 @@ class MockInviteLinksApiForProject { * @param {(deviceId: string) => Promise} [opts.markInternetPeerAsTrusted] * @param {(deviceId: string) => Promise} [opts.disconnectFromPeer] * @param {() => Promise} [opts.getProjectSettings] - * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] + * @param {(deviceId: string) => Promise} [opts.getDeviceInfo] * @param {(deviceId: string, deviceInfo: import('../src/member-api.js').NewDeviceInfo) => Promise} [opts.setDeviceInfo] * @param {MockInviteLinksApiForProject} [opts.inviteLinks] * @returns @@ -252,7 +268,12 @@ function setup({ rootKey = Buffer.alloc(16, 1), getProjectSettings = () => Promise.resolve({ name: 'example', sendStats: false }), - getDeviceInfo = () => Promise.reject(new Error('Not implemented')), + getDeviceInfo = () => + Promise.resolve({ + name: 'Test Device', + deviceType: DeviceInfo_DeviceType.desktop, + createdAt: new Date().toString(), + }), setDeviceInfo = () => Promise.reject(new Error('Not implemented')), waitForInitialSyncWithPeer = () => Promise.resolve(), disconnectFromPeer = () => Promise.resolve(), diff --git a/types/z32.d.ts b/types/z32.d.ts new file mode 100644 index 000000000..6f896db2f --- /dev/null +++ b/types/z32.d.ts @@ -0,0 +1,7 @@ +declare module 'z32' { + function encode(buf: Uint8Array | string): string + + function decode(s: string, out?: Uint8Array): Uint8Array + + export { encode, decode } +} From e873ecd1c426cec009e3119214437b0b2024b5d6 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 2 Jun 2026 16:53:02 -0400 Subject: [PATCH 061/103] feat: use crockford base32 for invite links --- package-lock.json | 17 +++++++++++++++- package.json | 2 +- src/errors.js | 7 +++++++ src/invite/invite-urls.js | 24 ++++++++++++++-------- test-e2e/invite-over-internet.js | 31 +++++++++++++---------------- test/member-api.js | 34 ++++++++++++++++++++++++++++++++ types/z32.d.ts | 7 ------- 7 files changed, 88 insertions(+), 34 deletions(-) delete mode 100644 types/z32.d.ts diff --git a/package-lock.json b/package-lock.json index 867ad2548..ca61bb00a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "comapeocat": "^1.0.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", + "crockford-base32": "^2.1.0", "custom-error-creator": "^1.1.1", "debug": "^4.3.4", "dot-prop": "^9.0.0", @@ -71,7 +72,6 @@ "ws": "^8.18.0", "xstate": "^5.19.2", "yauzl-promise": "^4.0.0", - "z32": "^1.1.0", "zip-stream-promise": "^1.0.2" }, "devDependencies": { @@ -4538,6 +4538,21 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/crockford-base32": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/crockford-base32/-/crockford-base32-2.1.0.tgz", + "integrity": "sha512-vadAEK4CkOKWaIkf0oWr3XMaYpXCyAwwE8N6rCUye6/QsVXn7cuJQXp2Yuy9BKgsCoXlkixTu+o3PXUoW1s5lw==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.17.29" + } + }, + "node_modules/crockford-base32/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.3", "license": "MIT", diff --git a/package.json b/package.json index 26b914977..817930162 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "comapeocat": "^1.0.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", + "crockford-base32": "^2.1.0", "custom-error-creator": "^1.1.1", "debug": "^4.3.4", "dot-prop": "^9.0.0", @@ -244,7 +245,6 @@ "ws": "^8.18.0", "xstate": "^5.19.2", "yauzl-promise": "^4.0.0", - "z32": "^1.1.0", "zip-stream-promise": "^1.0.2" }, "overrides": { diff --git a/src/errors.js b/src/errors.js index aa8c5f7a6..266c74826 100644 --- a/src/errors.js +++ b/src/errors.js @@ -581,6 +581,13 @@ export const MissingInviteURLParameter = createErrorClass({ status: 400, }) +export const InvalidInviteURLKeyParameterError = createErrorClass({ + code: 'INVALID_INVITE_URL_KEY_PARAMETER_ERROR', + message: + 'Invite URL key parameter "{paramName}" must be 32 bytes after decoding, got {byteLength} bytes', + status: 400, +}) + export const InviteLinkAlreadyExistsError = createErrorClass({ code: 'INVITE_LINK_ALREADY_EXISTS_ERROR', message: 'Invite link with ID {inviteId} already exists', diff --git a/src/invite/invite-urls.js b/src/invite/invite-urls.js index 28d0f67c9..a2f271efb 100644 --- a/src/invite/invite-urls.js +++ b/src/invite/invite-urls.js @@ -7,9 +7,11 @@ * @property {number} expiresAt */ -import { MissingInviteURLParameter } from '../errors.js' -import z32 from 'z32' -import b4a from 'b4a' +import { + MissingInviteURLParameter, + InvalidInviteURLKeyParameterError, +} from '../errors.js' +import { CrockfordBase32 } from 'crockford-base32' /** * @type {Record} @@ -46,11 +48,17 @@ export function parseInviteURL(url) { opts[optName] = parseInt(value) * 1000 } else if (optName === 'inviteIdString' || optName === 'swarmPublicKey') { // Decode z32 and convert to hex + const decoded = CrockfordBase32.decode(value) + if (decoded.length !== 32) { + console.log({ decoded, value, params, url }) + throw new InvalidInviteURLKeyParameterError({ + paramName: optName, + byteLength: decoded.length, + }) + } // @ts-ignore Type narrowing on computed key not precise enough - opts[/**@type {keyof InviteLinkParams}*/ (optName)] = b4a.toString( - z32.decode(value), - 'hex' - ) + opts[/**@type {keyof InviteLinkParams}*/ (optName)] = + decoded.toString('hex') } else { // @ts-ignore It complains about the use of Partial opts[/**@type {keyof InviteLinkParams}*/ (optName)] = value @@ -72,7 +80,7 @@ export function makeInviteURL(opts) { // Convert milliseconds timestamp to seconds and then to a string value = Math.floor(value / 1000).toString() } else if (optName === 'inviteIdString' || optName === 'swarmPublicKey') { - value = z32.encode(b4a.from(value, 'hex')) + value = CrockfordBase32.encode(Buffer.from(value, 'hex')) } params.set(paramName, value) } diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 58e48420e..4a6c2b532 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -1,5 +1,6 @@ import test from 'node:test' import fsPromises from 'node:fs/promises' +import { randomBytes } from 'node:crypto' import { createManager, createManagers } from './utils.js' import { MEMBER_ROLE_ID } from '../src/roles.js' import assert from 'node:assert/strict' @@ -12,10 +13,10 @@ import { ensureKnownError, InviteNotYetRedeemedError, } from '../src/errors.js' -import crypto from 'node:crypto' +import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' import { temporaryDirectory } from 'tempy' -test.only('invite over internet and join from URL', async (t) => { +test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, }) @@ -232,13 +233,11 @@ test('invite over internet errors if invitor deviceID is invalid', async (t) => roleId: MEMBER_ROLE_ID, }) - // Parse and modify the URL to use an invalid device ID - const urlObj = new URL(url) - const params = new URLSearchParams(urlObj.hash.slice(1)) - const invalidDeviceId = '0'.repeat(64) // Invalid device ID - params.set('d', invalidDeviceId) - urlObj.hash = params.toString() - const modifiedUrl = urlObj.toString() + const parsed = parseInviteURL(url) + const modifiedUrl = makeInviteURL({ + ...parsed, + swarmPublicKey: randomBytes(32).toString('hex'), + }) // Try to join with invalid device ID - should fail immediately // The invitee won't be able to connect to the non-existent device @@ -301,7 +300,7 @@ test('invite over internet errors if inviter closes before accepting', async (t) ) }) -test('invite over internet errors if invitee uses random invalid inviteId', async (t) => { +test('invite over internet errors if invitee uses invalid inviteId', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, }) @@ -318,13 +317,11 @@ test('invite over internet errors if invitee uses random invalid inviteId', asyn roleId: MEMBER_ROLE_ID, }) - // Parse and modify the URL to use an invalid invite ID - const urlObj = new URL(url) - const params = new URLSearchParams(urlObj.hash.slice(1)) - const invalidInviteId = crypto.randomBytes(32).toString('hex') // Random 32-byte invite ID - params.set('i', invalidInviteId) - urlObj.hash = params.toString() - const modifiedUrl = urlObj.toString() + const parsed = parseInviteURL(url) + const modifiedUrl = makeInviteURL({ + ...parsed, + inviteIdString: randomBytes(32).toString('hex'), + }) // Expect the invitor to emit an error when the invalid invite is attempted const onError = pEvent(project.$member, 'internet-invite-redeem-error', { diff --git a/test/member-api.js b/test/member-api.js index e13125046..e52547d23 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -8,6 +8,8 @@ import { LocalPeers } from '../src/local-peers.js' import { MEMBER_ROLE_ID, ROLES } from '../src/roles.js' import { DeviceInfo_DeviceType } from '../src/generated/rpc.js' import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' +import { InvalidInviteURLKeyParameterError } from '../src/errors.js' +import { CrockfordBase32 } from 'crockford-base32' /** @import { ProjectJoinDetails } from '../src/generated/rpc.js' */ /** @import WebSocket from 'ws' */ @@ -36,6 +38,38 @@ test('serialize and parse invite URLs', () => { assert.deepEqual(parsedParams, params, 'got same params that got passed in') }) +test('parseInviteURL throws on non-32-byte invite id', () => { + // Encode only 16 bytes instead of 32 + const shortKey = CrockfordBase32.encode(randomBytes(16)) + const url = `https://i.comapeo.app/invite/#i=${shortKey}&d=${CrockfordBase32.encode( + randomBytes(32) + )}&n=test&p=test&e=${Math.floor(Date.now() / 1000)}` + + assert.throws( + () => parseInviteURL(url), + { + code: InvalidInviteURLKeyParameterError.code, + }, + 'should throw InvalidInviteURLKeyParameterError for short invite id' + ) +}) + +test('parseInviteURL throws on non-32-byte swarm public key', () => { + // Encode only 16 bytes instead of 32 + const shortKey = CrockfordBase32.encode(randomBytes(16)) + const url = `https://i.comapeo.app/invite/#i=${CrockfordBase32.encode( + randomBytes(32) + )}&d=${shortKey}&n=test&p=test&e=${Math.floor(Date.now() / 1000)}` + + assert.throws( + () => parseInviteURL(url), + { + code: InvalidInviteURLKeyParameterError.code, + }, + 'should throw InvalidInviteURLKeyParameterError for short swarm public key' + ) +}) + test('List pending invites over internet', async () => { const { member, inviteLinks } = setup({}) diff --git a/types/z32.d.ts b/types/z32.d.ts deleted file mode 100644 index 6f896db2f..000000000 --- a/types/z32.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module 'z32' { - function encode(buf: Uint8Array | string): string - - function decode(s: string, out?: Uint8Array): Uint8Array - - export { encode, decode } -} From a1a91560edf646e629c0faf8b4feb5bda5a011ee Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 4 Jun 2026 12:39:54 -0400 Subject: [PATCH 062/103] feat: cancel and deny invite over internet --- proto/rpc.proto | 8 ++ src/errors.js | 12 +++ src/generated/rpc.d.ts | 18 ++++ src/generated/rpc.js | 84 ++++++++++++++++++ src/generated/rpc.ts | 98 +++++++++++++++++++++ src/local-peers.js | 66 ++++++++++++++- src/mapeo-manager.js | 68 ++++++++++++++- src/member-api.js | 47 ++++++++--- test-e2e/invite-over-internet.js | 141 ++++++++++++++++++++++++++----- 9 files changed, 506 insertions(+), 36 deletions(-) diff --git a/proto/rpc.proto b/proto/rpc.proto index 25b84ce46..63d88e48e 100644 --- a/proto/rpc.proto +++ b/proto/rpc.proto @@ -40,6 +40,10 @@ message RedeemInviteOverInternet { bytes inviteId = 1; } +message DenyInviteOverInternet { + bytes inviteId = 1; +} + message DeviceInfo { enum DeviceType { device_type_unspecified = 0; @@ -77,6 +81,10 @@ message RedeemInviteOverInternetAck { bytes inviteId = 1; } +message DenyInviteOverInternetAck { + bytes inviteId = 1; +} + message MapShareExtension { // URLs to map share repeated string mapShareUrls = 1; diff --git a/src/errors.js b/src/errors.js index 266c74826..12375ef35 100644 --- a/src/errors.js +++ b/src/errors.js @@ -594,6 +594,18 @@ export const InviteLinkAlreadyExistsError = createErrorClass({ status: 409, }) +export const InviteDeniedByInviterError = createErrorClass({ + code: 'INVITE_DENIED_BY_INVITER_ERROR', + message: 'Invitation denied by project inviter', + status: 403, +}) + +export const JoinProjectCancelledError = createErrorClass({ + code: 'JOIN_PROJECT_CANCELLED_ERROR', + message: 'Join project was cancelled', + status: 499, +}) + /** * @param {unknown} err * @returns {null} diff --git a/src/generated/rpc.d.ts b/src/generated/rpc.d.ts index f4f0d74a2..275d1546c 100644 --- a/src/generated/rpc.d.ts +++ b/src/generated/rpc.d.ts @@ -37,6 +37,9 @@ export interface ProjectJoinDetails { export interface RedeemInviteOverInternet { inviteId: Buffer; } +export interface DenyInviteOverInternet { + inviteId: Buffer; +} export interface DeviceInfo { name: string; deviceType?: DeviceInfo_DeviceType | undefined; @@ -76,6 +79,9 @@ export interface ProjectJoinDetailsAck { export interface RedeemInviteOverInternetAck { inviteId: Buffer; } +export interface DenyInviteOverInternetAck { + inviteId: Buffer; +} export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -130,6 +136,12 @@ export declare const RedeemInviteOverInternet: { create, I>>(base?: I): RedeemInviteOverInternet; fromPartial, I>>(object: I): RedeemInviteOverInternet; }; +export declare const DenyInviteOverInternet: { + encode(message: DenyInviteOverInternet, writer?: _m0.Writer): _m0.Writer; + decode(input: _m0.Reader | Uint8Array, length?: number): DenyInviteOverInternet; + create, I>>(base?: I): DenyInviteOverInternet; + fromPartial, I>>(object: I): DenyInviteOverInternet; +}; export declare const DeviceInfo: { encode(message: DeviceInfo, writer?: _m0.Writer): _m0.Writer; decode(input: _m0.Reader | Uint8Array, length?: number): DeviceInfo; @@ -166,6 +178,12 @@ export declare const RedeemInviteOverInternetAck: { create, I>>(base?: I): RedeemInviteOverInternetAck; fromPartial, I>>(object: I): RedeemInviteOverInternetAck; }; +export declare const DenyInviteOverInternetAck: { + encode(message: DenyInviteOverInternetAck, writer?: _m0.Writer): _m0.Writer; + decode(input: _m0.Reader | Uint8Array, length?: number): DenyInviteOverInternetAck; + create, I>>(base?: I): DenyInviteOverInternetAck; + fromPartial, I>>(object: I): DenyInviteOverInternetAck; +}; export declare const MapShareExtension: { encode(message: MapShareExtension, writer?: _m0.Writer): _m0.Writer; decode(input: _m0.Reader | Uint8Array, length?: number): MapShareExtension; diff --git a/src/generated/rpc.js b/src/generated/rpc.js index d2867956d..ea514ec3a 100644 --- a/src/generated/rpc.js +++ b/src/generated/rpc.js @@ -461,6 +461,48 @@ export var RedeemInviteOverInternet = { return message; }, }; +function createBaseDenyInviteOverInternet() { + return { inviteId: Buffer.alloc(0) }; +} +export var DenyInviteOverInternet = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseDenyInviteOverInternet(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.inviteId = reader.bytes(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return DenyInviteOverInternet.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a; + var message = createBaseDenyInviteOverInternet(); + message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + return message; + }, +}; function createBaseDeviceInfo() { return { name: "", features: [] }; } @@ -743,6 +785,48 @@ export var RedeemInviteOverInternetAck = { return message; }, }; +function createBaseDenyInviteOverInternetAck() { + return { inviteId: Buffer.alloc(0) }; +} +export var DenyInviteOverInternetAck = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseDenyInviteOverInternetAck(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.inviteId = reader.bytes(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return DenyInviteOverInternetAck.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a; + var message = createBaseDenyInviteOverInternetAck(); + message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + return message; + }, +}; function createBaseMapShareExtension() { return { mapShareUrls: [], diff --git a/src/generated/rpc.ts b/src/generated/rpc.ts index 457f2715d..02e06ce43 100644 --- a/src/generated/rpc.ts +++ b/src/generated/rpc.ts @@ -82,6 +82,10 @@ export interface RedeemInviteOverInternet { inviteId: Buffer; } +export interface DenyInviteOverInternet { + inviteId: Buffer; +} + export interface DeviceInfo { name: string; deviceType?: DeviceInfo_DeviceType | undefined; @@ -196,6 +200,10 @@ export interface RedeemInviteOverInternetAck { inviteId: Buffer; } +export interface DenyInviteOverInternetAck { + inviteId: Buffer; +} + export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -587,6 +595,51 @@ export const RedeemInviteOverInternet = { }, }; +function createBaseDenyInviteOverInternet(): DenyInviteOverInternet { + return { inviteId: Buffer.alloc(0) }; +} + +export const DenyInviteOverInternet = { + encode(message: DenyInviteOverInternet, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): DenyInviteOverInternet { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDenyInviteOverInternet(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.inviteId = reader.bytes() as Buffer; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DenyInviteOverInternet { + return DenyInviteOverInternet.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DenyInviteOverInternet { + const message = createBaseDenyInviteOverInternet(); + message.inviteId = object.inviteId ?? Buffer.alloc(0); + return message; + }, +}; + function createBaseDeviceInfo(): DeviceInfo { return { name: "", features: [] }; } @@ -891,6 +944,51 @@ export const RedeemInviteOverInternetAck = { }, }; +function createBaseDenyInviteOverInternetAck(): DenyInviteOverInternetAck { + return { inviteId: Buffer.alloc(0) }; +} + +export const DenyInviteOverInternetAck = { + encode(message: DenyInviteOverInternetAck, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.inviteId.length !== 0) { + writer.uint32(10).bytes(message.inviteId); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): DenyInviteOverInternetAck { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDenyInviteOverInternetAck(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.inviteId = reader.bytes() as Buffer; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + create, I>>(base?: I): DenyInviteOverInternetAck { + return DenyInviteOverInternetAck.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DenyInviteOverInternetAck { + const message = createBaseDenyInviteOverInternetAck(); + message.inviteId = object.inviteId ?? Buffer.alloc(0); + return message; + }, +}; + function createBaseMapShareExtension(): MapShareExtension { return { mapShareUrls: [], diff --git a/src/local-peers.js b/src/local-peers.js index c13d26815..7f239cadf 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -18,6 +18,8 @@ import { MapShareExtension, RedeemInviteOverInternet, RedeemInviteOverInternetAck, + DenyInviteOverInternet, + DenyInviteOverInternetAck, } from './generated/rpc.js' import pDefer from 'p-defer' import { Logger } from './logger.js' @@ -78,6 +80,8 @@ const MESSAGE_TYPES = { MapShareExtension: 9, RedeemInviteOverInternet: 10, RedeemInviteOverInternetAck: 11, + DenyInviteOverInternet: 12, + DenyInviteOverInternetAck: 13, } const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) @@ -431,6 +435,37 @@ class Peer { await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) } + /** + * @param {DenyInviteOverInternet} deny + * @returns {Promise} + */ + async sendDenyInviteOverInternet(deny) { + this.#assertConnected('Peer disconnected before sending deny over internet') + const buf = Buffer.from(DenyInviteOverInternet.encode(deny).finish()) + const messageType = MESSAGE_TYPES.DenyInviteOverInternet + await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) + await this.#waitForAck('DenyInviteOverInternetAck', ({ inviteId }) => + timingSafeEqual(inviteId, deny.inviteId) + ) + this.#log('denied invite over internet %h', deny.inviteId) + } + + /** + * @param {DenyInviteOverInternet} deny + * @returns {Promise} + */ + async sendDenyInviteOverInternetAck({ inviteId }) { + this.#assertConnected( + 'Peer disconnected before sending deny over internet ack' + ) + if (!this.supportsAck()) return + const buf = Buffer.from( + DenyInviteOverInternetAck.encode({ inviteId }).finish() + ) + const messageType = MESSAGE_TYPES.DenyInviteOverInternetAck + await this.#waitForDrain(this.#channel.messages[messageType].send(buf)) + } + /** * @param {RedeemInviteOverInternet} redeem * @returns {Promise} @@ -525,7 +560,9 @@ class Peer { * @property {(peerId: string, inviteResponse: InviteResponse) => void} invite-response Emitted when an invite response is received * @property {(peerId: string, inviteResponse: InviteResponseAck) => void} invite-response-ack Emitted when an invite response acknowledgement is received * @property {(peerId: string, inviteResponse: RedeemInviteOverInternet) => void} invite-over-internet-redeemed Emitted when a peer attempts to redeem an invite over the internet - * @property {(peerId: string, inviteResponse: RedeemInviteOverInternetAck) => void} invite-over-internet-redeemed-ack Emitted when an invite response acknowledgement is received + * @property {(peerId: string, inviteResponse: RedeemInviteOverInternetAck) => void} invite-over-internet-redeemed-ack Emitted when an invite redeem acknowledgement is received + * @property {(peerId: string, inviteResponse: DenyInviteOverInternet) => void} invite-over-internet-denied Emitted when a peer attempts to deny an invite over the internet + * @property {(peerId: string, inviteResponse: DenyInviteOverInternetAck) => void} invite-over-internet-denied-ack Emitted when an invite deny acknowledgement is received * @property {(peerId: string, details: ProjectJoinDetails) => void} got-project-details Emitted when project details are received * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received * @property {(sender: PeerInfo, details: MapShareExtension) => void} map-share Emitted when a MapShare request is received @@ -612,6 +649,18 @@ export class LocalPeers extends TypedEmitter { await peer.sendInviteResponse(inviteResponse) } + /** + * Deny an invite over the internet + * + * @param {string} deviceId id of the peer you want to deny from (publicKey of peer as hex string) + * @param {DenyInviteOverInternet} deny + */ + async sendDenyInviteOverInternet(deviceId, deny) { + await this.#waitForPendingConnections() + const peer = await this.#getPeerByDeviceId(deviceId) + await peer.sendDenyInviteOverInternet(deny) + } + /** * Redeem an invite over the internet * @@ -972,6 +1021,21 @@ export class LocalPeers extends TypedEmitter { this.emit('invite-over-internet-redeemed-ack', peer.id, ack) break } + case 'DenyInviteOverInternet': { + const deny = DenyInviteOverInternet.decode(value) + this.emit('invite-over-internet-denied', peer.id, deny) + peer.sendDenyInviteOverInternetAck(deny).catch((e) => { + this.#l.log(`Error sending deny over internet ack ${e.stack}`) + }) + break + } + case 'DenyInviteOverInternetAck': { + if (!peer.isTrusted) return + const ack = DenyInviteOverInternetAck.decode(value) + peer.receiveAck('DenyInviteOverInternetAck', ack) + this.emit('invite-over-internet-denied-ack', peer.id, ack) + break + } /* c8 ignore next 2 */ default: throw new ExhaustivenessError(type) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 9dd0a66b3..363770b30 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -61,6 +61,8 @@ import { ProjectExistsError, InviteRedeemConnectionClosedError, InvalidMapShareReceiverError, + InviteDeniedByInviterError, + JoinProjectCancelledError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -172,6 +174,10 @@ export class MapeoManager extends TypedEmitter { #makeWebsocket #defaultIsArchiveDevice + // Maps invite ID (hex string) to AbortController for in-flight redeem attempts + /** @type {Map} */ + #outboundRedeemInvites = new Map() + /** * @param {Object} opts * @param {Buffer} opts.rootKey 16-bytes of random data that uniquely identify the device, used to derive a 32-byte master key, which is used to derive all the keypairs used for Mapeo @@ -248,6 +254,10 @@ export class MapeoManager extends TypedEmitter { }) ) + this.#localPeers.on('invite-over-internet-denied', (peerId, deny) => + this.#handleInviteDenied(peerId, deny) + ) + this.#projectSettingsIndexWriter = new IndexWriter({ tables: [projectSettingsTable], sqlite, @@ -750,13 +760,30 @@ export class MapeoManager extends TypedEmitter { * @param {number} [options.timeout] * @returns {Promise} */ - async joinProjectOverInternet(url, { timeout = 60_000 } = {}) { + async joinProjectFromLink(url, { timeout = 60_000 } = {}) { const { swarmPublicKey, inviteIdString } = parseInviteURL(url) const inviteId = Buffer.from(inviteIdString, 'hex') + const redeemAbortController = new AbortController() + const onAbort = new Promise((_, reject) => { + redeemAbortController.signal.addEventListener( + 'abort', + () => reject(redeemAbortController.signal.reason), + { once: true } + ) + }) + this.#outboundRedeemInvites.set(inviteIdString, redeemAbortController) + const connection = await this.#remoteDiscovery.connectPeer(swarmPublicKey, { timeout, }) + + redeemAbortController.signal.addEventListener( + 'abort', + () => connection.end(), + { once: true } + ) + const onClose = pEvent(connection, 'close').then( () => { throw new InviteRedeemConnectionClosedError() @@ -781,18 +808,36 @@ export class MapeoManager extends TypedEmitter { inviteId, }), onClose, + onAbort, ]) - const invite = await Promise.race([onInvited, onClose]) + const invite = await Promise.race([onInvited, onClose, onAbort]) const projectId = await this.#invite.accept(invite) return projectId } finally { + this.#outboundRedeemInvites.delete(inviteIdString) connection.end() } } + /** + * Cancel an in-flight join project attempt initiated via joinProjectFromLink. + * If no matching redeem attempt is found, nothing happens. + * + * @param {string} url - The invite URL + * @returns {Promise} + */ + async cancelJoinProjectFromLink(url) { + const { inviteIdString } = parseInviteURL(url) + const ac = this.#outboundRedeemInvites.get(inviteIdString) + if (ac) { + ac.abort(new JoinProjectCancelledError()) + this.#outboundRedeemInvites.delete(inviteIdString) + } + } + /** * Add a project to this device. After adding a project the client should * await `project.$waitForInitialSync()` to ensure that the device has @@ -1200,6 +1245,25 @@ export class MapeoManager extends TypedEmitter { this.emit('map-share', mapShare) } + /** + * Handle an incoming deny from the RPC layer, aborting a matching redeem attempt if pending. + * @param {string} peerId + * @param {{ inviteId: Buffer }} deny + */ + #handleInviteDenied(peerId, deny) { + const inviteIdString = deny.inviteId.toString('hex') + this.#l.log( + 'Got deny for invite %S from %S', + inviteIdString.slice(0, 7), + peerId + ) + const ac = this.#outboundRedeemInvites.get(inviteIdString) + if (ac) { + ac.abort(new InviteDeniedByInviterError()) + this.#outboundRedeemInvites.delete(inviteIdString) + } + } + async getMapStyleJsonUrl() { await timeoutPromise(Promise.resolve(this.#fastify.ready()), { milliseconds: 1000, diff --git a/src/member-api.js b/src/member-api.js index 9dc19ebbc..57c4abb79 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -123,8 +123,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** * @typedef {object} MemberEvents - * @property {(deviceId: string, inviteId: string) => void} internet-invite-redeemed Emitted when an invite over the internet has been redeemed, accept the deviceId to add them - * @property {(inviteId: string) => void} internet-invite-cancelled Emitted when an invite over the internet has been redeemed, accept the deviceId to add them + * @property {(deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet has been redeemed, accept the deviceId to add them * @property {(err: Error, deviceId: string, url: string) => void} internet-invite-redeem-error Emitted when an invite over the internet has failed to be redeemed */ @@ -268,10 +267,6 @@ export class MemberApi extends TypedEmitter { */ async cancelInviteLink(url) { if (!url) { - const invites = await this.#inviteLinks.getAll() - for (const invite of invites) { - this.emit('internet-invite-cancelled', invite.inviteId) - } await this.#inviteLinks.deleteAll() return } @@ -288,19 +283,19 @@ export class MemberApi extends TypedEmitter { throw new InvalidInternetInviteURLError() } this.#redeemedInvites.delete(inviteIdString) - this.emit('internet-invite-cancelled', inviteIdString) await this.#inviteLinks.delete(inviteIdString) } /** * Get the list of pending invites over the internet - * @returns {Promise[]>} + * @returns {Promise[]>} */ async listInviteLinks() { const invites = await this.#inviteLinks.getAll() - return invites.map(({ url, inviteId, createdAt, expiresAt }) => ({ + return invites.map(({ url, inviteId, createdAt, expiresAt, roleId }) => ({ url, inviteId, + roleId, createdAt, expiresAt, })) @@ -368,7 +363,7 @@ export class MemberApi extends TypedEmitter { } else { redeemedSet.add(peerId) } - this.emit('internet-invite-redeemed', peerId, inviteIdString) + this.emit('invite-link-join-request', inviteIdString, peerId) } catch (e) { this.emit( 'internet-invite-redeem-error', @@ -387,7 +382,7 @@ export class MemberApi extends TypedEmitter { * @param {string} opts.deviceId * @returns {Promise} */ - async acceptRedeemedInvite({ inviteId, deviceId }) { + async acceptInviteLinkRequest({ inviteId, deviceId }) { const redeemedSet = this.#redeemedInvites.get(inviteId) if (!redeemedSet || !redeemedSet.has(deviceId)) { throw new InviteNotYetRedeemedError() @@ -413,6 +408,36 @@ export class MemberApi extends TypedEmitter { return decision } + /** + * Deny a specific device's attempt at redeeming an invite. + * @param {object} opts + * @param {string} opts.inviteId + * @param {string} opts.deviceId + * @returns {Promise} + */ + async denyInviteLinkRequest({ inviteId, deviceId }) { + const redeemedSet = this.#redeemedInvites.get(inviteId) + if (!redeemedSet || !redeemedSet.has(deviceId)) { + throw new InviteNotYetRedeemedError() + } + + const pendingInvite = await this.#inviteLinks.getById(inviteId) + if (!pendingInvite) { + throw new UnknownInviteIDError() + } + + try { + await this.#rpc.sendDenyInviteOverInternet(deviceId, { + inviteId: Buffer.from(inviteId, 'hex'), + }) + } catch { + // RPC may fail if the peer disconnected, that's ok + } + + await this.#cancelInviteLinkById(inviteId) + await this.#disconnectFromPeer(deviceId) + } + /** * Send an invite. Resolves when receiving a response. Rejects if the invite * is canceled, or if something else goes wrong. diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 4a6c2b532..5d0b77542 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -12,6 +12,9 @@ import { InviteRedeemConnectionClosedError, ensureKnownError, InviteNotYetRedeemedError, + InviteDeniedByInviterError, + InviteAbortedError, + JoinProjectCancelledError, } from '../src/errors.js' import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' import { temporaryDirectory } from 'tempy' @@ -35,13 +38,13 @@ test('invite over internet and join from URL', async (t) => { const onInviteRedeemAttempt = pEvent( project.$member, - 'internet-invite-redeemed', + 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onInvited = invitee.joinProjectOverInternet(url) + const onInvited = invitee.joinProjectFromLink(url) - const [deviceId, inviteId] = /** @type {[string, string]} */ ( + const [inviteId, deviceId] = /** @type {[string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -49,7 +52,7 @@ test('invite over internet and join from URL', async (t) => { // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptRedeemedInvite({ + const reason = await project.$member.acceptInviteLinkRequest({ inviteId, deviceId, }) @@ -121,13 +124,13 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { const onInviteRedeemAttempt = pEvent( project.$member, - 'internet-invite-redeemed', + 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onInvited = invitee.joinProjectOverInternet(url) + const onInvited = invitee.joinProjectFromLink(url) - const [deviceId, inviteId] = /** @type {[string, string]} */ ( + const [inviteId, deviceId] = /** @type {[string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -135,7 +138,7 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptRedeemedInvite({ + const reason = await project.$member.acceptInviteLinkRequest({ inviteId, deviceId, }) @@ -167,16 +170,16 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { // First invitee joins const onFirstInviteRedeemAttempt = pEvent( project.$member, - 'internet-invite-redeemed', + 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onFirstInvited = invitee1.joinProjectOverInternet(url) + const onFirstInvited = invitee1.joinProjectFromLink(url) - const [firstDeviceId, firstInviteId] = /** @type {[string, string]} */ ( + const [firstInviteId, firstDeviceId] = /** @type {[string, string]} */ ( /**@type unknown*/ (await onFirstInviteRedeemAttempt) ) assert.equal(firstDeviceId, invitee1.deviceId) - const firstReason = await project.$member.acceptRedeemedInvite({ + const firstReason = await project.$member.acceptInviteLinkRequest({ inviteId: firstInviteId, deviceId: firstDeviceId, }) @@ -186,12 +189,12 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { // Second invitee joins via the same URL const onSecondInviteRedeemAttempt = pEvent( project.$member, - 'internet-invite-redeemed', + 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onSecondInvited = invitee2.joinProjectOverInternet(url) + const onSecondInvited = invitee2.joinProjectFromLink(url) - const [secondDeviceId, secondInviteId] = /** @type {[string, string]} */ ( + const [secondInviteId, secondDeviceId] = /** @type {[string, string]} */ ( /**@type unknown*/ (await onSecondInviteRedeemAttempt) ) assert.equal( @@ -200,7 +203,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { 'Second invitee redeemed successfully' ) assert.equal(secondInviteId, firstInviteId, 'Invite ID is the same for both') - const secondReason = await project.$member.acceptRedeemedInvite({ + const secondReason = await project.$member.acceptInviteLinkRequest({ inviteId: secondInviteId, deviceId: secondDeviceId, }) @@ -242,7 +245,7 @@ test('invite over internet errors if invitor deviceID is invalid', async (t) => // Try to join with invalid device ID - should fail immediately // The invitee won't be able to connect to the non-existent device await assert.rejects( - invitee.joinProjectOverInternet(modifiedUrl, { timeout: 1000 }), + invitee.joinProjectFromLink(modifiedUrl, { timeout: 1000 }), (err) => ensureKnownError(err).code === TimeoutError.code ) }) @@ -266,13 +269,13 @@ test('invite over internet errors if inviter closes before accepting', async (t) const onInviteRedeemAttempt = pEvent( project.$member, - 'internet-invite-redeemed', + 'invite-link-join-request', { multiArgs: true, timeout: 50000 } ) - const onInvited = invitee.joinProjectOverInternet(url) + const onInvited = invitee.joinProjectFromLink(url) - const [deviceId, attemptedRedeemId] = /** @type {[string, string]} */ ( + const [attemptedRedeemId, deviceId] = /** @type {[string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -291,7 +294,7 @@ test('invite over internet errors if inviter closes before accepting', async (t) await assert.rejects( () => - project.$member.acceptRedeemedInvite({ + project.$member.acceptInviteLinkRequest({ inviteId: attemptedRedeemId, deviceId: invitee.deviceId, }), @@ -300,6 +303,100 @@ test('invite over internet errors if inviter closes before accepting', async (t) ) }) +test('invite over internet can be denied by inviter', async (t) => { + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const onInviteRedeemAttempt = pEvent( + project.$member, + 'invite-link-join-request', + { multiArgs: true, timeout: 5000 } + ) + + const onInvited = invitee.joinProjectFromLink(url) + + const [inviteId, deviceId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(deviceId, invitee.deviceId) + + // Deny the invite and wait for invitee's join to fail simultaneously + await Promise.all([ + project.$member.denyInviteLinkRequest({ + inviteId, + deviceId, + }), + assert.rejects( + onInvited, + (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code + ), + ]) +}) + +test.only('invite over internet can be cancelled by invitee', async (t) => { + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const onInviteRedeemAttempt = pEvent( + project.$member, + 'invite-link-join-request', + { multiArgs: true, timeout: 5000 } + ) + + const onInvited = invitee.joinProjectFromLink(url) + + const [inviteId, deviceId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(deviceId, invitee.deviceId) + + // Cancel the join and wait for invitee's join to fail simultaneously + await Promise.all([ + invitee.cancelJoinProjectFromLink(url), + assert.rejects( + onInvited, + (err) => ensureKnownError(err).code === JoinProjectCancelledError.code + ), + ]) + + // Accepting after the invitee disconnected should also fail + await assert.rejects( + () => + project.$member.acceptInviteLinkRequest({ + inviteId, + deviceId, + }), + (err) => ensureKnownError(err).code === InviteAbortedError.code, + 'Accepting after cancel causes an error' + ) +}) + test('invite over internet errors if invitee uses invalid inviteId', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, @@ -330,7 +427,7 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = }) // Try to join with invalid invite ID - const joinPromise = invitee.joinProjectOverInternet(modifiedUrl) + const joinPromise = invitee.joinProjectFromLink(modifiedUrl) // The invitor should receive the redeem attempt with an error const [error, peerId] = /** @type {[Error, string]} */ ( From 2ecbae1357e1d0dcdc2686801346eaf3465ad9d6 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 4 Jun 2026 15:10:28 -0400 Subject: [PATCH 063/103] feat: Wire AbortSignal to RemoteDiscovery peer connection --- src/discovery/remote-discovery.js | 10 ++++++- src/mapeo-manager.js | 35 ++++++++++++------------- test-e2e/invite-over-internet.js | 43 +++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index b66dd8793..aa27b858b 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -155,9 +155,10 @@ export class RemoteDiscovery extends TypedEmitter { * @param {string} publicKey * @param {object} [opts] * @param {number} [opts.timeout] + * @param {AbortSignal} [opts.signal] * @returns {Promise} */ - async connectPeer(publicKey, { timeout = 60_000 } = {}) { + async connectPeer(publicKey, { timeout = 60_000, signal } = {}) { const swarm = await this.#ensureSwarm() const noisePublicKey = Buffer.from(publicKey, 'hex') @@ -172,15 +173,21 @@ export class RemoteDiscovery extends TypedEmitter { } } + function onAbort() { + swarm.leavePeer(noisePublicKey) + } + this.#shouldTrustKeys.add(publicKey) const onConnected = pEvent(this, 'connection', { filter: (connection) => connection.remotePublicKey.equals(noisePublicKey), timeout, + signal, }) // Start trying to connect swarm.joinPeer(noisePublicKey) this.#l.log('Connecting to %S', publicKey) + signal?.addEventListener('abort', onAbort, { once: true }) try { const socket = await onConnected @@ -191,6 +198,7 @@ export class RemoteDiscovery extends TypedEmitter { } throw e } finally { + signal?.removeEventListener('abort', onAbort) this.#shouldTrustKeys.delete(publicKey) } } diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 363770b30..ecde88fb8 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -63,6 +63,7 @@ import { InvalidMapShareReceiverError, InviteDeniedByInviterError, JoinProjectCancelledError, + InvalidInternetInviteURLError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -765,24 +766,15 @@ export class MapeoManager extends TypedEmitter { const inviteId = Buffer.from(inviteIdString, 'hex') const redeemAbortController = new AbortController() - const onAbort = new Promise((_, reject) => { - redeemAbortController.signal.addEventListener( - 'abort', - () => reject(redeemAbortController.signal.reason), - { once: true } - ) - }) + const signal = redeemAbortController.signal this.#outboundRedeemInvites.set(inviteIdString, redeemAbortController) const connection = await this.#remoteDiscovery.connectPeer(swarmPublicKey, { timeout, + signal: redeemAbortController.signal, }) - redeemAbortController.signal.addEventListener( - 'abort', - () => connection.end(), - { once: true } - ) + signal.addEventListener('abort', () => connection.end(), { once: true }) const onClose = pEvent(connection, 'close').then( () => { @@ -801,18 +793,21 @@ export class MapeoManager extends TypedEmitter { const onInvited = pEvent(this.#invite, 'invite-received', { filter: (invite) => invite.invitorDeviceId === identityPublicKeyHex, + signal, }) - await Promise.race([ - this.#localPeers.sendRedeemInviteOverInternet(identityPublicKeyHex, { - inviteId, - }), + // This is to make sure all rejectable promises are listend to synchronously + // Else we get a PromiseRejectionHandledWarning in the tests + const [invite] = await Promise.race([ + Promise.all([ + onInvited, + this.#localPeers.sendRedeemInviteOverInternet(identityPublicKeyHex, { + inviteId, + }), + ]), onClose, - onAbort, ]) - const invite = await Promise.race([onInvited, onClose, onAbort]) - const projectId = await this.#invite.accept(invite) return projectId @@ -835,6 +830,8 @@ export class MapeoManager extends TypedEmitter { if (ac) { ac.abort(new JoinProjectCancelledError()) this.#outboundRedeemInvites.delete(inviteIdString) + } else { + throw new InvalidInternetInviteURLError() } } diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 5d0b77542..0ff8588bb 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -346,7 +346,7 @@ test('invite over internet can be denied by inviter', async (t) => { ]) }) -test.only('invite over internet can be cancelled by invitee', async (t) => { +test('invite over internet can be cancelled by invitee', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, }) @@ -378,11 +378,11 @@ test.only('invite over internet can be cancelled by invitee', async (t) => { // Cancel the join and wait for invitee's join to fail simultaneously await Promise.all([ - invitee.cancelJoinProjectFromLink(url), assert.rejects( onInvited, (err) => ensureKnownError(err).code === JoinProjectCancelledError.code ), + invitee.cancelJoinProjectFromLink(url), ]) // Accepting after the invitee disconnected should also fail @@ -447,3 +447,42 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = ensureKnownError(err).code === InviteRedeemConnectionClosedError.code ) }) + +test('invite over internet can be cancelled before connection to non-existing peer', async (t) => { + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + // Modify the URL to point to a non-existing peer + const parsed = parseInviteURL(url) + const modifiedUrl = makeInviteURL({ + ...parsed, + swarmPublicKey: randomBytes(32).toString('hex'), + }) + + // Start join — it will try to connect to the non-existing peer + const onInvited = invitee.joinProjectFromLink(modifiedUrl) + + // Give the connection attempt a moment to start, then cancel + await new Promise((resolve) => setTimeout(resolve, 200)) + + await Promise.all([ + assert.rejects( + onInvited, + (err) => ensureKnownError(err).code === JoinProjectCancelledError.code + ), + invitee.cancelJoinProjectFromLink(modifiedUrl), + ]) +}) From eb7940b3a844f3688394d6387250b23ee4765a82 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:04:21 -0400 Subject: [PATCH 064/103] fix: RemoteDiscovery Ensure connection is destroyed on disconnect --- src/discovery/remote-discovery.js | 12 +++- test/discovery/remote-discovery.js | 93 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index aa27b858b..e16dda8a1 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -143,11 +143,15 @@ export class RemoteDiscovery extends TypedEmitter { connection.handshakePublicKey.equals(noisePublicKey)) ) { this.#l.log('Disconnecting from peer %S', publicKey) - connection.end() - break + connection.destroy() + return } } // TODO: Error on unknown peer? + this.#l.log( + 'Error: Cannot disconnect from peer %S, not connected', + publicKey + ) } /** @@ -173,7 +177,8 @@ export class RemoteDiscovery extends TypedEmitter { } } - function onAbort() { + const onAbort = () => { + this.#l.log('Leave peer for %s', publicKey) swarm.leavePeer(noisePublicKey) } @@ -184,6 +189,7 @@ export class RemoteDiscovery extends TypedEmitter { timeout, signal, }) + // Start trying to connect swarm.joinPeer(noisePublicKey) this.#l.log('Connecting to %S', publicKey) diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 030d06bf6..cb8bf012a 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -144,6 +144,99 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => outboundStream.end() }) +test.only('RemoteDiscovery - Able to reconnect after disconnecting', async (t) => { + const testnet = await createTestnet(3) + t.after(async () => { + await testnet.destroy() + }) + + const identityKeypair1 = new KeyManager( + Buffer.alloc(16, 1) + ).getIdentityKeypair() + const identityKeypair2 = new KeyManager( + Buffer.alloc(16, 2) + ).getIdentityKeypair() + const swarmKeypair1 = new KeyManager(Buffer.alloc(16, 3)).getIdentityKeypair() + const swarmKeypair2 = new KeyManager(Buffer.alloc(16, 4)).getIdentityKeypair() + + const remoteDiscovery1 = new RemoteDiscovery({ + identityKeypair: identityKeypair1, + deriveSwarmIdentityKeypair: () => swarmKeypair1, + swarm: { dht: testnet.nodes[0] }, + }) + const remoteDiscovery2 = new RemoteDiscovery({ + identityKeypair: identityKeypair2, + deriveSwarmIdentityKeypair: () => swarmKeypair2, + swarm: { dht: testnet.nodes[1] }, + }) + + t.after(() => + Promise.all([remoteDiscovery1.close(), remoteDiscovery2.close()]) + ) + + // Start both instances + await Promise.all([remoteDiscovery1.start(), remoteDiscovery2.start()]) + + const swarmPublicKey1Hex = swarmKeypair1.publicKey.toString('hex') + const swarmPublicKey2Hex = swarmKeypair2.publicKey.toString('hex') + + // Listen for connection on instance 1 + const onConnection = pEvent(remoteDiscovery1, 'connection') + + // Connect from instance 2 to instance 1 + const connectionPromise = remoteDiscovery2.connectPeer(swarmPublicKey1Hex) + + const outboundStream = await connectionPromise + const inboundStream = await onConnection + + assert.ok( + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'remote public key should match instance 2' + ) + inboundStream.on('error', handleConnectionError) + + // Verify both sides have the correct keypairs + assert.ok( + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'instance 1 should have instance 2 swarm public key' + ) + assert.ok( + outboundStream.remotePublicKey.equals(swarmKeypair1.publicKey), + 'instance 2 should have instance 1 swarm public key' + ) + + const onEnd = Promise.all([ + pEvent(outboundStream, 'close').then(() => console.log('outbound close')), + pEvent(inboundStream, 'close').then(() => console.log('inbound close')), + ]) + + await Promise.all([ + onEnd, + // Need to disconnect both sides manually ATM cause disconenct isnt detected otherwise. + remoteDiscovery2.disconnectPeer(swarmPublicKey1Hex), + remoteDiscovery1.disconnectPeer(swarmPublicKey2Hex), + ]) + + // Listen for connection on instance 1 + const onConnection2 = pEvent(remoteDiscovery1, 'connection') + + // Connect from instance 2 to instance 1 + const connectionPromise2 = remoteDiscovery2.connectPeer(swarmPublicKey1Hex) + + const outboundStream2 = await connectionPromise2 + const inboundStream2 = await onConnection2 + + // Verify both sides have the correct keypairs + assert.ok( + inboundStream2.remotePublicKey.equals(swarmKeypair2.publicKey), + 'instance 1 should have instance 2 swarm public key' + ) + assert.ok( + outboundStream2.remotePublicKey.equals(swarmKeypair1.publicKey), + 'instance 2 should have instance 1 swarm public key' + ) +}) + test('RemoteDiscovery - readChunk throws UnableToReadHandshakeError on empty stream', async () => { // Create a stream that closes immediately without providing data const emptyStream = new Transform({ From c36e919a13ffa0902bd63afce8d8ecf38d512efe Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:04:48 -0400 Subject: [PATCH 065/103] feat: new leaveOnFail flag in Invite protobuf --- proto/rpc.proto | 1 + src/generated/rpc.d.ts | 1 + src/generated/rpc.js | 13 ++++++++++++- src/generated/rpc.ts | 13 +++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/proto/rpc.proto b/proto/rpc.proto index 63d88e48e..df38097fc 100644 --- a/proto/rpc.proto +++ b/proto/rpc.proto @@ -13,6 +13,7 @@ message Invite { optional string projectDescription = 8; bool sendStats = 9; bool invitorWroteDeviceInfo = 10; + bool leaveOnFail = 11; } message InviteCancel { diff --git a/src/generated/rpc.d.ts b/src/generated/rpc.d.ts index 275d1546c..f88920f52 100644 --- a/src/generated/rpc.d.ts +++ b/src/generated/rpc.d.ts @@ -11,6 +11,7 @@ export interface Invite { projectDescription?: string | undefined; sendStats: boolean; invitorWroteDeviceInfo: boolean; + leaveOnFail: boolean; } export interface InviteCancel { inviteId: Buffer; diff --git a/src/generated/rpc.js b/src/generated/rpc.js index ea514ec3a..5d7950a27 100644 --- a/src/generated/rpc.js +++ b/src/generated/rpc.js @@ -130,6 +130,7 @@ function createBaseInvite() { invitorName: "", sendStats: false, invitorWroteDeviceInfo: false, + leaveOnFail: false, }; } export var Invite = { @@ -165,6 +166,9 @@ export var Invite = { if (message.invitorWroteDeviceInfo === true) { writer.uint32(80).bool(message.invitorWroteDeviceInfo); } + if (message.leaveOnFail === true) { + writer.uint32(88).bool(message.leaveOnFail); + } return writer; }, decode: function (input, length) { @@ -234,6 +238,12 @@ export var Invite = { } message.invitorWroteDeviceInfo = reader.bool(); continue; + case 11: + if (tag !== 88) { + break; + } + message.leaveOnFail = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -246,7 +256,7 @@ export var Invite = { return Invite.fromPartial(base !== null && base !== void 0 ? base : {}); }, fromPartial: function (object) { - var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; var message = createBaseInvite(); message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); message.projectInviteId = (_b = object.projectInviteId) !== null && _b !== void 0 ? _b : Buffer.alloc(0); @@ -258,6 +268,7 @@ export var Invite = { message.projectDescription = (_h = object.projectDescription) !== null && _h !== void 0 ? _h : undefined; message.sendStats = (_j = object.sendStats) !== null && _j !== void 0 ? _j : false; message.invitorWroteDeviceInfo = (_k = object.invitorWroteDeviceInfo) !== null && _k !== void 0 ? _k : false; + message.leaveOnFail = (_l = object.leaveOnFail) !== null && _l !== void 0 ? _l : false; return message; }, }; diff --git a/src/generated/rpc.ts b/src/generated/rpc.ts index 02e06ce43..6cc26e655 100644 --- a/src/generated/rpc.ts +++ b/src/generated/rpc.ts @@ -14,6 +14,7 @@ export interface Invite { projectDescription?: string | undefined; sendStats: boolean; invitorWroteDeviceInfo: boolean; + leaveOnFail: boolean; } export interface InviteCancel { @@ -237,6 +238,7 @@ function createBaseInvite(): Invite { invitorName: "", sendStats: false, invitorWroteDeviceInfo: false, + leaveOnFail: false, }; } @@ -272,6 +274,9 @@ export const Invite = { if (message.invitorWroteDeviceInfo === true) { writer.uint32(80).bool(message.invitorWroteDeviceInfo); } + if (message.leaveOnFail === true) { + writer.uint32(88).bool(message.leaveOnFail); + } return writer; }, @@ -352,6 +357,13 @@ export const Invite = { message.invitorWroteDeviceInfo = reader.bool(); continue; + case 11: + if (tag !== 88) { + break; + } + + message.leaveOnFail = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -376,6 +388,7 @@ export const Invite = { message.projectDescription = object.projectDescription ?? undefined; message.sendStats = object.sendStats ?? false; message.invitorWroteDeviceInfo = object.invitorWroteDeviceInfo ?? false; + message.leaveOnFail = object.leaveOnFail ?? false; return message; }, }; From 2addca563f09230cda4ef3b85752467b45ac45c4 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:05:20 -0400 Subject: [PATCH 066/103] feat: new leaveOnFail flag in Invite protobuf --- test/invite-api.js | 6 ++++++ test/local-peers.js | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/test/invite-api.js b/test/invite-api.js index 5f4587591..60445c873 100644 --- a/test/invite-api.js +++ b/test/invite-api.js @@ -67,6 +67,7 @@ test('invite-received event has expected payload', async () => { projectName, invitorName: 'Your Friend', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, } rpc.emit('invite', invitorPeerId, bareInvite) @@ -97,6 +98,7 @@ test('invite-received event has expected payload', async () => { invitorName: 'Your Friend', state: 'pending', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, }, { @@ -109,6 +111,7 @@ test('invite-received event has expected payload', async () => { projectColor: '#123456', state: 'pending', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, }, { @@ -122,6 +125,7 @@ test('invite-received event has expected payload', async () => { projectDescription: 'cool project', state: 'pending', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, }, ] @@ -1190,6 +1194,7 @@ function setup() { invitorName: 'Host', sendStats: false, invitorWroteDeviceInfo: false, + leaveOnFail: false, } const inviteExternal = { ...invite, @@ -1198,6 +1203,7 @@ function setup() { inviteId: invite.inviteId.toString('hex'), projectInviteId: projectInviteId.toString('hex'), sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, } diff --git a/test/local-peers.js b/test/local-peers.js index 1616666fa..eaa55cd01 100644 --- a/test/local-peers.js +++ b/test/local-peers.js @@ -26,6 +26,7 @@ test('sending and receiving invites', async () => { projectName: 'Mapeo Project', invitorName: 'device0', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, } const invalidInvites = [ @@ -137,6 +138,7 @@ test('messages to unknown peers', async () => { projectName: 'Mapeo Project', invitorName: 'device0', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, }), UnknownPeerError @@ -332,6 +334,7 @@ test('Device info with ack results in acks sent', async () => { projectName: 'Mapeo Project', invitorName: 'device0', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, } @@ -397,6 +400,7 @@ test('Device info without ack results in no acks sent', async () => { projectName: 'Mapeo Project', invitorName: 'device0', sendStats: false, + leaveOnFail: false, invitorWroteDeviceInfo: false, } From 7a4146065b2cd82079c921f01ce2dafae9b22226 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:06:26 -0400 Subject: [PATCH 067/103] feat: Leave project on fail, more robust initial sync timeout --- src/errors.js | 6 +++++ src/invite/invite-api.js | 2 ++ src/mapeo-manager.js | 49 +++++++++++++++++++++++++++++++++++++--- src/member-api.js | 29 +++++++++++++++++------- src/sync/sync-api.js | 29 ++++++++++++++++++++---- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/src/errors.js b/src/errors.js index 12375ef35..7b342c84d 100644 --- a/src/errors.js +++ b/src/errors.js @@ -606,6 +606,12 @@ export const JoinProjectCancelledError = createErrorClass({ status: 499, }) +export const InitialSyncFailedError = createErrorClass({ + code: 'INITIAL_SYNC_FAILED_ERROR', + message: 'Failed to perform initial sync with peers', + status: 409, +}) + /** * @param {unknown} err * @returns {null} diff --git a/src/invite/invite-api.js b/src/invite/invite-api.js index 182a91058..f3bd87e6a 100644 --- a/src/invite/invite-api.js +++ b/src/invite/invite-api.js @@ -130,6 +130,7 @@ export class InviteApi extends TypedEmitter { projectDescription, sendStats, invitorWroteDeviceInfo, + leaveOnFail, } = inviteRpcMessage const invite = { ...inviteRpcMessage, receivedAt: Date.now() } @@ -171,6 +172,7 @@ export class InviteApi extends TypedEmitter { projectDescription, sendStats, invitorWroteDeviceInfo, + leaveOnFail, }) }), }, diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index ecde88fb8..09cac4974 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -64,6 +64,7 @@ import { InviteDeniedByInviterError, JoinProjectCancelledError, InvalidInternetInviteURLError, + InitialSyncFailedError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -83,7 +84,7 @@ import { parseInviteURL } from './invite/invite-urls.js' /** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ /** @typedef {SetNonNullable} ValidatedProjectKeys */ -/** @typedef {Pick & { projectName: string, projectColor?: string, projectDescription?: string, sendStats?: boolean, invitorWroteDeviceInfo? : boolean }} ProjectToAddDetails */ +/** @typedef {Pick & { projectName: string, projectColor?: string, projectDescription?: string, sendStats?: boolean, invitorWroteDeviceInfo? : boolean, leaveOnFail?: boolean }} ProjectToAddDetails */ /** @typedef {Pick} ListedProjectSettings */ /** @typedef {ListedProjectSettings & { status: 'joined', projectId: string } | ProjectInfo & { status: 'joining' | 'left', projectId: string }} ListedProject */ @@ -774,7 +775,7 @@ export class MapeoManager extends TypedEmitter { signal: redeemAbortController.signal, }) - signal.addEventListener('abort', () => connection.end(), { once: true }) + signal.addEventListener('abort', () => connection.destroy(), { once: true }) const onClose = pEvent(connection, 'close').then( () => { @@ -811,6 +812,10 @@ export class MapeoManager extends TypedEmitter { const projectId = await this.#invite.accept(invite) return projectId + } catch (e) { + // Disconnect from them on error + await this.#remoteDiscovery.disconnectPeer(swarmPublicKey) + throw e } finally { this.#outboundRedeemInvites.delete(inviteIdString) connection.end() @@ -853,6 +858,7 @@ export class MapeoManager extends TypedEmitter { projectDescription, sendStats = false, invitorWroteDeviceInfo = false, + leaveOnFail = false, }, { waitForSync = true } = {} ) => { @@ -952,9 +958,46 @@ export class MapeoManager extends TypedEmitter { try { await project.$sync.waitForSync('initial', { timeoutMs: INITIAL_SYNC_TIMEOUT_MS, + errorOnNoPeers: true, }) } catch (e) { - this.#l.log('ERROR: could not do initial project sync', e) + this.#l.log( + 'ERROR: could not do initial project sync, leaving %b', + e, + leaveOnFail + ) + if (leaveOnFail) { + // Delete keys and mark as left + this.#saveToProjectKeysTable({ + projectId, + projectPublicId, + projectInviteId, + projectInfo: { + name: projectName, + projectColor, + projectDescription, + sendStats, + }, + projectKeys: { + projectKey, + projectSecretKey, + encryptionKeys: encryptionKeys + ? { auth: encryptionKeys.auth } + : undefined, + }, + hasLeftProject: true, + }) + + this.#db + .delete(projectSettingsTable) + .where(eq(projectSettingsTable.docId, projectId)) + .run() + + // We'll just clear the data without assigning the left role + // since the invitor will add the blocked role already + await project[kClearData]() + throw new InitialSyncFailedError() + } } } this.#l.log('Added project %h, public ID: %S', projectKey, projectPublicId) diff --git a/src/member-api.js b/src/member-api.js index 57c4abb79..00f4f32a9 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -40,6 +40,7 @@ import { PeerDisconnectedSinceRedeemingInviteError, UnknownInviteIDError, CannotBlockSelfError, + InitialSyncFailedError, } from './errors.js' import { wsCoreReplicator } from './lib/ws-core-replicator.js' import { @@ -104,6 +105,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * @prop {import('./roles.js').RoleIdForNewInvite} opts.roleId * @prop {string} [roleName] * @prop {string} [roleDescription] + * @prop {boolean} [leaveOnFail] * @prop {Buffer} [__testOnlyInviteId] Hard-code the invite ID. Only for tests. * @prop {number} [initialSyncTimeoutMs=5000] * @prop {InvitePeerInfo} [peerInfo] @@ -399,13 +401,18 @@ export class MemberApi extends TypedEmitter { throw new PeerDisconnectedSinceRedeemingInviteError() } const { roleId, roleName, roleDescription } = pendingInvite - const decision = await this.invite(deviceId, { - roleId, - roleName, - roleDescription, - }) - - return decision + try { + const decision = await this.invite(deviceId, { + roleId, + roleName, + roleDescription, + leaveOnFail: true, + }) + return decision + } catch (e) { + await this.#disconnectFromPeer(deviceId) + throw e + } } /** @@ -434,7 +441,6 @@ export class MemberApi extends TypedEmitter { // RPC may fail if the peer disconnected, that's ok } - await this.#cancelInviteLinkById(inviteId) await this.#disconnectFromPeer(deviceId) } @@ -452,6 +458,7 @@ export class MemberApi extends TypedEmitter { roleId, roleName = ROLES[roleId]?.name, roleDescription, + leaveOnFail = false, __testOnlyInviteId, initialSyncTimeoutMs = 5000, peerInfo, @@ -506,6 +513,7 @@ export class MemberApi extends TypedEmitter { invitorName, sendStats, invitorWroteDeviceInfo, + leaveOnFail, } const inviteResponse = await this.#sendInviteAndGetResponse( @@ -557,6 +565,11 @@ export class MemberApi extends TypedEmitter { await this.#waitForInitialSyncWithPeer(deviceId, abortSync) } catch (e) { this.#l.log('ERROR: Could not initial sync with peer', e) + if (leaveOnFail) { + this.#l.log('Removing member due to leave on fail flag') + await this.remove(deviceId, { reason: 'failed initial sync' }) + throw new InitialSyncFailedError() + } } return inviteResponse.decision diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 2b5bcd0fb..45a1c19b3 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -448,19 +448,27 @@ export class SyncApi extends TypedEmitter { * to wait between sync state updates before giving up. As long as syncing is * happening, this will never timeout, but if more than timeoutMs passes * without any sync activity, then this will reject. + * @param {boolean} [options.errorOnNoPeers] Whether to quickly exit when no + * remote states for peers are detected. * @returns {Promise} */ - async waitForSync(type, { timeoutMs } = {}) { + async waitForSync(type, { timeoutMs, errorOnNoPeers = false } = {}) { return new Promise((resolve, reject) => { - /** @type {NodeJS.Timeout | undefined} */ - let timeoutId + /** @type {NodeJS.Timeout | null} */ + let timeoutId = null + const onTimeout = () => { this[kSyncState].off('state', onState) reject(new Error('Sync timeout')) } /** @param {import('./sync-state.js').State} state */ const onState = (state) => { - clearTimeout(timeoutId) + if (timeoutId) clearTimeout(timeoutId) + if (errorOnNoPeers && !hasRemoteStates(state)) { + this[kSyncState].off('state', onState) + reject(new Error('No peers found to do initial sync with')) + return + } if (isSynced(state, type, this.#peerSyncControllers)) { this[kSyncState].off('state', onState) resolve() @@ -686,6 +694,19 @@ function isSynced(state, type, peerSyncControllers) { return true } +/** + * Check if there are any remote states present + * Use this to see if we're lacking any peers to sync with + * @param {import('./sync-state.js').State} state + * @returns + */ +function hasRemoteStates(state) { + for (const { remoteStates } of Object.values(state)) { + if (Object.keys(remoteStates).length) return true + } + return false +} + /** * @param {import('./sync-state.js').State} state * @param {string} peerId From 0c829a90dfbf748687f80a929c75168532b72325 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:07:14 -0400 Subject: [PATCH 068/103] test: InviteOverInternet, fail to sync and try again --- test-e2e/invite-over-internet.js | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 0ff8588bb..6ef0141ba 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -18,6 +18,7 @@ import { } from '../src/errors.js' import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' import { temporaryDirectory } from 'tempy' +import { kWaitForInitialSyncWithPeer } from '../src/sync/sync-api.js' test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { @@ -486,3 +487,76 @@ test('invite over internet can be cancelled before connection to non-existing pe invitee.cancelJoinProjectFromLink(modifiedUrl), ]) }) + +test('invite over the internet removes project and removes member when failing to sync', async (t) => { + const managers = await createManagers(2, t, 'device_type_unspecified', { + useTestnet: true, + }) + const [invitor, invitee] = managers + + const projectId = await invitor.createProject({ + name: 'Mapeo', + projectColor: '#123456', + projectDescription: 'fun project', + }) + const project = await invitor.getProject(projectId) + + const origSyncWithPeer = project.$sync[kWaitForInitialSyncWithPeer] + + project.$sync[kWaitForInitialSyncWithPeer] = async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + throw new Error('Unexpected error!') + } + + const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const onInviteRedeemAttempt = pEvent( + project.$member, + 'invite-link-join-request', + { multiArgs: true, timeout: 5000 } + ) + + const onInvited = invitee.joinProjectFromLink(url) + + const [inviteId, deviceId] = /** @type {[string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + + assert.equal(deviceId, invitee.deviceId) + + // Show the user the device ID and their name and have them verify the invitee sees the same device ID + // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji + const onInviteeAccepted = project.$member.acceptInviteLinkRequest({ + inviteId, + deviceId, + }) + + await Promise.all([ + assert.rejects(onInvited), + assert.rejects(onInviteeAccepted), + ]) + + const members = await project.$member.getMany() + + assert.equal(members.length, 1, 'only invitor exists') + + const projects = await invitee.listProjects() + + assert.equal(projects.length, 0, 'invitee no longer in a project') + + project.$sync[kWaitForInitialSyncWithPeer] = origSyncWithPeer + + project.$member.on('invite-link-join-request', (inviteId, deviceId) => { + console.log('got invite link', inviteId, deviceId) + project.$member.acceptInviteLinkRequest({ + inviteId, + deviceId, + }) + }) + + const gotProjectId = await invitee.joinProjectFromLink(url) + + assert.equal(gotProjectId, projectId, 'Invited to project') +}) From 6a06098d74580e68a8a54d3757f7ff53c4c5319d Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 15:30:10 -0400 Subject: [PATCH 069/103] chore: use a.comapeo.app for invite over internet URL --- src/invite/invite-urls.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/invite/invite-urls.js b/src/invite/invite-urls.js index a2f271efb..4f6498a64 100644 --- a/src/invite/invite-urls.js +++ b/src/invite/invite-urls.js @@ -24,7 +24,7 @@ export const URL_PARAM_MAPPINGS = { expiresAt: 'e', } -export const INTERNET_INVITE_PAGE = 'https://i.comapeo.app/invite/' +export const INTERNET_INVITE_PAGE = 'https://a.comapeo.app/invite' /** * @param {string} url From 3f919fae1a0f99179c35dde1a98cac6220c61b21 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 16:19:31 -0400 Subject: [PATCH 070/103] feat: add seedTime to invite links, use oldest seed time or current time whe creating new links --- ...sty_knight.sql => 0005_thin_iron_patriot.sql} | 1 + drizzle/client/meta/0005_snapshot.json | 9 ++++++++- drizzle/client/meta/_journal.json | 4 ++-- src/invite/invite-links-api.js | 7 +++++++ src/mapeo-manager.js | 16 ++++++++++++---- src/schema/client.js | 1 + test/pending-invites-api.js | 12 +++++++++++- 7 files changed, 42 insertions(+), 8 deletions(-) rename drizzle/client/{0005_nifty_misty_knight.sql => 0005_thin_iron_patriot.sql} (90%) diff --git a/drizzle/client/0005_nifty_misty_knight.sql b/drizzle/client/0005_thin_iron_patriot.sql similarity index 90% rename from drizzle/client/0005_nifty_misty_knight.sql rename to drizzle/client/0005_thin_iron_patriot.sql index 797054ccf..12bdf1299 100644 --- a/drizzle/client/0005_nifty_misty_knight.sql +++ b/drizzle/client/0005_thin_iron_patriot.sql @@ -6,6 +6,7 @@ CREATE TABLE `inviteLinks` ( `roleId` text NOT NULL, `roleName` text, `roleDescription` text, + `seedTime` integer NOT NULL, `createdAt` integer NOT NULL, `expiresAt` integer NOT NULL ); diff --git a/drizzle/client/meta/0005_snapshot.json b/drizzle/client/meta/0005_snapshot.json index 4dc417f88..09f1197b8 100644 --- a/drizzle/client/meta/0005_snapshot.json +++ b/drizzle/client/meta/0005_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "ca036e4c-dc1d-4c34-b08d-6062267c243b", + "id": "4338fa73-975a-4ccd-b236-fc8ef47e3573", "prevId": "2dec4710-70ac-4857-b889-2bc3586695ee", "tables": { "deviceSettings": { @@ -95,6 +95,13 @@ "notNull": false, "autoincrement": false }, + "seedTime": { + "name": "seedTime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "createdAt": { "name": "createdAt", "type": "integer", diff --git a/drizzle/client/meta/_journal.json b/drizzle/client/meta/_journal.json index 4e13ad744..029f8bb55 100644 --- a/drizzle/client/meta/_journal.json +++ b/drizzle/client/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "6", - "when": 1779827153551, - "tag": "0005_nifty_misty_knight", + "when": 1780948369238, + "tag": "0005_thin_iron_patriot", "breakpoints": true } ] diff --git a/src/invite/invite-links-api.js b/src/invite/invite-links-api.js index 6c6b6cfee..30077f458 100644 --- a/src/invite/invite-links-api.js +++ b/src/invite/invite-links-api.js @@ -243,6 +243,12 @@ export class InviteLinksApi extends ReadyResource { } } + getSeedTime() { + const oldest = this.#sql.getOldest.get() + if (!oldest) return Date.now() + return oldest.seedTime + } + /** * Create a new invite link record * @param {InviteLinkCreate} data @@ -261,6 +267,7 @@ export class InviteLinksApi extends ReadyResource { roleDescription: data.opts.roleDescription, createdAt: Date.now(), expiresAt: Date.now() + this.#expiryMs, + seedTime: this.getSeedTime(), }) await this.#checkSetShouldListenOverInternet(true) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 09cac4974..a9943ec0a 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -78,7 +78,7 @@ import { parseInviteURL } from './invite/invite-urls.js' /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ /** @import { SetNonNullable } from 'type-fest' */ /** @import { ProjectJoinDetails, } from './generated/rpc.js' */ -/** @import { CoreStorage, Namespace } from './types.js' */ +/** @import { CoreStorage, KeyPair, Namespace } from './types.js' */ /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */ /** @import { ProjectSettings, ProjectSettingsValue } from '@comapeo/schema' */ /** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ @@ -315,8 +315,7 @@ export class MapeoManager extends TypedEmitter { this.#localDiscovery.on('connection', this.#replicate.bind(this)) this.#remoteDiscovery = new RemoteDiscovery({ identityKeypair: this.#keyManager.getIdentityKeypair(), - // ephemeral swarm identity each run - deriveSwarmIdentityKeypair: () => this.#keyManager.deriveSwarmIdentity(), + deriveSwarmIdentityKeypair: () => this.#swarmIdentity, swarm, logger, }) @@ -332,6 +331,15 @@ export class MapeoManager extends TypedEmitter { return this.#deviceId } + /** + * @returns {KeyPair} + */ + get #swarmIdentity() { + return this.#keyManager.deriveSwarmIdentity( + new Date(this.#inviteLinks.getSeedTime()) + ) + } + /** * @param {'blobs' | 'icons' | 'maps'} mediaType * @returns {Promise} @@ -650,7 +658,7 @@ export class MapeoManager extends TypedEmitter { ...projectKeys, projectMigrationsFolder: this.#projectMigrationsFolder, keyManager: this.#keyManager, - getSwarmPublicKey: () => this.#keyManager.deriveSwarmIdentity().publicKey, + getSwarmPublicKey: () => this.#swarmIdentity.publicKey, sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, diff --git a/src/schema/client.js b/src/schema/client.js index 2cb9f783b..fe2bc6f60 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -58,6 +58,7 @@ export const inviteLinksTable = sqliteTable('inviteLinks', { roleId: text('roleId').notNull(), roleName: text('roleName'), roleDescription: text('roleDescription'), + seedTime: int('seedTime').notNull(), createdAt: int('createdAt').notNull(), expiresAt: int('expiresAt').notNull(), }) diff --git a/test/pending-invites-api.js b/test/pending-invites-api.js index bb35dfb45..2c56804f8 100644 --- a/test/pending-invites-api.js +++ b/test/pending-invites-api.js @@ -33,7 +33,15 @@ const PROJECT_ID = 'test-project-id' */ /** - * @typedef {Omit & {createdAt?: number, expiresAt?: number, projectId: string}} SeedPendingInvite + * @typedef {object} SeedOptionals + * @property {number} [createdAt] + * @property {number} [expiresAt] + * @property {number} [seedTime] + * @property {string} projectId + */ + +/** + * @typedef {Omit & SeedOptionals} SeedPendingInvite */ /** @@ -57,6 +65,7 @@ function setup( .values({ ...seed, createdAt: seed.createdAt ?? Date.now(), + seedTime: seed.seedTime ?? Date.now(), expiresAt: seed.expiresAt ?? Date.now() + expiryMs, }) .run() @@ -399,6 +408,7 @@ test('Role ID validation on read', async (t) => { url: 'https://example.com/invite', roleId: 'invalid-role-id', createdAt: Date.now(), + seedTime: Date.now(), expiresAt: Date.now() + DEFAULT_INVITE_EXPIRY_MS, }) From 548d285ae1426d3b2635250798445a73f2cab59e Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 16:20:31 -0400 Subject: [PATCH 071/103] chore: rename pending-invites test to invite-links-api --- test/{pending-invites-api.js => invite-links-api.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{pending-invites-api.js => invite-links-api.js} (100%) diff --git a/test/pending-invites-api.js b/test/invite-links-api.js similarity index 100% rename from test/pending-invites-api.js rename to test/invite-links-api.js From f709900bb344a0770afc9c3674b3d2188b3a3008 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 8 Jun 2026 16:36:40 -0400 Subject: [PATCH 072/103] docs: Guide for invite over internet --- docs/guides/invite-over-internet.md | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/guides/invite-over-internet.md diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md new file mode 100644 index 000000000..61b532cbb --- /dev/null +++ b/docs/guides/invite-over-internet.md @@ -0,0 +1,80 @@ +t # Inviting New Mebers Over the Internet + +## Concepts + +## Steps + +- Invitor: Set up project +- Invitor: create invite link +- Invitor: send link out of bounds +- Invitor: wait for invite-link-join-request +- Invitee: get Invitor name and project name from the link +- Invitee: use invite link to connect and join + - On accept gets a regular project invite and auto accepts/joins it + - resolves to new project id + - rejects if you can't connect + - rejects if you get denied + - rejects if you disconnect part way through + - rejects if you cancel the join +- Invitor: Get `invite-link-join-request` +- Invitor: Accept invite link request which sends them an invite and follows the usual flow +- **OR** Invitor: Deny invite link request which disconnects and errors them out + +## Examples + +## Invitor Flow + +```JavaScript +project.$member.on('invite-link-join-request', (inviteId, deviceId) => { + // Reason is same as from invite api + // Throws if there is an error during the invite proces + const reason = await project.$member.acceptInviteLinkRequest(inviteId, deviceId) + // InviteNotYetRedeemedError + // UnknownInviteIDError + // PeerDisconnectedSinceRedeemingInviteError + // InviteAbortedError + + // You can also deny the request + await project.$member.denyInviteLinkRequest(inviteId, deviceId) +}) + +// creat a new invite link +const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, +}) +// InvalidProjectNameError + +// cancel at any time +await project.$member.cancelInviteLink(url) +// InvalidInternetInviteURLError + +// get curren list +const [{ + url, + inviteId, + roleId, + createdAt, + expiresAt, +}] = await project.$member.listInviteLinks(url) +``` + +## Invitee Flow + +```JavaScript +import {parseInviteURL} from '@comapeo/core/invite-urls.js' + +const { + invitorName, + projectName, + expiresAt, + inviteIdString +} = parseInviteURL(url) + +// Connect to the invitor, send request, join project with initial sync +const projectId = await manager.joinProjectFromLink(url) +// InviteRedeemConnectionClosedError + +await manager.cancelJoinProjectFromLink(url) +// InvalidInternetInviteURLError +``` + From 163d58c89eb765f13e6ba20d0a01e59b3a2b4a31 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 15:41:45 -0400 Subject: [PATCH 073/103] fix: Wait for writes to finish before disconnecting from peer --- src/discovery/remote-discovery.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index e16dda8a1..19f50f630 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -143,7 +143,8 @@ export class RemoteDiscovery extends TypedEmitter { connection.handshakePublicKey.equals(noisePublicKey)) ) { this.#l.log('Disconnecting from peer %S', publicKey) - connection.destroy() + connection.end() + await pEvent(connection, 'close') return } } From 8c3a752f61d4b367e8d6228e88c4cc64e9a138a7 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 16:13:14 -0400 Subject: [PATCH 074/103] chore: remove stray console logs --- src/invite/invite-urls.js | 1 - test/discovery/remote-discovery.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/invite/invite-urls.js b/src/invite/invite-urls.js index 4f6498a64..63b675686 100644 --- a/src/invite/invite-urls.js +++ b/src/invite/invite-urls.js @@ -50,7 +50,6 @@ export function parseInviteURL(url) { // Decode z32 and convert to hex const decoded = CrockfordBase32.decode(value) if (decoded.length !== 32) { - console.log({ decoded, value, params, url }) throw new InvalidInviteURLKeyParameterError({ paramName: optName, byteLength: decoded.length, diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index cb8bf012a..db7d6a590 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -206,8 +206,8 @@ test.only('RemoteDiscovery - Able to reconnect after disconnecting', async (t) = ) const onEnd = Promise.all([ - pEvent(outboundStream, 'close').then(() => console.log('outbound close')), - pEvent(inboundStream, 'close').then(() => console.log('inbound close')), + pEvent(outboundStream, 'close'), + pEvent(inboundStream, 'close'), ]) await Promise.all([ From 5020d7b39c7c9a677da54ede4ad02f2fa3cc4aeb Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 16:16:51 -0400 Subject: [PATCH 075/103] fix: Wait for sending ack send before emitting RPC event --- src/errors.js | 2 +- src/local-peers.js | 98 +++++++++++++++++++++++++++++----------------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/errors.js b/src/errors.js index 7b342c84d..18978bb5c 100644 --- a/src/errors.js +++ b/src/errors.js @@ -38,7 +38,7 @@ export const RPCDisconnectBeforeSendingError = createErrorClass({ export const RPCDisconnectBeforeAckError = createErrorClass({ code: 'RPC_DISCONNECT_BEFORE_ACK_ERROR', - message: 'RPC disconnected before receiving acknowledgement', + message: 'RPC disconnected before receiving acknowledgement for {type}', status: 499, }) diff --git a/src/local-peers.js b/src/local-peers.js index 7f239cadf..d879ff90c 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -244,9 +244,9 @@ class Peer { for (const listener of this.#drainedListeners) { listener.reject(new RPCDisconnectBeforeSendingError()) } - for (const waiters of this.#ackWaiters.values()) { + for (const [type, waiters] of this.#ackWaiters.entries()) { for (const { deferred } of waiters) { - deferred.reject(new RPCDisconnectBeforeAckError()) + deferred.reject(new RPCDisconnectBeforeAckError({ type })) } } this.#ackWaiters.clear() @@ -921,56 +921,76 @@ export class LocalPeers extends TypedEmitter { case 'Invite': { if (!peer.isTrusted) return const invite = parseInvite(value) - this.emit('invite', peer.id, invite) - peer.sendInviteAck(invite).catch((e) => { - this.#l.log(`Error sending invite ack ${e.stack}`) - }) - this.#l.log( - 'Invite %h from %S for %h', - invite.inviteId, - peer.id, - invite.projectInviteId - ) + peer + .sendInviteAck(invite) + .then(() => { + this.#l.log( + 'Invite %h from %S for %h', + invite.inviteId, + peer.id, + invite.projectInviteId + ) + this.emit('invite', peer.id, invite) + }) + .catch((e) => { + this.#l.log(`Error sending invite ack ${e.stack}`) + }) break } case 'InviteCancel': { if (!peer.isTrusted) return const inviteCancel = parseInviteCancel(value) - this.emit('invite-cancel', peer.id, inviteCancel) - peer.sendInviteCancelAck(inviteCancel).catch((e) => { - this.#l.log(`Error sending invite cancel ack ${e.stack}`) - }) - this.#l.log( - 'Invite cancel from %S for %h', - peer.id, - inviteCancel.inviteId - ) + peer + .sendInviteCancelAck(inviteCancel) + .then(() => { + this.emit('invite-cancel', peer.id, inviteCancel) + this.#l.log( + 'Invite cancel from %S for %h', + peer.id, + inviteCancel.inviteId + ) + }) + .catch((e) => { + this.#l.log(`Error sending invite cancel ack ${e.stack}`) + }) break } case 'InviteResponse': { if (!peer.isTrusted) return const inviteResponse = parseInviteResponse(value) - this.emit('invite-response', peer.id, inviteResponse) - peer.sendInviteResponseAck(inviteResponse).catch((e) => { - this.#l.log(`Error sending invite response ack ${e.stack}`) - }) + peer + .sendInviteResponseAck(inviteResponse) + .then(() => { + this.emit('invite-response', peer.id, inviteResponse) + }) + .catch((e) => { + this.#l.log(`Error sending invite response ack ${e.stack}`) + }) break } case 'RedeemInviteOverInternet': { const redeem = RedeemInviteOverInternet.decode(value) - this.emit('invite-over-internet-redeemed', peer.id, redeem) - peer.sendRedeemInviteOverInternetAck(redeem).catch((e) => { - this.#l.log(`Error sending redeem over internet ack ${e.stack}`) - }) + peer + .sendRedeemInviteOverInternetAck(redeem) + .then(() => { + this.emit('invite-over-internet-redeemed', peer.id, redeem) + }) + .catch((e) => { + this.#l.log(`Error sending redeem over internet ack ${e.stack}`) + }) break } case 'ProjectJoinDetails': { if (!peer.isTrusted) return const details = parseProjectJoinDetails(value) - this.emit('got-project-details', peer.id, details) - peer.sendProjectJoinDetailsAck(details).catch((e) => { - this.#l.log(`Error sending project details ack ${e.stack}`) - }) + peer + .sendProjectJoinDetailsAck(details) + .then(() => { + this.emit('got-project-details', peer.id, details) + }) + .catch((e) => { + this.#l.log(`Error sending project details ack ${e.stack}`) + }) break } case 'DeviceInfo': { @@ -1023,10 +1043,14 @@ export class LocalPeers extends TypedEmitter { } case 'DenyInviteOverInternet': { const deny = DenyInviteOverInternet.decode(value) - this.emit('invite-over-internet-denied', peer.id, deny) - peer.sendDenyInviteOverInternetAck(deny).catch((e) => { - this.#l.log(`Error sending deny over internet ack ${e.stack}`) - }) + peer + .sendDenyInviteOverInternetAck(deny) + .then(() => { + this.emit('invite-over-internet-denied', peer.id, deny) + }) + .catch((e) => { + this.#l.log(`Error sending deny over internet ack ${e.stack}`) + }) break } case 'DenyInviteOverInternetAck': { From 668a6d21524875c910024f3cb1b37d272ec78cec Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 16:18:09 -0400 Subject: [PATCH 076/103] feat: Emit invite-link-join-request without needing a project loaded --- src/invite/invite-links-api.js | 22 +-- src/mapeo-manager.js | 61 +++++++- src/mapeo-project.js | 5 +- src/member-api.js | 59 +++----- test-e2e/invite-over-internet.js | 241 ++++++++++++++++--------------- test/invite-links-api.js | 38 +---- test/member-api.js | 15 +- 7 files changed, 238 insertions(+), 203 deletions(-) diff --git a/src/invite/invite-links-api.js b/src/invite/invite-links-api.js index 30077f458..ab6f88e18 100644 --- a/src/invite/invite-links-api.js +++ b/src/invite/invite-links-api.js @@ -1,4 +1,4 @@ -import { and, eq, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import ReadyResource from 'ready-resource' import { inviteLinksTable } from '../schema/client.js' import { isRoleIdForNewInvite } from '../roles.js' @@ -15,6 +15,7 @@ import { deNullify } from '../utils.js' /** * @typedef {object} InviteLinkRecord * @property {string} inviteId Hex string invite ID (primary key) + * @property {string} projectId Hex string of project * @property {Buffer} inviteIdBuffer Binary invite ID * @property {string} url Invite URL * @property {import('../roles.js').RoleIdForNewInvite} roleId @@ -70,7 +71,12 @@ export class InviteLinksApiForProject { */ async getById(inviteId) { await this.#inviteLinksApi.ready() - return this.#inviteLinksApi.getById(inviteId, this.#projectId) + const link = await this.#inviteLinksApi.getById(inviteId) + if (!link) return link + if (link.projectId !== this.#projectId) { + return undefined + } + return link } /** @@ -137,12 +143,7 @@ export class InviteLinksApi extends ReadyResource { getById: db .select() .from(inviteLinksTable) - .where( - and( - eq(inviteLinksTable.inviteId, sql.placeholder('inviteId')), - eq(inviteLinksTable.projectId, sql.placeholder('projectId')) - ) - ) + .where(eq(inviteLinksTable.inviteId, sql.placeholder('inviteId'))) .limit(1) .prepare(), getAll: db.select().from(inviteLinksTable).prepare(), @@ -287,12 +288,11 @@ export class InviteLinksApi extends ReadyResource { /** * Get a invite link by invite ID * @param {string} inviteId - * @param {string} projectId * @returns {Promise} */ - async getById(inviteId, projectId) { + async getById(inviteId) { await this.ready() - const row = this.#sql.getById.get({ inviteId, projectId }) + const row = this.#sql.getById.get({ inviteId }) if (!row) return undefined if (!isRoleIdForNewInvite(row.roleId)) { diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index a9943ec0a..c62c5bde2 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -65,6 +65,8 @@ import { JoinProjectCancelledError, InvalidInternetInviteURLError, InitialSyncFailedError, + UnknownInviteIDRedeemAttemptError, + RPCDisconnectBeforeAckError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -73,6 +75,7 @@ import { RemoteDiscovery } from './discovery/remote-discovery.js' import { pEvent } from 'p-event' import { InviteLinksApi } from './invite/invite-links-api.js' import { parseInviteURL } from './invite/invite-urls.js' +import { kHandleRedeemInviteOverInternet } from './member-api.js' /** @import { MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ @@ -145,6 +148,8 @@ const RPC_FEATURES = [ * @property {(peers: PublicPeerInfo[]) => void} local-peers Emitted when the list of connected peers changes (new ones added, or connection status changes) * @property {(mapShare: MapShare) => void} map-share Emitted when a project has recieved a map share request * @property {(e: Error, mapShare: MapShareExtension) => void} map-share-error - Emitted when an incoming map share fails to be recieved due to formatting issues + * @property {(projectId: string, deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet has been redeemed, accept the deviceId to add them + * @property {(err: Error, deviceId: string, inviteId: string) => void} invite-link-join-request-error Emitted when an invite over the internet has failed to be redeemed */ /** @@ -260,6 +265,20 @@ export class MapeoManager extends TypedEmitter { this.#handleInviteDenied(peerId, deny) ) + this.#localPeers.on( + 'invite-over-internet-redeemed', + (peerId, { inviteId }) => { + this.#handleRedeemInviteOverInternet(peerId, inviteId).catch((e) => { + this.emit( + 'invite-link-join-request-error', + e, + peerId, + inviteId.toString('hex') + ) + }) + } + ) + this.#projectSettingsIndexWriter = new IndexWriter({ tables: [projectSettingsTable], sqlite, @@ -783,7 +802,11 @@ export class MapeoManager extends TypedEmitter { signal: redeemAbortController.signal, }) - signal.addEventListener('abort', () => connection.destroy(), { once: true }) + signal.addEventListener( + 'abort', + () => this.#remoteDiscovery.disconnectPeer(swarmPublicKey), + { once: true } + ) const onClose = pEvent(connection, 'close').then( () => { @@ -1312,6 +1335,42 @@ export class MapeoManager extends TypedEmitter { } } + /** + * + * @param {string} peerId + * @param {Buffer} inviteId + */ + async #handleRedeemInviteOverInternet(peerId, inviteId) { + const inviteIdString = inviteId.toString('hex') + const invite = await this.#inviteLinks.getById(inviteIdString) + + if (!invite) { + try { + await this.#localPeers.sendDenyInviteOverInternet(peerId, { + inviteId, + }) + } catch (e) { + // This error happens sometimes since both sides break the conn on deny + if (ensureKnownError(e).code !== RPCDisconnectBeforeAckError.code) { + throw e + } + } + await this.#remoteDiscovery.disconnectPeer(peerId) + throw new UnknownInviteIDRedeemAttemptError() + } + + const { projectId } = invite + + const project = await this.getProject(projectId) + + await project.$member[kHandleRedeemInviteOverInternet]( + peerId, + inviteIdString + ) + + this.emit('invite-link-join-request', projectId, peerId, inviteIdString) + } + async getMapStyleJsonUrl() { await timeoutPromise(Promise.resolve(this.#fastify.ready()), { milliseconds: 1000, diff --git a/src/mapeo-project.js b/src/mapeo-project.js index e6d667d23..992991d85 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -429,7 +429,10 @@ export class MapeoProject extends ReadyResource { encryptionKeys, projectKey, rpc: localPeers, - inviteLinks: new InviteLinksApiForProject(this.#projectId, inviteLinks), + inviteLinks: new InviteLinksApiForProject( + this.#projectPublicId, + inviteLinks + ), getSwarmPublicKey: this.#getSwarmPublicKey, makeWebsocket, getReplicationStream, diff --git a/src/member-api.js b/src/member-api.js index 00f4f32a9..b74885b2d 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -57,6 +57,10 @@ import { makeInviteURL, parseInviteURL } from './invite/invite-urls.js' const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] +export const kHandleRedeemInviteOverInternet = Symbol( + 'handleRedeemInviteOverInternet' +) + /** * @import { * DeviceInfo, @@ -65,7 +69,7 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * ProjectSettingsValue, * } from '@comapeo/schema' */ -/** @import { Invite, InviteResponse, RedeemInviteOverInternet } from './generated/rpc.js' */ +/** @import { Invite, InviteResponse } from './generated/rpc.js' */ /** @import { DataType } from './datatype/index.js' */ /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ @@ -125,8 +129,6 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] /** * @typedef {object} MemberEvents - * @property {(deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet has been redeemed, accept the deviceId to add them - * @property {(err: Error, deviceId: string, url: string) => void} internet-invite-redeem-error Emitted when an invite over the internet has failed to be redeemed */ /** @@ -212,10 +214,6 @@ export class MemberApi extends TypedEmitter { this.#setDeviceInfo = setDeviceInfo this.#getSwarmPublicKey = getSwarmPublicKey - // Setup event listeners - this.#rpc.on('invite-over-internet-redeemed', (peerId, redeem) => - this.#handleRedeemInviteOverInternet(peerId, redeem) - ) this.#rpc.on('peer-remove', this.#handlePeerRemove) } @@ -320,10 +318,9 @@ export class MemberApi extends TypedEmitter { /** * Handle an incoming redeem attempt from the RPC layer. * @param {string} peerId - * @param {RedeemInviteOverInternet} redeem + * @param {string} inviteIdString */ - async #handleRedeemInviteOverInternet(peerId, { inviteId }) { - const inviteIdString = inviteId.toString('hex') + async [kHandleRedeemInviteOverInternet](peerId, inviteIdString) { this.#l.log('Got incoming invite redeem %S from %S', inviteIdString, peerId) const invite = await this.#inviteLinks.getById(inviteIdString) @@ -333,14 +330,8 @@ export class MemberApi extends TypedEmitter { inviteIdString.slice(0, 7) ) - this.emit( - 'internet-invite-redeem-error', - new UnknownInviteIDRedeemAttemptError(), - peerId, - inviteIdString - ) - await this.#disconnectFromPeer(peerId) - return + await this.denyInviteLinkRequest(inviteIdString, peerId) + throw new UnknownInviteIDRedeemAttemptError() } const redeemedSet = this.#redeemedInvites.get(inviteIdString) @@ -349,14 +340,9 @@ export class MemberApi extends TypedEmitter { 'Incoming invite was already redeemed, disconnecting', inviteIdString.slice(0, 7) ) - this.emit( - 'internet-invite-redeem-error', - new InviteAlreadyRedeemedError(), - peerId, - inviteIdString - ) await this.#disconnectFromPeer(peerId) - return + + throw new InviteAlreadyRedeemedError() } try { @@ -365,26 +351,20 @@ export class MemberApi extends TypedEmitter { } else { redeemedSet.add(peerId) } - this.emit('invite-link-join-request', inviteIdString, peerId) + return inviteIdString } catch (e) { - this.emit( - 'internet-invite-redeem-error', - ensureKnownError(e), - peerId, - inviteIdString - ) await this.#disconnectFromPeer(peerId) + throw e } } /** * Accept a specific device's attempt at redeeming an invite. - * @param {object} opts - * @param {string} opts.inviteId - * @param {string} opts.deviceId + * @param {string} inviteId + * @param {string} deviceId * @returns {Promise} */ - async acceptInviteLinkRequest({ inviteId, deviceId }) { + async acceptInviteLinkRequest(inviteId, deviceId) { const redeemedSet = this.#redeemedInvites.get(inviteId) if (!redeemedSet || !redeemedSet.has(deviceId)) { throw new InviteNotYetRedeemedError() @@ -417,12 +397,11 @@ export class MemberApi extends TypedEmitter { /** * Deny a specific device's attempt at redeeming an invite. - * @param {object} opts - * @param {string} opts.inviteId - * @param {string} opts.deviceId + * @param {string} inviteId + * @param {string} deviceId * @returns {Promise} */ - async denyInviteLinkRequest({ inviteId, deviceId }) { + async denyInviteLinkRequest(inviteId, deviceId) { const redeemedSet = this.#redeemedInvites.get(inviteId) if (!redeemedSet || !redeemedSet.has(deviceId)) { throw new InviteNotYetRedeemedError() diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 6ef0141ba..b14bcab14 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -13,7 +13,6 @@ import { ensureKnownError, InviteNotYetRedeemedError, InviteDeniedByInviterError, - InviteAbortedError, JoinProjectCancelledError, } from '../src/errors.js' import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' @@ -37,26 +36,28 @@ test('invite over internet and join from URL', async (t) => { roleId: MEMBER_ROLE_ID, }) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 5000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [inviteId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, inviteId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptInviteLinkRequest({ + const reason = await project.$member.acceptInviteLinkRequest( inviteId, - deviceId, - }) + deviceId + ) assert.equal(reason, InviteResponse_Decision.ACCEPT) @@ -123,26 +124,28 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { 'Pending internet invites loaded on reload' ) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 5000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [inviteId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, inviteId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptInviteLinkRequest({ + const reason = await project.$member.acceptInviteLinkRequest( inviteId, - deviceId, - }) + deviceId + ) assert.equal(reason, InviteResponse_Decision.ACCEPT) @@ -170,44 +173,48 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { // First invitee joins const onFirstInviteRedeemAttempt = pEvent( - project.$member, + invitor, 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) const onFirstInvited = invitee1.joinProjectFromLink(url) - const [firstInviteId, firstDeviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onFirstInviteRedeemAttempt) - ) + const [firstProjectId, firstDeviceId, firstInviteId] = + /** @type {[String, string, string]} */ ( + /**@type unknown*/ (await onFirstInviteRedeemAttempt) + ) + assert.equal(firstProjectId, projectId) assert.equal(firstDeviceId, invitee1.deviceId) - const firstReason = await project.$member.acceptInviteLinkRequest({ - inviteId: firstInviteId, - deviceId: firstDeviceId, - }) + const firstReason = await project.$member.acceptInviteLinkRequest( + firstInviteId, + firstDeviceId + ) assert.equal(firstReason, InviteResponse_Decision.ACCEPT) await onFirstInvited // Second invitee joins via the same URL const onSecondInviteRedeemAttempt = pEvent( - project.$member, + invitor, 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) const onSecondInvited = invitee2.joinProjectFromLink(url) - const [secondInviteId, secondDeviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onSecondInviteRedeemAttempt) - ) + const [secondProjectId, secondDeviceId, secondInviteId] = + /** @type {[String, string, string]} */ ( + /**@type unknown*/ (await onSecondInviteRedeemAttempt) + ) + assert.equal(secondProjectId, projectId) assert.equal( secondDeviceId, invitee2.deviceId, 'Second invitee redeemed successfully' ) assert.equal(secondInviteId, firstInviteId, 'Invite ID is the same for both') - const secondReason = await project.$member.acceptInviteLinkRequest({ - inviteId: secondInviteId, - deviceId: secondDeviceId, - }) + const secondReason = await project.$member.acceptInviteLinkRequest( + secondInviteId, + secondDeviceId + ) assert.equal(secondReason, InviteResponse_Decision.ACCEPT) await onSecondInvited @@ -268,18 +275,20 @@ test('invite over internet errors if inviter closes before accepting', async (t) roleId: MEMBER_ROLE_ID, }) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 50000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 50000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [attemptedRedeemId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, attemptedRedeemId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) await Promise.all([ @@ -295,10 +304,10 @@ test('invite over internet errors if inviter closes before accepting', async (t) await assert.rejects( () => - project.$member.acceptInviteLinkRequest({ - inviteId: attemptedRedeemId, - deviceId: invitee.deviceId, - }), + project.$member.acceptInviteLinkRequest( + attemptedRedeemId, + invitee.deviceId + ), (err) => ensureKnownError(err).code === InviteNotYetRedeemedError.code, 'Accepting after a disconnect causes an error' ) @@ -321,29 +330,29 @@ test('invite over internet can be denied by inviter', async (t) => { roleId: MEMBER_ROLE_ID, }) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 5000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [inviteId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, inviteId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) // Deny the invite and wait for invitee's join to fail simultaneously await Promise.all([ - project.$member.denyInviteLinkRequest({ - inviteId, - deviceId, - }), assert.rejects( onInvited, (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code ), + project.$member.denyInviteLinkRequest(inviteId, deviceId), ]) }) @@ -364,17 +373,20 @@ test('invite over internet can be cancelled by invitee', async (t) => { roleId: MEMBER_ROLE_ID, }) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 5000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [inviteId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, inviteId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) // Cancel the join and wait for invitee's join to fail simultaneously @@ -388,12 +400,8 @@ test('invite over internet can be cancelled by invitee', async (t) => { // Accepting after the invitee disconnected should also fail await assert.rejects( - () => - project.$member.acceptInviteLinkRequest({ - inviteId, - deviceId, - }), - (err) => ensureKnownError(err).code === InviteAbortedError.code, + () => project.$member.acceptInviteLinkRequest(inviteId, deviceId), + (err) => ensureKnownError(err).code === InviteNotYetRedeemedError.code, 'Accepting after cancel causes an error' ) }) @@ -422,31 +430,32 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = }) // Expect the invitor to emit an error when the invalid invite is attempted - const onError = pEvent(project.$member, 'internet-invite-redeem-error', { - timeout: 5000, - multiArgs: true, - }) - - // Try to join with invalid invite ID - const joinPromise = invitee.joinProjectFromLink(modifiedUrl) - - // The invitor should receive the redeem attempt with an error - const [error, peerId] = /** @type {[Error, string]} */ ( - /**@type unknown*/ (await onError) - ) - assert.equal( - ensureKnownError(error).code, - UnknownInviteIDRedeemAttemptError.code, - 'Expected UnknownInviteIDRedeemAttemptError' + const onError = /** @type {Promise<[Error, string, string]>} */ ( + /**@type unknown*/ ( + pEvent(invitor, 'invite-link-join-request-error', { + timeout: 5000, + multiArgs: true, + }) + ) ) - assert.equal(peerId, invitee.deviceId, 'Error from expected peer ID') - // The invitee's join should fail because the connection closes when the invite is invalid - await assert.rejects( - joinPromise, - (err) => - ensureKnownError(err).code === InviteRedeemConnectionClosedError.code - ) + // We need to listen to both promises at the same time to avoid warnings + await Promise.all([ + onError.then(([error, peerId]) => { + // The invitor should receive the redeem attempt with an error + assert.equal( + ensureKnownError(error).code, + UnknownInviteIDRedeemAttemptError.code, + 'Expected UnknownInviteIDRedeemAttemptError' + ) + assert.equal(peerId, invitee.deviceId, 'Error from expected peer ID') + }), + assert.rejects( + // Try to join with invalid invite ID + invitee.joinProjectFromLink(modifiedUrl), + (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code + ), + ]) }) test('invite over internet can be cancelled before connection to non-existing peer', async (t) => { @@ -512,26 +521,28 @@ test('invite over the internet removes project and removes member when failing t roleId: MEMBER_ROLE_ID, }) - const onInviteRedeemAttempt = pEvent( - project.$member, - 'invite-link-join-request', - { multiArgs: true, timeout: 5000 } - ) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [inviteId, deviceId] = /** @type {[string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + const [invitedProjectId, deviceId, inviteId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const onInviteeAccepted = project.$member.acceptInviteLinkRequest({ + const onInviteeAccepted = project.$member.acceptInviteLinkRequest( inviteId, - deviceId, - }) + deviceId + ) await Promise.all([ assert.rejects(onInvited), @@ -548,12 +559,8 @@ test('invite over the internet removes project and removes member when failing t project.$sync[kWaitForInitialSyncWithPeer] = origSyncWithPeer - project.$member.on('invite-link-join-request', (inviteId, deviceId) => { - console.log('got invite link', inviteId, deviceId) - project.$member.acceptInviteLinkRequest({ - inviteId, - deviceId, - }) + invitor.on('invite-link-join-request', (_projectId, deviceId, inviteId) => { + project.$member.acceptInviteLinkRequest(inviteId, deviceId) }) const gotProjectId = await invitee.joinProjectFromLink(url) diff --git a/test/invite-links-api.js b/test/invite-links-api.js index 2c56804f8..df6f16bea 100644 --- a/test/invite-links-api.js +++ b/test/invite-links-api.js @@ -117,7 +117,7 @@ test('create() - basic functionality', async (t) => { }, }) - const retrieved = await api.getById(inviteIdString, PROJECT_ID) + const retrieved = await api.getById(inviteIdString) assert(retrieved, 'invite can be retrieved') assert.equal(retrieved.inviteId, inviteIdString) assert.equal(retrieved.url, url) @@ -164,32 +164,10 @@ test('create() - duplicate inviteId throws', async (t) => { test('getById() - non-existent invite', async (t) => { const { api } = setup(t) - const result = await api.getById('non-existent-id', PROJECT_ID) + const result = await api.getById('non-existent-id') assert.equal(result, undefined, 'returns undefined for non-existent invite') }) -test('getById() - invite from different project not found', async (t) => { - const { api } = setup(t) - - const inviteId = randomBytes(32) - const inviteIdString = inviteId.toString('hex') - - await api.create({ - projectId: PROJECT_ID, - inviteId: inviteIdString, - inviteIdBuffer: inviteId, - url: 'https://example.com/invite', - opts: { roleId: MEMBER_ROLE_ID }, - }) - - const result = await api.getById(inviteIdString, 'other-project-id') - assert.equal( - result, - undefined, - 'returns undefined for invite in different project' - ) -}) - test('getAll() - empty database', async (t) => { const { api } = setup(t) @@ -295,7 +273,7 @@ test('delete() - single invite', async (t) => { await api.delete(inviteIdString) - const retrieved = await api.getById(inviteIdString, PROJECT_ID) + const retrieved = await api.getById(inviteIdString) assert.equal(retrieved, undefined, 'invite deleted') const all = await api.getAll() @@ -307,7 +285,7 @@ test('delete() - non-existent invite', async (t) => { await api.delete('non-existent-id') - const result = await api.getById('non-existent-id', PROJECT_ID) + const result = await api.getById('non-existent-id') assert.equal(result, undefined, 'no-op for non-existent invite') }) @@ -413,7 +391,7 @@ test('Role ID validation on read', async (t) => { }) await assert.rejects( - async () => await api.getById(inviteIdString, PROJECT_ID), + async () => await api.getById(inviteIdString), /Invalid roleId in database/, 'throws error for invalid roleId on getById' ) @@ -439,7 +417,7 @@ test('Buffer persistence', async (t) => { opts: { roleId: MEMBER_ROLE_ID }, }) - const retrieved = await api.getById(inviteIdString, PROJECT_ID) + const retrieved = await api.getById(inviteIdString) assert.ok(retrieved?.inviteIdBuffer.equals(inviteId), 'buffer is identical') }) @@ -459,7 +437,7 @@ test('Timestamp verification', async (t) => { }) const afterCreate = Date.now() - const retrieved = await api.getById(inviteIdString, PROJECT_ID) + const retrieved = await api.getById(inviteIdString) assert.ok(retrieved, 'able to retrieve') @@ -486,7 +464,7 @@ test('Optional fields', async (t) => { }, }) - const retrieved = await api.getById(inviteIdString, PROJECT_ID) + const retrieved = await api.getById(inviteIdString) assert.equal( retrieved?.roleName, undefined, diff --git a/test/member-api.js b/test/member-api.js index e52547d23..c65fcda7b 100644 --- a/test/member-api.js +++ b/test/member-api.js @@ -231,6 +231,11 @@ class MockRoles { class MockInviteLinksApiForProject { /** @type {Map} */ #invites = new Map() + #projectId + + constructor(projectId = 'example') { + this.#projectId = projectId + } /** * Create a new pending invite record @@ -240,6 +245,7 @@ class MockInviteLinksApiForProject { async create(data) { this.#invites.set(data.inviteId, { inviteId: data.inviteId, + projectId: this.#projectId, inviteIdBuffer: data.inviteIdBuffer, url: data.url, roleId: data.opts.roleId, @@ -318,7 +324,7 @@ function setup({ throw new Error('Not implemented') }, markInternetPeerAsTrusted = () => Promise.resolve(true), - inviteLinks = new MockInviteLinksApiForProject(), + inviteLinks, } = {}) { const keyManager = new KeyManager(rootKey) @@ -331,13 +337,16 @@ function setup({ const rpc = new MockLocalPeers() const roles = new MockRoles() + const finalInviteLinks = + inviteLinks ?? new MockInviteLinksApiForProject(projectKey.toString('hex')) + const member = new MemberApi({ deviceId, rpc, roles, encryptionKeys, projectKey, - inviteLinks, + inviteLinks: finalInviteLinks, makeWebsocket, getReplicationStream, waitForInitialSyncWithPeer, @@ -354,6 +363,6 @@ function setup({ roles, member, projectKey, - inviteLinks, + inviteLinks: finalInviteLinks, } } From 7ed2d2c2ba55da9b44286b19791bd3ddc4b2ea95 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 16:18:32 -0400 Subject: [PATCH 077/103] docs: Update invite over internet docs --- docs/guides/invite-over-internet.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md index 61b532cbb..626febc9d 100644 --- a/docs/guides/invite-over-internet.md +++ b/docs/guides/invite-over-internet.md @@ -1,4 +1,4 @@ -t # Inviting New Mebers Over the Internet +# Inviting New Mebers Over the Internet ## Concepts @@ -25,7 +25,9 @@ t # Inviting New Mebers Over the Internet ## Invitor Flow ```JavaScript -project.$member.on('invite-link-join-request', (inviteId, deviceId) => { +manager.on('invite-link-join-request', async (projectId, deviceId, inviteId,) => { + const project = await manager.getProject(projectId) + // Reason is same as from invite api // Throws if there is an error during the invite proces const reason = await project.$member.acceptInviteLinkRequest(inviteId, deviceId) From 2228e5dbcaba1d83573052ce8f29d8492ed20026 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 16:44:05 -0400 Subject: [PATCH 078/103] feat: Distinguish reason between invitor deny and auto deny --- docs/guides/invite-over-internet.md | 2 ++ proto/rpc.proto | 6 ++++ src/errors.js | 2 +- src/generated/rpc.d.ts | 10 ++++++ src/generated/rpc.js | 50 ++++++++++++++++++++++++-- src/generated/rpc.ts | 56 ++++++++++++++++++++++++++++- src/mapeo-manager.js | 16 ++++++--- src/member-api.js | 19 ++++++++-- test-e2e/invite-over-internet.js | 3 +- 9 files changed, 151 insertions(+), 13 deletions(-) diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md index 626febc9d..a18a100b3 100644 --- a/docs/guides/invite-over-internet.md +++ b/docs/guides/invite-over-internet.md @@ -75,6 +75,8 @@ const { // Connect to the invitor, send request, join project with initial sync const projectId = await manager.joinProjectFromLink(url) // InviteRedeemConnectionClosedError +// UnknownInviteIDError +// InviteDeniedByInviterError await manager.cancelJoinProjectFromLink(url) // InvalidInternetInviteURLError diff --git a/proto/rpc.proto b/proto/rpc.proto index df38097fc..64c8a6ac2 100644 --- a/proto/rpc.proto +++ b/proto/rpc.proto @@ -42,7 +42,13 @@ message RedeemInviteOverInternet { } message DenyInviteOverInternet { + enum DenyReason { + unspecified = 0; + unknown_invite_id = 1; + invitor_denied = 2; + } bytes inviteId = 1; + DenyReason reason = 2; } message DeviceInfo { diff --git a/src/errors.js b/src/errors.js index 18978bb5c..d125dee9f 100644 --- a/src/errors.js +++ b/src/errors.js @@ -596,7 +596,7 @@ export const InviteLinkAlreadyExistsError = createErrorClass({ export const InviteDeniedByInviterError = createErrorClass({ code: 'INVITE_DENIED_BY_INVITER_ERROR', - message: 'Invitation denied by project inviter', + message: 'Invitation denied by project inviter, reason: {reason}', status: 403, }) diff --git a/src/generated/rpc.d.ts b/src/generated/rpc.d.ts index f88920f52..e3b969ad7 100644 --- a/src/generated/rpc.d.ts +++ b/src/generated/rpc.d.ts @@ -40,7 +40,17 @@ export interface RedeemInviteOverInternet { } export interface DenyInviteOverInternet { inviteId: Buffer; + reason: DenyInviteOverInternet_DenyReason; } +export declare const DenyInviteOverInternet_DenyReason: { + readonly unspecified: "unspecified"; + readonly unknown_invite_id: "unknown_invite_id"; + readonly invitor_denied: "invitor_denied"; + readonly UNRECOGNIZED: "UNRECOGNIZED"; +}; +export type DenyInviteOverInternet_DenyReason = typeof DenyInviteOverInternet_DenyReason[keyof typeof DenyInviteOverInternet_DenyReason]; +export declare function denyInviteOverInternet_DenyReasonFromJSON(object: any): DenyInviteOverInternet_DenyReason; +export declare function denyInviteOverInternet_DenyReasonToNumber(object: DenyInviteOverInternet_DenyReason): number; export interface DeviceInfo { name: string; deviceType?: DeviceInfo_DeviceType | undefined; diff --git a/src/generated/rpc.js b/src/generated/rpc.js index 5d7950a27..5a4ad4956 100644 --- a/src/generated/rpc.js +++ b/src/generated/rpc.js @@ -44,6 +44,42 @@ export function inviteResponse_DecisionToNumber(object) { return -1; } } +export var DenyInviteOverInternet_DenyReason = { + unspecified: "unspecified", + unknown_invite_id: "unknown_invite_id", + invitor_denied: "invitor_denied", + UNRECOGNIZED: "UNRECOGNIZED", +}; +export function denyInviteOverInternet_DenyReasonFromJSON(object) { + switch (object) { + case 0: + case "unspecified": + return DenyInviteOverInternet_DenyReason.unspecified; + case 1: + case "unknown_invite_id": + return DenyInviteOverInternet_DenyReason.unknown_invite_id; + case 2: + case "invitor_denied": + return DenyInviteOverInternet_DenyReason.invitor_denied; + case -1: + case "UNRECOGNIZED": + default: + return DenyInviteOverInternet_DenyReason.UNRECOGNIZED; + } +} +export function denyInviteOverInternet_DenyReasonToNumber(object) { + switch (object) { + case DenyInviteOverInternet_DenyReason.unspecified: + return 0; + case DenyInviteOverInternet_DenyReason.unknown_invite_id: + return 1; + case DenyInviteOverInternet_DenyReason.invitor_denied: + return 2; + case DenyInviteOverInternet_DenyReason.UNRECOGNIZED: + default: + return -1; + } +} export var DeviceInfo_DeviceType = { device_type_unspecified: "device_type_unspecified", mobile: "mobile", @@ -473,7 +509,7 @@ export var RedeemInviteOverInternet = { }, }; function createBaseDenyInviteOverInternet() { - return { inviteId: Buffer.alloc(0) }; + return { inviteId: Buffer.alloc(0), reason: DenyInviteOverInternet_DenyReason.unspecified }; } export var DenyInviteOverInternet = { encode: function (message, writer) { @@ -481,6 +517,9 @@ export var DenyInviteOverInternet = { if (message.inviteId.length !== 0) { writer.uint32(10).bytes(message.inviteId); } + if (message.reason !== DenyInviteOverInternet_DenyReason.unspecified) { + writer.uint32(16).int32(denyInviteOverInternet_DenyReasonToNumber(message.reason)); + } return writer; }, decode: function (input, length) { @@ -496,6 +535,12 @@ export var DenyInviteOverInternet = { } message.inviteId = reader.bytes(); continue; + case 2: + if (tag !== 16) { + break; + } + message.reason = denyInviteOverInternet_DenyReasonFromJSON(reader.int32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -508,9 +553,10 @@ export var DenyInviteOverInternet = { return DenyInviteOverInternet.fromPartial(base !== null && base !== void 0 ? base : {}); }, fromPartial: function (object) { - var _a; + var _a, _b; var message = createBaseDenyInviteOverInternet(); message.inviteId = (_a = object.inviteId) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + message.reason = (_b = object.reason) !== null && _b !== void 0 ? _b : DenyInviteOverInternet_DenyReason.unspecified; return message; }, }; diff --git a/src/generated/rpc.ts b/src/generated/rpc.ts index 6cc26e655..6cf92e307 100644 --- a/src/generated/rpc.ts +++ b/src/generated/rpc.ts @@ -85,6 +85,49 @@ export interface RedeemInviteOverInternet { export interface DenyInviteOverInternet { inviteId: Buffer; + reason: DenyInviteOverInternet_DenyReason; +} + +export const DenyInviteOverInternet_DenyReason = { + unspecified: "unspecified", + unknown_invite_id: "unknown_invite_id", + invitor_denied: "invitor_denied", + UNRECOGNIZED: "UNRECOGNIZED", +} as const; + +export type DenyInviteOverInternet_DenyReason = + typeof DenyInviteOverInternet_DenyReason[keyof typeof DenyInviteOverInternet_DenyReason]; + +export function denyInviteOverInternet_DenyReasonFromJSON(object: any): DenyInviteOverInternet_DenyReason { + switch (object) { + case 0: + case "unspecified": + return DenyInviteOverInternet_DenyReason.unspecified; + case 1: + case "unknown_invite_id": + return DenyInviteOverInternet_DenyReason.unknown_invite_id; + case 2: + case "invitor_denied": + return DenyInviteOverInternet_DenyReason.invitor_denied; + case -1: + case "UNRECOGNIZED": + default: + return DenyInviteOverInternet_DenyReason.UNRECOGNIZED; + } +} + +export function denyInviteOverInternet_DenyReasonToNumber(object: DenyInviteOverInternet_DenyReason): number { + switch (object) { + case DenyInviteOverInternet_DenyReason.unspecified: + return 0; + case DenyInviteOverInternet_DenyReason.unknown_invite_id: + return 1; + case DenyInviteOverInternet_DenyReason.invitor_denied: + return 2; + case DenyInviteOverInternet_DenyReason.UNRECOGNIZED: + default: + return -1; + } } export interface DeviceInfo { @@ -609,7 +652,7 @@ export const RedeemInviteOverInternet = { }; function createBaseDenyInviteOverInternet(): DenyInviteOverInternet { - return { inviteId: Buffer.alloc(0) }; + return { inviteId: Buffer.alloc(0), reason: DenyInviteOverInternet_DenyReason.unspecified }; } export const DenyInviteOverInternet = { @@ -617,6 +660,9 @@ export const DenyInviteOverInternet = { if (message.inviteId.length !== 0) { writer.uint32(10).bytes(message.inviteId); } + if (message.reason !== DenyInviteOverInternet_DenyReason.unspecified) { + writer.uint32(16).int32(denyInviteOverInternet_DenyReasonToNumber(message.reason)); + } return writer; }, @@ -634,6 +680,13 @@ export const DenyInviteOverInternet = { message.inviteId = reader.bytes() as Buffer; continue; + case 2: + if (tag !== 16) { + break; + } + + message.reason = denyInviteOverInternet_DenyReasonFromJSON(reader.int32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -649,6 +702,7 @@ export const DenyInviteOverInternet = { fromPartial, I>>(object: I): DenyInviteOverInternet { const message = createBaseDenyInviteOverInternet(); message.inviteId = object.inviteId ?? Buffer.alloc(0); + message.reason = object.reason ?? DenyInviteOverInternet_DenyReason.unspecified; return message; }, }; diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index c62c5bde2..5014545bb 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -67,6 +67,7 @@ import { InitialSyncFailedError, UnknownInviteIDRedeemAttemptError, RPCDisconnectBeforeAckError, + UnknownInviteIDError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' @@ -77,7 +78,7 @@ import { InviteLinksApi } from './invite/invite-links-api.js' import { parseInviteURL } from './invite/invite-urls.js' import { kHandleRedeemInviteOverInternet } from './member-api.js' -/** @import { MapShareExtension } from './generated/rpc.js' */ +/** @import { DenyInviteOverInternet, MapShareExtension } from './generated/rpc.js' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ /** @import { SetNonNullable } from 'type-fest' */ /** @import { ProjectJoinDetails, } from './generated/rpc.js' */ @@ -1319,10 +1320,10 @@ export class MapeoManager extends TypedEmitter { /** * Handle an incoming deny from the RPC layer, aborting a matching redeem attempt if pending. * @param {string} peerId - * @param {{ inviteId: Buffer }} deny + * @param {DenyInviteOverInternet} deny */ - #handleInviteDenied(peerId, deny) { - const inviteIdString = deny.inviteId.toString('hex') + #handleInviteDenied(peerId, { inviteId, reason }) { + const inviteIdString = inviteId.toString('hex') this.#l.log( 'Got deny for invite %S from %S', inviteIdString.slice(0, 7), @@ -1330,7 +1331,11 @@ export class MapeoManager extends TypedEmitter { ) const ac = this.#outboundRedeemInvites.get(inviteIdString) if (ac) { - ac.abort(new InviteDeniedByInviterError()) + const err = + reason === 'unknown_invite_id' + ? new UnknownInviteIDError() + : new InviteDeniedByInviterError({ reason }) + ac.abort(err) this.#outboundRedeemInvites.delete(inviteIdString) } } @@ -1348,6 +1353,7 @@ export class MapeoManager extends TypedEmitter { try { await this.#localPeers.sendDenyInviteOverInternet(peerId, { inviteId, + reason: 'unknown_invite_id', }) } catch (e) { // This error happens sometimes since both sides break the conn on deny diff --git a/src/member-api.js b/src/member-api.js index b74885b2d..5ec24dac2 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -2,7 +2,10 @@ import * as b4a from 'b4a' import * as crypto from 'node:crypto' import WebSocket from 'ws' import { pEvent } from 'p-event' -import { InviteResponse_Decision } from './generated/rpc.js' +import { + InviteResponse_Decision, + DenyInviteOverInternet_DenyReason, +} from './generated/rpc.js' import { noop, projectKeyToProjectInviteId, @@ -330,7 +333,11 @@ export class MemberApi extends TypedEmitter { inviteIdString.slice(0, 7) ) - await this.denyInviteLinkRequest(inviteIdString, peerId) + await this.denyInviteLinkRequest( + inviteIdString, + peerId, + DenyInviteOverInternet_DenyReason.unknown_invite_id + ) throw new UnknownInviteIDRedeemAttemptError() } @@ -399,9 +406,14 @@ export class MemberApi extends TypedEmitter { * Deny a specific device's attempt at redeeming an invite. * @param {string} inviteId * @param {string} deviceId + * @param {DenyInviteOverInternet_DenyReason} [reason] Reason for denying the request * @returns {Promise} */ - async denyInviteLinkRequest(inviteId, deviceId) { + async denyInviteLinkRequest( + inviteId, + deviceId, + reason = DenyInviteOverInternet_DenyReason.invitor_denied + ) { const redeemedSet = this.#redeemedInvites.get(inviteId) if (!redeemedSet || !redeemedSet.has(deviceId)) { throw new InviteNotYetRedeemedError() @@ -415,6 +427,7 @@ export class MemberApi extends TypedEmitter { try { await this.#rpc.sendDenyInviteOverInternet(deviceId, { inviteId: Buffer.from(inviteId, 'hex'), + reason, }) } catch { // RPC may fail if the peer disconnected, that's ok diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index b14bcab14..10f3d68be 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -14,6 +14,7 @@ import { InviteNotYetRedeemedError, InviteDeniedByInviterError, JoinProjectCancelledError, + UnknownInviteIDError, } from '../src/errors.js' import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' import { temporaryDirectory } from 'tempy' @@ -453,7 +454,7 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = assert.rejects( // Try to join with invalid invite ID invitee.joinProjectFromLink(modifiedUrl), - (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code + (err) => ensureKnownError(err).code === UnknownInviteIDError.code ), ]) }) From 3e5692d671662bf66a0f8dc128f5dd6c5f0939b1 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 17:53:15 -0400 Subject: [PATCH 079/103] test: Check that invite links seedTime works as expected --- test-e2e/invite-over-internet.js | 34 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 10f3d68be..063f4d6c5 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -43,23 +43,41 @@ test('invite over internet and join from URL', async (t) => { timeout: 5000, }) + const abortOnFailedJoin = new AbortController() + + const onConnected = pEvent(invitee, 'invite-link-join-connected', { + signal: abortOnFailedJoin.signal, + timeout: 5000, + }) const onInvited = invitee.joinProjectFromLink(url) - const [invitedProjectId, deviceId, inviteId] = - /** @type {[string, string, string]} */ ( - /**@type unknown*/ (await onInviteRedeemAttempt) - ) + onInvited.catch((e) => abortOnFailedJoin.abort(e)) + + const [connectedURL, [invitedProjectId, deviceId, inviteId]] = + await Promise.all([ + onConnected, + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ), + ]) + assert.equal(connectedURL, url) assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) + const onAccepted = pEvent(invitee, 'invite-link-join-accepted', { + signal: abortOnFailedJoin.signal, + timeout: 5000, + }) + // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const reason = await project.$member.acceptInviteLinkRequest( - inviteId, - deviceId - ) + const [acceptedURL, reason] = await Promise.all([ + onAccepted, + project.$member.acceptInviteLinkRequest(inviteId, deviceId), + ]) + assert.equal(acceptedURL, url) assert.equal(reason, InviteResponse_Decision.ACCEPT) const gotProjectId = await onInvited From 6f230a5d10c593ad8ba1ec9b5a2c167920c04526 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 10 Jun 2026 17:54:29 -0400 Subject: [PATCH 080/103] feat: invite link connected and accepted events on manager. --- docs/guides/invite-over-internet.md | 18 ++- src/mapeo-manager.js | 9 +- test/invite-links-api.js | 179 ++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md index a18a100b3..64c7255f2 100644 --- a/docs/guides/invite-over-internet.md +++ b/docs/guides/invite-over-internet.md @@ -72,11 +72,23 @@ const { inviteIdString } = parseInviteURL(url) +manager.on('invite-link-join-connected', (url) => { + // Connected to the invitor and waiting to join or get denied + // Show waiting for accept screen +}) + +manager.on('invite-link-join-accepted', (url) => { + // Invitor has accepted us and we're now joining + // Show now joining screen +}) + // Connect to the invitor, send request, join project with initial sync +// Show Waiting to connect screen until we get the connected event or react to the errors +// Once resolved show joined screen const projectId = await manager.joinProjectFromLink(url) -// InviteRedeemConnectionClosedError -// UnknownInviteIDError -// InviteDeniedByInviterError +// InviteRedeemConnectionClosedError => Connection error page +// UnknownInviteIDError => Invite has expried page +// InviteDeniedByInviterError => Request denied page await manager.cancelJoinProjectFromLink(url) // InvalidInternetInviteURLError diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 5014545bb..4c821125b 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -149,7 +149,9 @@ const RPC_FEATURES = [ * @property {(peers: PublicPeerInfo[]) => void} local-peers Emitted when the list of connected peers changes (new ones added, or connection status changes) * @property {(mapShare: MapShare) => void} map-share Emitted when a project has recieved a map share request * @property {(e: Error, mapShare: MapShareExtension) => void} map-share-error - Emitted when an incoming map share fails to be recieved due to formatting issues - * @property {(projectId: string, deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet has been redeemed, accept the deviceId to add them + * @property {(projectId: string, deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet link has been redeemed, accept the deviceId to add them + * @property {(url: string) => void} invite-link-join-connected Emitted when we've connected to the invitor for an invite over the internet link + * @property {(url: string) => void} invite-link-join-accepted Emitted when the invitor has accepted our request to join * @property {(err: Error, deviceId: string, inviteId: string) => void} invite-link-join-request-error Emitted when an invite over the internet has failed to be redeemed */ @@ -820,6 +822,9 @@ export class MapeoManager extends TypedEmitter { ) // It's okay if this rejection never gets handled onClose.catch(noop) + + this.emit('invite-link-join-connected', url) + try { // Use the identity key from the handshake, not the swarm key from the URL const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') @@ -841,6 +846,8 @@ export class MapeoManager extends TypedEmitter { onClose, ]) + this.emit('invite-link-join-accepted', url) + const projectId = await this.#invite.accept(invite) return projectId diff --git a/test/invite-links-api.js b/test/invite-links-api.js index df6f16bea..a14361664 100644 --- a/test/invite-links-api.js +++ b/test/invite-links-api.js @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' +import { eq } from 'drizzle-orm' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { randomBytes } from 'node:crypto' @@ -984,3 +985,181 @@ test('scheduleExpired - deleting last invite cancels timer', async (t) => { 'no timer firing after delete (none to check against)' ) }) + +// --------------------------------------------------------------------------- +// getSeedTime() tests +// These test the mechanism that keeps the swarm identity stable while +// invite links exist, and changes it when all invites are gone. +// In MapeoManager the swarm identity is derived from InviteLinksApi.getSeedTime(). +// --------------------------------------------------------------------------- + +test('getSeedTime() - returns current time when no invites exist', async (t) => { + const { api } = setup(t) + + await api.ready() + + const before = Date.now() + const seedTime = api.getSeedTime() + const after = Date.now() + + assert.ok( + seedTime >= before && seedTime <= after, + 'getSeedTime returns approximate current time when no invites exist' + ) +}) + +test('getSeedTime() - locks to first invite, subsequent invites share same seedTime', async (t) => { + const { api, db } = setup(t) + + await api.ready() + + const inviteId1 = randomBytes(32) + const inviteId1String = inviteId1.toString('hex') + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId1String, + inviteIdBuffer: inviteId1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const seedTime1 = api.getSeedTime() + const [dbRow1] = db + .select({ seedTime: inviteLinksTable.seedTime }) + .from(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteId1String)) + .all() + assert.equal( + dbRow1.seedTime, + seedTime1, + 'first invite seedTime matches getSeedTime' + ) + + // Small delay to guarantee a different Date.now() would be produced + await new Promise((resolve) => setTimeout(resolve, 5)) + + const inviteId2 = randomBytes(32) + const inviteId2String = inviteId2.toString('hex') + + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId2String, + inviteIdBuffer: inviteId2, + url: 'https://example.com/2', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const [dbRow2] = db + .select({ seedTime: inviteLinksTable.seedTime }) + .from(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteId2String)) + .all() + assert.equal( + dbRow2.seedTime, + seedTime1, + 'second invite shares the same seedTime as the first' + ) + assert.equal( + api.getSeedTime(), + seedTime1, + 'getSeedTime still returns the locked value' + ) +}) + +test('getSeedTime() - new invite after all deleted gets a new seedTime', async (t) => { + const { api } = setup(t) + + await api.ready() + + const inviteId1 = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId1.toString('hex'), + inviteIdBuffer: inviteId1, + url: 'https://example.com/1', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const seedTime1 = api.getSeedTime() + + // Delete the only invite + await api.delete(inviteId1.toString('hex')) + + // Small delay to ensure a different timestamp + await new Promise((resolve) => setTimeout(resolve, 5)) + + const inviteId2 = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId2.toString('hex'), + inviteIdBuffer: inviteId2, + url: 'https://example.com/2', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + const seedTime2 = api.getSeedTime() + assert.notEqual( + seedTime2, + seedTime1, + 'seedTime changed after all invites were deleted and a new one created' + ) +}) + +test('getSeedTime() - locked to oldest invite across all projects', async (t) => { + const { api, db } = setup(t) + + await api.ready() + + // Create invite in project A + const inviteIdA = randomBytes(32) + const inviteIdAString = inviteIdA.toString('hex') + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdAString, + inviteIdBuffer: inviteIdA, + url: 'https://example.com/a', + opts: { roleId: MEMBER_ROLE_ID }, + }) + const [dbRowA] = db + .select({ seedTime: inviteLinksTable.seedTime }) + .from(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteIdAString)) + .all() + + // Small delay so project B's invite is strictly newer + await new Promise((resolve) => setTimeout(resolve, 5)) + + // Create invite in project B + const inviteIdB = randomBytes(32) + const inviteIdBString = inviteIdB.toString('hex') + await api.create({ + projectId: 'other-project', + inviteId: inviteIdBString, + inviteIdBuffer: inviteIdB, + url: 'https://example.com/b', + opts: { roleId: MEMBER_ROLE_ID }, + }) + const [dbRowB] = db + .select({ seedTime: inviteLinksTable.seedTime }) + .from(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteIdBString)) + .all() + + // seedTime should lock to project A's invite (the oldest) + assert.equal( + api.getSeedTime(), + dbRowA.seedTime, + 'seedTime locked to oldest invite (project A)' + ) + + // Delete project A's oldest invite + await api.delete(inviteIdAString) + + // seedTime should now be from project B's invite + assert.equal( + api.getSeedTime(), + dbRowB.seedTime, + 'seedTime shifted to second-oldest invite (project B) after oldest deleted' + ) +}) From 3b43a27f87314e7283e908f5f3abb73ca2805f2d Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Mon, 15 Jun 2026 17:34:45 -0400 Subject: [PATCH 081/103] feat: allow list rpc methods on untrusted local peers --- src/errors.js | 11 +++++++ src/local-peers.js | 41 +++++++++++++++--------- test/helpers/local-peers.js | 8 +++-- test/local-peers.js | 64 ++++++++++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/errors.js b/src/errors.js index d125dee9f..72bfdec36 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,5 +1,9 @@ import { createErrorClass } from 'custom-error-creator' +/** + * @typedef {Error & {status: number, code: string}} KnownError + */ + export const NotFoundError = createErrorClass({ code: 'NOT_FOUND_ERROR', message: 'Not found', @@ -612,6 +616,12 @@ export const InitialSyncFailedError = createErrorClass({ status: 409, }) +export const UntrustedRPCMethodError = createErrorClass({ + code: 'UNTRUSTED_RPC_METHOD_ERROR', + message: 'Got a restricted RPC method {type} from {peerId}', + status: 405, +}) + /** * @param {unknown} err * @returns {null} @@ -648,6 +658,7 @@ export function getErrorCode(maybeError) { /** * Throw an UnexpectedErrorTypeError if this is not a standard error * @param {Error & {status?: number, code?: string} | any} err + * @returns {KnownError} */ export function ensureKnownError(err) { if (typeof err.status !== 'number' || typeof err.code !== 'string') { diff --git a/src/local-peers.js b/src/local-peers.js index d879ff90c..2ebb8c096 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -33,6 +33,8 @@ import { InvalidInviteError, InvalidProjectJoinDetailsError, MapShareNotSupportedByPeerError, + UntrustedRPCMethodError, + ensureKnownError, } from './errors.js' /** @import NoiseStream from '@hyperswarm/secret-stream' */ @@ -85,6 +87,14 @@ const MESSAGE_TYPES = { } const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) +/** + * @type {Set} + */ +const ALLOWED_UNTRUSTED_RPC = new Set([ + 'DeviceInfo', + 'RedeemInviteOverInternet', +]) + export const kTestOnlySendRawInvite = Symbol('testOnlySendRawInvite') /** @@ -567,7 +577,7 @@ class Peer { * @property {(peerId: string, details: ProjectJoinDetailsAck) => void} got-project-details-ack Emitted when project details are acknowledged as received * @property {(sender: PeerInfo, details: MapShareExtension) => void} map-share Emitted when a MapShare request is received * @property {(discoveryKey: Buffer, protomux: Protomux) => void} discovery-key Emitted when a new hypercore is replicated (by a peer) to a peer protomux instance (passed as the second parameter) - * @property {(messageType: string, errorMessage?: string) => void} failed-to-handle-message Emitted when we received a message we couldn't handle for some reason. Primarily useful for testing + * @property {(messageType: string, errorMessage: import('./errors.js').KnownError) => void} failed-to-handle-message Emitted when we received a message we couldn't handle for some reason. Primarily useful for testing */ /** @extends {TypedEmitter} */ @@ -804,9 +814,8 @@ export class LocalPeers extends TypedEmitter { message ) } catch (e) { - const errorMessage = String(e) - this.emit('failed-to-handle-message', type, errorMessage) - this.#l.log(`Error handling ${type} message: ${errorMessage}`) + this.emit('failed-to-handle-message', type, ensureKnownError(e)) + this.#l.log(`Error handling ${type} message: ${e}`) } }, } @@ -917,9 +926,21 @@ export class LocalPeers extends TypedEmitter { const peer = this.#getPeerByProtomux(protomux) /* c8 ignore next */ if (!peer) return // TODO: report error - this should not happen + // If the peer isn't trusted, ignore anything not allowed + // Allow acknowledge messages by default + if ( + !peer.isTrusted && + !ALLOWED_UNTRUSTED_RPC.has(type) && + !type.endsWith('Ack') + ) { + throw new UntrustedRPCMethodError({ + type, + peerId: peer.id, + }) + } + switch (type) { case 'Invite': { - if (!peer.isTrusted) return const invite = parseInvite(value) peer .sendInviteAck(invite) @@ -938,7 +959,6 @@ export class LocalPeers extends TypedEmitter { break } case 'InviteCancel': { - if (!peer.isTrusted) return const inviteCancel = parseInviteCancel(value) peer .sendInviteCancelAck(inviteCancel) @@ -956,7 +976,6 @@ export class LocalPeers extends TypedEmitter { break } case 'InviteResponse': { - if (!peer.isTrusted) return const inviteResponse = parseInviteResponse(value) peer .sendInviteResponseAck(inviteResponse) @@ -981,7 +1000,6 @@ export class LocalPeers extends TypedEmitter { break } case 'ProjectJoinDetails': { - if (!peer.isTrusted) return const details = parseProjectJoinDetails(value) peer .sendProjectJoinDetailsAck(details) @@ -1000,42 +1018,36 @@ export class LocalPeers extends TypedEmitter { break } case 'MapShareExtension': { - if (!peer.isTrusted) return const mapShare = MapShareExtension.decode(value) const info = /** @type {PeerInfo} */ (peer.info) this.emit('map-share', info, mapShare) break } case 'InviteAck': { - if (!peer.isTrusted) return const ack = InviteAck.decode(value) peer.receiveAck('InviteAck', ack) this.emit('invite-ack', peer.id, ack) break } case 'InviteCancelAck': { - if (!peer.isTrusted) return const ack = InviteCancelAck.decode(value) peer.receiveAck('InviteCancelAck', ack) this.emit('invite-cancel-ack', peer.id, ack) break } case 'InviteResponseAck': { - if (!peer.isTrusted) return const ack = InviteResponseAck.decode(value) peer.receiveAck('InviteResponseAck', ack) this.emit('invite-response-ack', peer.id, ack) break } case 'ProjectJoinDetailsAck': { - if (!peer.isTrusted) return const ack = ProjectJoinDetailsAck.decode(value) peer.receiveAck('ProjectJoinDetailsAck', ack) this.emit('got-project-details-ack', peer.id, ack) break } case 'RedeemInviteOverInternetAck': { - if (!peer.isTrusted) return const ack = RedeemInviteOverInternetAck.decode(value) peer.receiveAck('RedeemInviteOverInternetAck', ack) this.emit('invite-over-internet-redeemed-ack', peer.id, ack) @@ -1054,7 +1066,6 @@ export class LocalPeers extends TypedEmitter { break } case 'DenyInviteOverInternetAck': { - if (!peer.isTrusted) return const ack = DenyInviteOverInternetAck.decode(value) peer.receiveAck('DenyInviteOverInternetAck', ack) this.emit('invite-over-internet-denied-ack', peer.id, ack) diff --git a/test/helpers/local-peers.js b/test/helpers/local-peers.js index 1b7a8ce84..74d4da96a 100644 --- a/test/helpers/local-peers.js +++ b/test/helpers/local-peers.js @@ -7,7 +7,7 @@ import NoiseSecretStream from '@hyperswarm/secret-stream' /** * @param {import('../../src/local-peers.js').LocalPeers} rpc1 * @param {import('../../src/local-peers.js').LocalPeers} rpc2 - * @param { {kp1?: KeyPair, kp2?: KeyPair} } [keyPairs] + * @param { {kp1?: KeyPair, kp2?: KeyPair, isTrusted1?: boolean, isTrusted2?: boolean} } [opts] */ export function replicate( rpc1, @@ -16,6 +16,8 @@ export function replicate( // Keep keypairs deterministic for tests, since we use peer.publicKey as an identifier. kp1 = NoiseSecretStream.keyPair(Buffer.allocUnsafe(32).fill(0)), kp2 = NoiseSecretStream.keyPair(Buffer.allocUnsafe(32).fill(1)), + isTrusted1 = true, + isTrusted2 = true, } = {} ) { const n1 = new NoiseSecretStream(true, undefined, { @@ -28,8 +30,8 @@ export function replicate( // @ts-expect-error n1.rawStream.pipe(n2.rawStream).pipe(n1.rawStream) - rpc1.connect(n1) - rpc2.connect(n2) + rpc1.connect(n1, isTrusted1) + rpc2.connect(n2, isTrusted2) /** @param {Error} [e] */ return async function destroy(e) { diff --git a/test/local-peers.js b/test/local-peers.js index eaa55cd01..15e98c136 100644 --- a/test/local-peers.js +++ b/test/local-peers.js @@ -13,7 +13,7 @@ import { InviteResponse_Decision, } from '../src/generated/rpc.js' import { pEvent } from 'p-event' -import { UnknownPeerError } from '../src/errors.js' +import { UnknownPeerError, UntrustedRPCMethodError } from '../src/errors.js' test('sending and receiving invites', async () => { const r1 = new LocalPeers() @@ -457,3 +457,65 @@ function testProjectInviteId() { const projectKey = testProjectKey() return projectKeyToProjectInviteId(projectKey) } + +test('untrusted peer cannot send restricted RPC methods', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + // Connect r1 as trusted but r2 as untrusted + replicate(r1, r2, { isTrusted1: false, isTrusted2: true }) + + // Get the peer info from r2's perspective (r2 sees r1 as a peer) + const [[peerFromR2]] = await once(r2, 'peers') + assert.equal(peerFromR2.status, 'connected') + + // r2 (untrusted) tries to send an Invite (restricted method) to r1 + const failedPromise = once(r1, 'failed-to-handle-message') + + /** @type {import('../src/generated/rpc.js').Invite} */ + const validInvite = { + inviteId: testInviteId(), + projectInviteId: testProjectInviteId(), + projectName: 'Mapeo Project', + invitorName: 'device0', + sendStats: false, + leaveOnFail: false, + invitorWroteDeviceInfo: false, + } + + await r2.sendInvite(peerFromR2.deviceId, validInvite) + + const [messageType, errorMessage] = await failedPromise + assert.equal(messageType, 'Invite') + assert.equal( + errorMessage.code, + UntrustedRPCMethodError.code, + 'got untrusted method error on r1' + ) +}) + +test('untrusted peer can send allowed RPC methods', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + // Connect r1 as trusted but r2 as untrusted + replicate(r1, r2, { isTrusted1: false, isTrusted2: false }) + + // Get the peer info from r2's perspective (r2 sees r1 as a peer) + const [[peerFromR2]] = await once(r2, 'peers') + assert.equal(peerFromR2.status, 'connected') + + // DeviceInfo is in ALLOWED_UNTRUSTED_RPC, so it should succeed + /** @type {import('../src/generated/rpc.js').DeviceInfo} */ + const deviceInfo = { + name: 'untrusted', + deviceType: 'mobile', + features: [], + } + + await r2.sendDeviceInfo(peerFromR2.deviceId, deviceInfo) + + // Should have received the device info without failing + const [r1Peers] = await once(r1, 'peers') + assert.equal(r1Peers[0].name, 'untrusted') +}) From 898cbc7974df358e110cfdd82d01ac173ba229ab Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 17 Jun 2026 15:35:32 -0400 Subject: [PATCH 082/103] feat: gate sent rpc on trust, send device info on trusted peer --- src/local-peers.js | 20 ++++++++++++++++++++ src/mapeo-manager.js | 21 +++++++++++++++++++++ test/local-peers.js | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/local-peers.js b/src/local-peers.js index 2ebb8c096..b69c727cb 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -578,6 +578,7 @@ class Peer { * @property {(sender: PeerInfo, details: MapShareExtension) => void} map-share Emitted when a MapShare request is received * @property {(discoveryKey: Buffer, protomux: Protomux) => void} discovery-key Emitted when a new hypercore is replicated (by a peer) to a peer protomux instance (passed as the second parameter) * @property {(messageType: string, errorMessage: import('./errors.js').KnownError) => void} failed-to-handle-message Emitted when we received a message we couldn't handle for some reason. Primarily useful for testing + * @property {(peerId:string) => void} peer-trusted Emitted when a previously untrusted peer gets marked as trusted */ /** @extends {TypedEmitter} */ @@ -622,6 +623,7 @@ export class LocalPeers extends TypedEmitter { if (!peer.supportsMapShare()) { throw new MapShareNotSupportedByPeerError() } + checkTrusted('MapShareExtension', peer) await peer.sendMapShare(mapShare) } @@ -633,6 +635,7 @@ export class LocalPeers extends TypedEmitter { async sendInvite(deviceId, invite) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('Invite', peer) await peer.sendInvite(invite) } @@ -644,6 +647,7 @@ export class LocalPeers extends TypedEmitter { async sendInviteCancel(deviceId, inviteCancel) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('InviteCancel', peer) await peer.sendInviteCancel(inviteCancel) } @@ -656,6 +660,7 @@ export class LocalPeers extends TypedEmitter { async sendInviteResponse(deviceId, inviteResponse) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('InviteResponse', peer) await peer.sendInviteResponse(inviteResponse) } @@ -690,6 +695,7 @@ export class LocalPeers extends TypedEmitter { async sendProjectJoinDetails(deviceId, details) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('ProjectJoinDetails', peer) await peer.sendProjectJoinDetails(details) } @@ -701,6 +707,7 @@ export class LocalPeers extends TypedEmitter { async sendDeviceInfo(deviceId, deviceInfo) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('DeviceInfo', peer) await peer.sendDeviceInfo(deviceInfo) } @@ -721,6 +728,7 @@ export class LocalPeers extends TypedEmitter { async trustPeer(peerId) { const peer = await this.#getPeerByDeviceId(peerId) peer.isTrusted = true + this.emit('peer-trusted', peerId) } /** @@ -1247,3 +1255,15 @@ export function peerIdFromNoise(stream) { return peerId } + +/** + * @param {string} type + * @param {Peer} peer + */ +function checkTrusted(type, peer) { + if (peer.isTrusted) return + throw new UntrustedRPCMethodError({ + type, + peerId: peer.id, + }) +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 4c821125b..3216091df 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -282,6 +282,12 @@ export class MapeoManager extends TypedEmitter { } ) + this.#localPeers.on('peer-trusted', (peerId) => { + this.#handlePeerTrusted(peerId).catch((e) => { + this.#l.log('Error: Unable to handle peer trust update', ensureError(e)) + }) + }) + this.#projectSettingsIndexWriter = new IndexWriter({ tables: [projectSettingsTable], sqlite, @@ -1384,6 +1390,21 @@ export class MapeoManager extends TypedEmitter { this.emit('invite-link-join-request', projectId, peerId, inviteIdString) } + /** + * @param {string} peerId + */ + async #handlePeerTrusted(peerId) { + const deviceInfo = this.getDeviceInfo() + if (!hasSavedDeviceInfo(deviceInfo)) return + + const deviceInfoToSend = { + ...deviceInfo, + features: RPC_FEATURES, + } + + await this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend) + } + async getMapStyleJsonUrl() { await timeoutPromise(Promise.resolve(this.#fastify.ready()), { milliseconds: 1000, diff --git a/test/local-peers.js b/test/local-peers.js index 15e98c136..2ffeebb7a 100644 --- a/test/local-peers.js +++ b/test/local-peers.js @@ -499,7 +499,7 @@ test('untrusted peer can send allowed RPC methods', async () => { const r2 = new LocalPeers() // Connect r1 as trusted but r2 as untrusted - replicate(r1, r2, { isTrusted1: false, isTrusted2: false }) + replicate(r1, r2, { isTrusted1: false, isTrusted2: true }) // Get the peer info from r2's perspective (r2 sees r1 as a peer) const [[peerFromR2]] = await once(r2, 'peers') From 2c8bd3ef7dcb594d682a910bd7bd341a2b798844 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 17 Jun 2026 16:51:24 -0400 Subject: [PATCH 083/103] test: check for rejected RPC on untrusted peers --- test/local-peers.js | 182 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 3 deletions(-) diff --git a/test/local-peers.js b/test/local-peers.js index 2ffeebb7a..d0390ccbb 100644 --- a/test/local-peers.js +++ b/test/local-peers.js @@ -458,18 +458,18 @@ function testProjectInviteId() { return projectKeyToProjectInviteId(projectKey) } -test('untrusted peer cannot send restricted RPC methods', async () => { +test('untrusted peer sends restricted RPC methods are rejected by receiver', async () => { const r1 = new LocalPeers() const r2 = new LocalPeers() - // Connect r1 as trusted but r2 as untrusted + // r1 does not trust r2, r2 trusts r1 replicate(r1, r2, { isTrusted1: false, isTrusted2: true }) // Get the peer info from r2's perspective (r2 sees r1 as a peer) const [[peerFromR2]] = await once(r2, 'peers') assert.equal(peerFromR2.status, 'connected') - // r2 (untrusted) tries to send an Invite (restricted method) to r1 + // r2 trusts r1, so it can send — but r1 rejects because r2 is untrusted from r1's side const failedPromise = once(r1, 'failed-to-handle-message') /** @type {import('../src/generated/rpc.js').Invite} */ @@ -494,6 +494,182 @@ test('untrusted peer cannot send restricted RPC methods', async () => { ) }) +test('cannot send restricted RPC methods to untrusted peers', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + // r1 does not trust r2, so checkTrusted blocks all restricted sends from r1 + replicate(r1, r2, { isTrusted1: false, isTrusted2: false }) + + const [[peerFromR1]] = await once(r1, 'peers') + assert.equal(peerFromR1.status, 'connected') + + /** @type {import('../src/generated/rpc.js').Invite} */ + const validInvite = { + inviteId: testInviteId(), + projectInviteId: testProjectInviteId(), + projectName: 'Mapeo Project', + invitorName: 'device0', + sendStats: false, + leaveOnFail: false, + invitorWroteDeviceInfo: false, + } + + /** @type {import('../src/errors.js').UntrustedRPCMethodError['code']} */ + const expected = UntrustedRPCMethodError.code + + // All restricted methods throw when peer is not trusted + await Promise.all([ + assert.rejects( + () => r1.sendInvite(peerFromR1.deviceId, validInvite), + { code: expected }, + 'sendInvite rejected' + ), + assert.rejects( + () => + r1.sendInviteCancel(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + }), + { code: expected }, + 'sendInviteCancel rejected' + ), + assert.rejects( + () => + r1.sendInviteResponse(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + decision: InviteResponse_Decision.ACCEPT, + }), + { code: expected }, + 'sendInviteResponse rejected' + ), + assert.rejects( + () => + r1.sendProjectJoinDetails(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + projectKey: testProjectKey(), + encryptionKeys: { auth: randomBytes(16) }, + }), + { code: expected }, + 'sendProjectJoinDetails rejected' + ), + assert.rejects( + () => + r1.sendDeviceInfo(peerFromR1.deviceId, { + name: 'test', + deviceType: 'mobile', + features: [], + }), + { code: expected }, + 'sendDeviceInfo rejected' + ), + ]) +}) + +test('peer-trusted event fires on trustPeer', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + replicate(r1, r2, { isTrusted1: false, isTrusted2: true }) + + const [[peerFromR1]] = await once(r1, 'peers') + const trustedPromise = once(r1, 'peer-trusted') + + await r1.trustPeer(peerFromR1.deviceId) + + const [emittedPeerId] = await trustedPromise + assert.equal(emittedPeerId, peerFromR1.deviceId) +}) + +test('restricted RPC methods work after trustPeer', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + replicate(r1, r2, { isTrusted1: false, isTrusted2: true }) + + const [[peerFromR1]] = await once(r1, 'peers') + + /** @type {import('../src/generated/rpc.js').Invite} */ + const validInvite = { + inviteId: testInviteId(), + projectInviteId: testProjectInviteId(), + projectName: 'Mapeo Project', + invitorName: 'device0', + sendStats: false, + leaveOnFail: false, + invitorWroteDeviceInfo: false, + } + + // All blocked before trust + /** @type {import('../src/errors.js').UntrustedRPCMethodError['code']} */ + const expected = UntrustedRPCMethodError.code + await Promise.all([ + assert.rejects(() => r1.sendInvite(peerFromR1.deviceId, validInvite), { + code: expected, + }), + assert.rejects( + () => + r1.sendInviteCancel(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + }), + { code: expected } + ), + assert.rejects( + () => + r1.sendInviteResponse(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + decision: InviteResponse_Decision.ACCEPT, + }), + { code: expected } + ), + assert.rejects( + () => + r1.sendProjectJoinDetails(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + projectKey: testProjectKey(), + encryptionKeys: { auth: randomBytes(16) }, + }), + { code: expected } + ), + assert.rejects( + () => + r1.sendDeviceInfo(peerFromR1.deviceId, { + name: 'test', + deviceType: 'mobile', + features: [], + }), + { code: expected } + ), + ]) + + // Trust the peer — all methods work now + await r1.trustPeer(peerFromR1.deviceId) + + const inviteReceived = once(r2, 'invite') + await Promise.all([ + r1.sendInvite(peerFromR1.deviceId, validInvite), + r1.sendInviteCancel(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + }), + r1.sendInviteResponse(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + decision: InviteResponse_Decision.ACCEPT, + }), + r1.sendProjectJoinDetails(peerFromR1.deviceId, { + inviteId: validInvite.inviteId, + projectKey: testProjectKey(), + encryptionKeys: { auth: randomBytes(16) }, + }), + r1.sendDeviceInfo(peerFromR1.deviceId, { + name: 'test', + deviceType: 'mobile', + features: [], + }), + ]) + + const [_, receivedInvite] = await inviteReceived + assert.equal(receivedInvite.projectName, 'Mapeo Project') +}) + test('untrusted peer can send allowed RPC methods', async () => { const r1 = new LocalPeers() const r2 = new LocalPeers() From 570743b2ae546c5770c77f5a6d58984591b5c061 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 24 Jun 2026 16:48:05 -0400 Subject: [PATCH 084/103] fix: account for race conditions in finding existing peers in remote discovery --- src/discovery/remote-discovery.js | 42 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 19f50f630..4be34828e 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -13,6 +13,7 @@ import { } from '../errors.js' import { openedNoiseSecretStream } from '../lib/noise-secret-stream-helpers.js' +import pDefer from 'p-defer' /** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ /** @import {Keypair} from './local-discovery.js' */ @@ -50,6 +51,8 @@ export class RemoteDiscovery extends TypedEmitter { #shouldTrustKeys = new Set() /** @type {Set} */ #connections = new Set() + /** @type {Map>}*/ + #pendingHandshakes = new Map() /** * @param {Object} opts @@ -155,6 +158,28 @@ export class RemoteDiscovery extends TypedEmitter { ) } + /** + * @param {Buffer} noisePublicKey + * @returns {Promise} + */ + async #findExistingPeer(noisePublicKey) { + for (const existingConnection of this.#connections) { + if (!existingConnection.remotePublicKey?.equals(noisePublicKey)) continue + const opened = await openedNoiseSecretStream(existingConnection) + // If the connection closed, try again + if (opened.destroyed) return this.#findExistingPeer(noisePublicKey) + // @ts-ignore Some connections might not be handshaked, wait for them to be + if (!existingConnection.handshakePublicKey) { + const success = await this.#pendingHandshakes.get(existingConnection) + if (!success) return this.#findExistingPeer(noisePublicKey) + } + // @ts-ignore + return opened + } + + return null + } + /** * Connect to another peer by their NOISE public key * @param {string} publicKey @@ -167,16 +192,8 @@ export class RemoteDiscovery extends TypedEmitter { const swarm = await this.#ensureSwarm() const noisePublicKey = Buffer.from(publicKey, 'hex') - for (const existingConnection of this.#connections) { - if (existingConnection.remotePublicKey?.equals(noisePublicKey)) { - const opened = await openedNoiseSecretStream(existingConnection) - if (opened.destroyed) break - // @ts-ignore Some connections might not be handshaked, wait for them to be - if (!existingConnection.handshakePublicKey) break - // @ts-ignore - return opened - } - } + const existing = await this.#findExistingPeer(noisePublicKey) + if (existing) return existing const onAbort = () => { this.#l.log('Leave peer for %s', publicKey) @@ -215,6 +232,8 @@ export class RemoteDiscovery extends TypedEmitter { */ async #handleHyperswarmConnection(socket) { this.#connections.add(socket) + const pendingDefer = pDefer() + this.#pendingHandshakes.set(socket, pendingDefer.promise) socket.once('close', () => this.#connections.delete(socket)) try { const remotePublicKeyString = socket.remotePublicKey.toString('hex') @@ -253,8 +272,11 @@ export class RemoteDiscovery extends TypedEmitter { socket.handshakePublicKey = msg.publicKey // @ts-ignore this.emit('connection', socket) + this.#pendingHandshakes.delete(socket) + pendingDefer.resolve(true) } catch (err) { this.emit('error', ensureKnownError(err)) + pendingDefer.resolve(false) } } } From fd6094142580af4679bbfafb5412986d94092918 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 24 Jun 2026 17:14:47 -0400 Subject: [PATCH 085/103] fix: ensure remote connection ends on handshake error --- src/discovery/remote-discovery.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 4be34828e..86b402deb 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -275,6 +275,7 @@ export class RemoteDiscovery extends TypedEmitter { this.#pendingHandshakes.delete(socket) pendingDefer.resolve(true) } catch (err) { + socket.end() this.emit('error', ensureKnownError(err)) pendingDefer.resolve(false) } From 08eeca3740ccd7f5a4f4c3dba071e1c0b00ce93c Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 15:26:55 -0400 Subject: [PATCH 086/103] feat: Disconnect peers if they don't get trusted within 10s --- src/local-peers.js | 9 +++++ src/mapeo-manager.js | 31 +++++++++++++++- test-e2e/invite-over-internet.js | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/local-peers.js b/src/local-peers.js index b69c727cb..bd632a875 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -731,6 +731,15 @@ export class LocalPeers extends TypedEmitter { this.emit('peer-trusted', peerId) } + /** + * Check if a given peer is currently trusted for RPC calls + * @param {string} peerId + */ + async isTrusted(peerId) { + const peer = await this.#getPeerByDeviceId(peerId) + return peer.isTrusted + } + /** * Connect to a peer over an existing NoiseSecretStream * diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 3216091df..35414f139 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -121,6 +121,9 @@ const MAX_FILE_DESCRIPTORS = 768 // This is the timeout for waiting for sync state updates during initial sync (when adding a project or leaving a project) const INITIAL_SYNC_TIMEOUT_MS = 45_000 // 45 seconds +// how long to wait for a remote peer to be trusted before forec disconnecting +export const UNTRUSTED_TIMEOUT = 16000 + // Prefix names for routes registered with http server const BLOBS_PREFIX = 'blobs' const ICONS_PREFIX = 'icons' @@ -183,6 +186,9 @@ export class MapeoManager extends TypedEmitter { #defaultConfigPath #makeWebsocket #defaultIsArchiveDevice + #untrustedTimeout + /** @type {Set>}*/ + #pendingTrustedTimers = new Set() // Maps invite ID (hex string) to AbortController for in-flight redeem attempts /** @type {Map} */ @@ -201,6 +207,7 @@ export class MapeoManager extends TypedEmitter { * @param {string} [opts.fallbackMapPath] File path to a locally stored Styled Map Package (SMP) * @param {string} [opts.defaultOnlineStyleUrl] URL for an online-hosted StyleJSON asset. * @param {boolean} [opts.defaultIsArchiveDevice] Whether the node is an archive device by default + * @param {number} [opts.untrustedTimeout] How long to wait before disconnecting an untrusted peer * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {import('hyperswarm').SwarmOpts} [opts.swarm] */ @@ -217,6 +224,7 @@ export class MapeoManager extends TypedEmitter { fallbackMapPath = DEFAULT_FALLBACK_MAP_FILE_PATH, defaultOnlineStyleUrl = DEFAULT_ONLINE_STYLE_URL, defaultIsArchiveDevice = DEFAULT_IS_ARCHIVE_DEVICE, + untrustedTimeout = UNTRUSTED_TIMEOUT, makeWebsocket = (url) => new WebSocket(url), }) { super() @@ -224,6 +232,7 @@ export class MapeoManager extends TypedEmitter { this.#deviceId = getDeviceId(this.#keyManager) this.#defaultConfigPath = defaultConfigPath this.#defaultIsArchiveDevice = defaultIsArchiveDevice + this.#untrustedTimeout = untrustedTimeout this.#makeWebsocket = makeWebsocket const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId })) this.#l = Logger.create('manager', logger) @@ -424,7 +433,24 @@ export class MapeoManager extends TypedEmitter { const peerId = peerIdFromNoise(openedNoiseStream) - return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend) + if (!isTrusted) { + const timer = setTimeout(async () => { + this.#pendingTrustedTimers.delete(timer) + + if (!(await this.#localPeers.isTrusted(peerId))) { + noiseStream.end() + } + }, this.#untrustedTimeout) + + this.#pendingTrustedTimers.add(timer) + + noiseStream.once('close', () => { + clearTimeout(timer) + this.#pendingTrustedTimers.delete(timer) + }) + } else { + return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend) + } }) .catch((e) => { // Ignore error but log @@ -1417,6 +1443,9 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async close() { + for (const timer of this.#pendingTrustedTimers) { + clearTimeout(timer) + } await this.#inviteLinks.close() await this.#remoteDiscovery.close() // This added for workers PR diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 063f4d6c5..ca4de7fa3 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -3,6 +3,9 @@ import fsPromises from 'node:fs/promises' import { randomBytes } from 'node:crypto' import { createManager, createManagers } from './utils.js' import { MEMBER_ROLE_ID } from '../src/roles.js' +import createTestnet from 'hyperdht/testnet.js' +import { KeyManager } from '@mapeo/crypto' +import { RemoteDiscovery } from '../src/discovery/remote-discovery.js' import assert from 'node:assert/strict' import { pEvent } from 'p-event' import { InviteResponse_Decision } from '../src/generated/rpc.js' @@ -19,6 +22,7 @@ import { import { makeInviteURL, parseInviteURL } from '../src/invite/invite-urls.js' import { temporaryDirectory } from 'tempy' import { kWaitForInitialSyncWithPeer } from '../src/sync/sync-api.js' +import { LocalPeers } from '../src/local-peers.js' test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { @@ -586,3 +590,61 @@ test('invite over the internet removes project and removes member when failing t assert.equal(gotProjectId, projectId, 'Invited to project') }) + +test.only('untrusted peer is disconnected after untrustedTimeout', async (t) => { + process.on('unhandledRejection', (reason) => { + console.error('Unhandled rejection:', reason) + }) + const testnet = await createTestnet(2) + t.after(() => testnet.destroy()) + + const UNTRUSTED_TIMEOUT = 1000 + + const manager = createManager('invitor', t, { + swarm: { dht: testnet.nodes[0] }, + untrustedTimeout: UNTRUSTED_TIMEOUT, + }) + + await manager.setDeviceInfo({ name: 'invitor', deviceType: 'desktop' }) + + const projectId = await manager.createProject({ + name: 'Test Project', + }) + const project = await manager.getProject(projectId) + + const url = await project.$member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const { swarmPublicKey } = parseInviteURL(url) + + // Create a separate RemoteDiscovery to connect as an untrusted peer + const identityKeypair = new KeyManager( + Buffer.alloc(16, 99) + ).getIdentityKeypair() + const swarmKeypair = new KeyManager( + Buffer.alloc(16, 100) + ).getIdentityKeypair() + + const remoteDiscovery = new RemoteDiscovery({ + identityKeypair, + deriveSwarmIdentityKeypair: () => swarmKeypair, + swarm: { dht: testnet.nodes[0] }, + }) + t.after(() => remoteDiscovery.close()) + + const localPeers = new LocalPeers() + + await remoteDiscovery.start() + + // Connect to the manager's swarm key (inbound on manager side → isTrusted = false) + const connection = await remoteDiscovery.connectPeer(swarmPublicKey, { + timeout: 10000, + }) + + // Set up protomux connection + localPeers.connect(connection, true) + + // Connection should close within untrustedTimeout + small buffer + await pEvent(connection, 'close', { timeout: UNTRUSTED_TIMEOUT + 2000 }) +}) From 65148625d3684b9cac20ba67167825e53ccbb98a Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 15:55:33 -0400 Subject: [PATCH 087/103] chore: remove only from untrusted timeout test --- test-e2e/invite-over-internet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index ca4de7fa3..5c228e3cc 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -591,7 +591,7 @@ test('invite over the internet removes project and removes member when failing t assert.equal(gotProjectId, projectId, 'Invited to project') }) -test.only('untrusted peer is disconnected after untrustedTimeout', async (t) => { +test('untrusted peer is disconnected after untrustedTimeout', async (t) => { process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason) }) From d10bb7bef2a1a6890b10af1568facebbaf27c2bb Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 16:25:52 -0400 Subject: [PATCH 088/103] feat: tighten restrictions on Ack rpc from untrusted peers --- src/local-peers.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/local-peers.js b/src/local-peers.js index bd632a875..b5b160e77 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -93,6 +93,8 @@ const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) const ALLOWED_UNTRUSTED_RPC = new Set([ 'DeviceInfo', 'RedeemInviteOverInternet', + 'RedeemInviteOverInternetAck', + 'DenyInviteOverInternetAck', ]) export const kTestOnlySendRawInvite = Symbol('testOnlySendRawInvite') @@ -945,11 +947,7 @@ export class LocalPeers extends TypedEmitter { if (!peer) return // TODO: report error - this should not happen // If the peer isn't trusted, ignore anything not allowed // Allow acknowledge messages by default - if ( - !peer.isTrusted && - !ALLOWED_UNTRUSTED_RPC.has(type) && - !type.endsWith('Ack') - ) { + if (!peer.isTrusted && !ALLOWED_UNTRUSTED_RPC.has(type)) { throw new UntrustedRPCMethodError({ type, peerId: peer.id, From 6f344ca1aff0661e86e783dfe56eba0601680ce8 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 16:42:00 -0400 Subject: [PATCH 089/103] feat: Use StartStopStateMachine for RemoteDiscovery --- src/discovery/remote-discovery.js | 46 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 86b402deb..6b4e13292 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -1,6 +1,7 @@ import { TypedEmitter } from 'tiny-typed-emitter' import { Logger } from '../logger.js' import Hyperswarm from 'hyperswarm' +import StartStopStateMachine from 'start-stop-state-machine' import { pEvent, TimeoutError as EventTimeoutError } from 'p-event' import sodium from 'sodium-universal' import b4a from 'b4a' @@ -42,8 +43,7 @@ export class RemoteDiscovery extends TypedEmitter { #l /** @type {Hyperswarm?} */ #swarm = null - /** @type {Promise?} */ - #loading = null + #sm #identityKeypair #deriveSwarmIdentityKeypair #swarmOpts @@ -72,11 +72,14 @@ export class RemoteDiscovery extends TypedEmitter { this.#identityKeypair = identityKeypair this.#deriveSwarmIdentityKeypair = deriveSwarmIdentityKeypair this.#swarmOpts = swarmOpts + this.#sm = new StartStopStateMachine({ + start: this.#start.bind(this), + stop: this.#stop.bind(this), + }) } - async #initSwarm() { + async #start() { this.#l.log('Initializing swarm') - const swarm = new Hyperswarm({ keyPair: this.#deriveSwarmIdentityKeypair(), maxPeers: 16, @@ -87,40 +90,35 @@ export class RemoteDiscovery extends TypedEmitter { this.#l.log('Starting listen') await swarm.listen() this.#l.log('Listening') - return swarm + await swarm.resume() + this.#swarm = swarm } /** - * @returns {Promise} + * Start listening for incoming connections */ - async #ensureSwarm() { - await this.#loading - if (!this.#swarm) { - this.#loading = this.#initSwarm() - this.#swarm = await this.#loading - } - return this.#swarm + async start() { + return this.#sm.start() } /** - * Start listening for incoming connections + * @param {object} [opts] + * @param {boolean} [opts.force=false] Force-close open connections + * @returns {Promise} */ - async start() { - // TODO: Use start stop state machine - const swarm = await this.#ensureSwarm() - await swarm.resume() + async stop(opts) { + return this.#sm.stop(opts) } + /** - * Close all connections and stop listening + * @param {object} [_opts] */ - async stop() { + async #stop(_opts) { this.#l.log('Suspending swarm') - await this.#loading await this.#swarm?.suspend() } async close() { - await this.#loading await this.#swarm?.destroy() this.#l.log('Closed swarm') } @@ -189,7 +187,9 @@ export class RemoteDiscovery extends TypedEmitter { * @returns {Promise} */ async connectPeer(publicKey, { timeout = 60_000, signal } = {}) { - const swarm = await this.#ensureSwarm() + await this.#sm.start() + const swarm = this.#swarm + if (!swarm) throw new Error('Swarm not initialized') const noisePublicKey = Buffer.from(publicKey, 'hex') const existing = await this.#findExistingPeer(noisePublicKey) From eafa908ec42de586b5718302510dd805348a717b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 16:55:27 -0400 Subject: [PATCH 090/103] chore: Delete old keyBy util --- src/lib/key-by.js | 26 -------------------------- test/lib/key-by.js | 37 ------------------------------------- 2 files changed, 63 deletions(-) delete mode 100644 src/lib/key-by.js delete mode 100644 test/lib/key-by.js diff --git a/src/lib/key-by.js b/src/lib/key-by.js deleted file mode 100644 index 60db15a45..000000000 --- a/src/lib/key-by.js +++ /dev/null @@ -1,26 +0,0 @@ -import { DuplicateKeyError } from '../errors.js' - -/** - * Like [`Map.groupBy`][0], but the result's values aren't arrays. - * - * If multiple values resolve to the same key, an error is thrown. - * - * [0]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/groupBy - * - * @template T - * @template K - * @param {Iterable} items - * @param {(item: T) => K} callbackFn - * @returns {Map} - */ -export function keyBy(items, callbackFn) { - /** @type {Map} */ const result = new Map() - for (const item of items) { - const key = callbackFn(item) - if (result.has(key)) { - throw new DuplicateKeyError({ key: `${key}` }) - } - result.set(key, item) - } - return result -} diff --git a/test/lib/key-by.js b/test/lib/key-by.js deleted file mode 100644 index f7905829d..000000000 --- a/test/lib/key-by.js +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { keyBy } from '../../src/lib/key-by.js' - -test('returns an empty map if passed an empty iterable', () => { - assert.deepEqual( - keyBy([], () => { - throw new Error('Should not be called') - }), - new Map() - ) -}) - -test('keys a list of items by a key function', () => { - const items = [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' }, - { id: 3, name: 'baz' }, - ] - const result = keyBy(items, (item) => item.id) - assert.deepEqual( - result, - new Map([ - [1, items[0]], - [2, items[1]], - [3, items[2]], - ]) - ) -}) - -test('duplicate keys', () => { - const items = [ - { id: 1, name: 'foo' }, - { id: 1, name: 'bar' }, - ] - assert.throws(() => keyBy(items, (item) => item.id)) -}) From 4b699ce52fefc4447f180cd61914940c0c02e8e4 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 30 Jun 2026 18:34:02 -0400 Subject: [PATCH 091/103] feat: use 16bit length prefix and check for size being too large --- src/discovery/remote-discovery.js | 37 ++++++++++++++++++++++-------- src/errors.js | 6 +++++ test/discovery/remote-discovery.js | 18 +++++++++++++-- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 6b4e13292..b2f49e765 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -4,10 +4,10 @@ import Hyperswarm from 'hyperswarm' import StartStopStateMachine from 'start-stop-state-machine' import { pEvent, TimeoutError as EventTimeoutError } from 'p-event' import sodium from 'sodium-universal' -import b4a from 'b4a' import { SwarmHandshake } from '../generated/handshake.js' import { ensureKnownError, + HandshakeTooLargeError, InvalidIdentityProofError, TimeoutError, UnableToReadHandshakeError, @@ -33,8 +33,11 @@ export const kTestOnlyHandleHyperswarmConnection = Symbol( 'testOnlyHandleHyperswarmConnection' ) -// 4 bytes 32bit unsigned int -export const LENGTH_BYTES_LENGTH = 4 +// 2 bytes 16bit unsigned int +export const LENGTH_BYTES_LENGTH = 2 + +// Max payload size: total packet (prefix + body) capped at UInt16 max, minus the 2-byte prefix itself +const MAX_HANDSHAKE_SIZE = 0xffff - 2 /** * @extends {TypedEmitter} @@ -321,7 +324,10 @@ async function readChunk(stream, length) { const remainingBytes = length - data.length const remainingData = await readChunk(stream, remainingBytes) - return b4a.concat([data, remainingData]) + const result = new Uint8Array(length) + result.set(data, 0) + result.set(remainingData, data.length) + return result } /** @@ -332,7 +338,14 @@ async function readChunk(stream, length) { export async function readHandshakeBuffer(stream) { const handshakeLengthBytes = await readChunk(stream, LENGTH_BYTES_LENGTH) - const handshakeLength = await b4a.readUInt32LE(handshakeLengthBytes) + const handshakeLength = new DataView( + handshakeLengthBytes.buffer, + handshakeLengthBytes.byteOffset + ).getUint16(0, true) + + if (handshakeLength > MAX_HANDSHAKE_SIZE) { + throw new HandshakeTooLargeError() + } const data = await readChunk(stream, handshakeLength) @@ -346,7 +359,7 @@ export async function readHandshakeBuffer(stream) { * @returns */ export function makeSwarmHandshake(handshakeHash, keyPair) { - const sig = b4a.allocUnsafe(64) + const sig = new Uint8Array(64) sodium.crypto_sign_detached(sig, handshakeHash, keyPair.secretKey) // Send stable public key + proof in a single message @@ -359,16 +372,20 @@ export function makeSwarmHandshake(handshakeHash, keyPair) { } /** - * Convert a buffer to its length prefixed version using Uin32LE + * Convert a buffer to its length prefixed version using UInt16LE * @param {Uint8Array} buffer * @return */ export function lengthPrefix(buffer) { - const fullBuffer = b4a.allocUnsafe(buffer.length + LENGTH_BYTES_LENGTH) + const fullBuffer = new Uint8Array(buffer.length + LENGTH_BYTES_LENGTH) - b4a.writeUInt32LE(fullBuffer, buffer.length) + new DataView(fullBuffer.buffer, fullBuffer.byteOffset).setUint16( + 0, + buffer.length, + true + ) - b4a.copy(buffer, fullBuffer, LENGTH_BYTES_LENGTH) + fullBuffer.set(buffer, LENGTH_BYTES_LENGTH) return fullBuffer } diff --git a/src/errors.js b/src/errors.js index 72bfdec36..912346bcb 100644 --- a/src/errors.js +++ b/src/errors.js @@ -543,6 +543,12 @@ export const UnableToReadHandshakeError = createErrorClass({ status: 500, }) +export const HandshakeTooLargeError = createErrorClass({ + code: 'HANDSHAKE_TOO_LARGE_ERROR', + message: 'Handshake packet is too large', + status: 400, +}) + export const InvalidInternetInviteURLError = createErrorClass({ code: 'INVALID_INTERNET_INVITE_URL_ERROR', message: 'Invalid internet invite URL', diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index db7d6a590..6a1860064 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -14,10 +14,11 @@ import { import { SwarmHandshake } from '../../src/generated/handshake.js' import { ensureKnownError, + HandshakeTooLargeError, UnableToReadHandshakeError, InvalidIdentityProofError, } from '../../src/errors.js' -import { Duplex, Transform } from 'streamx' +import { Duplex, Transform, Readable } from 'streamx' /** @import {OpenedNoiseStream} from '../../src/lib/noise-secret-stream-helpers.js'*/ @@ -144,7 +145,7 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => outboundStream.end() }) -test.only('RemoteDiscovery - Able to reconnect after disconnecting', async (t) => { +test('RemoteDiscovery - Able to reconnect after disconnecting', async (t) => { const testnet = await createTestnet(3) t.after(async () => { await testnet.destroy() @@ -237,6 +238,19 @@ test.only('RemoteDiscovery - Able to reconnect after disconnecting', async (t) = ) }) +test('RemoteDiscovery - readHandshakeBuffer throws HandshakeTooLargeError when length exceeds max', async () => { + const prefix = Buffer.alloc(2) + prefix.writeUInt16LE(0xffff, 0) + + const stream = Readable.from([prefix]) + + await assert.rejects( + readHandshakeBuffer(stream), + (err) => ensureKnownError(err).code === HandshakeTooLargeError.code, + 'should throw HandshakeTooLargeError when length exceeds max' + ) +}) + test('RemoteDiscovery - readChunk throws UnableToReadHandshakeError on empty stream', async () => { // Create a stream that closes immediately without providing data const emptyStream = new Transform({ From 15b0c5f1f24b111f3ee3c7c7f8c9410788ace902 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Thu, 2 Jul 2026 16:49:10 -0400 Subject: [PATCH 092/103] wip: InviteLinkJoiner API for refactoring joinProjectFromURL --- src/errors.js | 12 ++ src/invite/invite-link-joiner.js | 287 +++++++++++++++++++++++++++++++ test/invite-link-joiner.js | 149 ++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/invite/invite-link-joiner.js create mode 100644 test/invite-link-joiner.js diff --git a/src/errors.js b/src/errors.js index 912346bcb..f5e1b804b 100644 --- a/src/errors.js +++ b/src/errors.js @@ -622,6 +622,18 @@ export const InitialSyncFailedError = createErrorClass({ status: 409, }) +export const ExistingJoinRequestError = createErrorClass({ + code: 'EXISTING_JOIN_REQUEST_ERROR', + message: 'A join request for this invite already exists: {inviteId}', + status: 409, +}) + +export const JoinRequestNotFoundError = createErrorClass({ + code: 'JOIN_REQUEST_NOT_FOUND_ERROR', + message: 'Join request not found: {inviteId}', + status: 404, +}) + export const UntrustedRPCMethodError = createErrorClass({ code: 'UNTRUSTED_RPC_METHOD_ERROR', message: 'Got a restricted RPC method {type} from {peerId}', diff --git a/src/invite/invite-link-joiner.js b/src/invite/invite-link-joiner.js new file mode 100644 index 000000000..bd9a354b1 --- /dev/null +++ b/src/invite/invite-link-joiner.js @@ -0,0 +1,287 @@ +import { TypedEmitter } from 'tiny-typed-emitter' +import { pEvent } from 'p-event' +import { parseInviteURL } from './invite-urls.js' +import { + ExistingJoinRequestError, + InviteRedeemConnectionClosedError, + JoinRequestNotFoundError, +} from '../errors.js' + +/** @import { RemoteDiscovery } from '../discovery/remote-discovery.js' */ +/** @import { LocalPeers } from '../local-peers.js' */ +/** @import { InviteApi } from '../invite/invite-api.js' */ + +/** + * @typedef {'connecting' | 'connected' | 'accepted' | 'completed' | 'failed'} JoinRequestStatus + */ + +/** + * A join request initiated via an invite URL. Listen to `join-request-update` + * events for progress. + * + * @typedef {object} JoinRequest + * @property {string} inviteId Hex invite ID + * @property {string} swarmPublicKey Invitor's swarm public key + * @property {string} url Original invite URL + * @property {JoinRequestStatus} status Current status + * @property {Error|null} error Error if status is 'failed' + * @property {string|undefined} projectId Resolved project ID on completion + */ + +/** + * @typedef {object} JoinRequestUpdate + * @property {JoinRequestStatus} status + * @property {string} inviteId + * @property {Error|null} [error] + * @property {string|undefined} [projectId] + */ + +/** + * @typedef {object} ConnectPeerOptions + * @property {number} [timeout] + * @property {AbortSignal} [signal] + */ + +/** + * @typedef {object} RedeemInvite + * @property {Buffer} inviteId + */ + +/** + * @typedef {object} InviteLinkJoinerOptions + * @property {(swarmPublicKey: string, opts?: ConnectPeerOptions) => Promise} options.connectPeer Connect to a remote peer + * @property {(swarmPublicKey: string) => Promise} options.disconnectPeer Disconnect from a remote peer + * @property {(deviceId: string, redeem: RedeemInvite) => Promise} options.sendRedeemInviteOverInternet Send redeem request to a peer + * @property {Pick} options.inviteApi Invite API (on + accept only) + * @property {number} [options.defaultTimeout] Default timeout in ms for peer connection (default: 60_000) + */ + +/** + * @typedef {object} InviteLinkJoinerEvents + * @property {(update: JoinRequestUpdate) => void} join-request-update + */ + +/** + * @typedef {object} PendingJoinRequest + * @property {AbortController} abortController + * @property {JoinRequest} joinRequest + */ + +/** + * @extends {TypedEmitter} + */ +export class InviteLinkJoiner extends TypedEmitter { + /** @type {InviteLinkJoinerOptions['connectPeer']} */ + #connectPeer + /** @type {InviteLinkJoinerOptions['disconnectPeer']} */ + #disconnectPeer + /** @type {InviteLinkJoinerOptions['sendRedeemInviteOverInternet']} */ + #sendRedeemInviteOverInternet + /** @type {InviteLinkJoinerOptions['inviteApi']} */ + #inviteApi + /** @type {number} */ + #defaultTimeout + /** @type {Map} */ + #pending = new Map() + + /** + * @param {InviteLinkJoinerOptions} options + */ + constructor({ + connectPeer, + disconnectPeer, + sendRedeemInviteOverInternet, + inviteApi, + defaultTimeout = 60_000, + }) { + super() + this.#connectPeer = connectPeer + this.#disconnectPeer = disconnectPeer + this.#sendRedeemInviteOverInternet = sendRedeemInviteOverInternet + this.#inviteApi = inviteApi + this.#defaultTimeout = defaultTimeout + } + + /** + * Create and start a join request from an invite URL. + * + * @param {string} url Invite URL + * @param {object} [opts] + * @param {number} [opts.timeout] Connection timeout in ms + * @returns {JoinRequest} + */ + createJoinRequest(url, { timeout = this.#defaultTimeout } = {}) { + const parsed = parseInviteURL(url) + const inviteId = parsed.inviteIdString + + if (this.#pending.has(inviteId)) { + throw new ExistingJoinRequestError({ inviteId }) + } + + const redeemAbortController = new AbortController() + /** @type {JoinRequest} */ + const joinRequest = { + inviteId, + swarmPublicKey: parsed.swarmPublicKey, + url, + status: 'connecting', + error: null, + projectId: undefined, + } + this.#pending.set(inviteId, { + abortController: redeemAbortController, + joinRequest, + }) + + this.#emitUpdate(joinRequest) + + // Start the async flow (fire-and-forget) + this.#runJoinFlow(joinRequest, parsed, timeout, redeemAbortController) + + return joinRequest + } + + /** + * @param {JoinRequest} joinRequest + * @param {ReturnType} parsed + * @param {number} timeout + * @param {AbortController} redeemAbortController + */ + async #runJoinFlow(joinRequest, parsed, timeout, redeemAbortController) { + const { inviteIdString, swarmPublicKey } = parsed + const inviteIdBuffer = Buffer.from(inviteIdString, 'hex') + const signal = redeemAbortController.signal + + try { + const connection = await this.#connectPeer(swarmPublicKey, { + timeout, + signal, + }) + + signal.addEventListener( + 'abort', + () => this.#disconnectPeer(swarmPublicKey), + { once: true } + ) + + const onClose = pEvent(connection, 'close').then( + () => { + throw new InviteRedeemConnectionClosedError() + }, + // Handle `error` event on connection if there's sudden closes + (e) => { + throw new InviteRedeemConnectionClosedError({ cause: e }) + } + ) + // It's okay if this rejection never gets handled + onClose.catch(noop) + + // Connected + joinRequest.status = 'connected' + this.#emitUpdate(joinRequest) + + // Use the identity key from the handshake, not the swarm key from the URL + const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') + + const onInvited = pEvent(this.#inviteApi, 'invite-received', { + filter: (invite) => invite.invitorDeviceId === identityPublicKeyHex, + signal, + }) + + // Race: wait for invite vs connection close + const [invite] = await Promise.race([ + Promise.all([ + onInvited, + this.#sendRedeemInviteOverInternet(identityPublicKeyHex, { + inviteId: inviteIdBuffer, + }), + ]), + onClose, + ]) + + // Accepted + joinRequest.status = 'accepted' + this.#emitUpdate(joinRequest) + + const projectId = await this.#inviteApi.accept( + /** @type {{ inviteId: string }} */ (invite) + ) + + // Completed + joinRequest.status = 'completed' + joinRequest.projectId = projectId + this.#emitUpdate(joinRequest) + + connection.end() + } catch (e) { + // Failed + joinRequest.status = 'failed' + joinRequest.error = /** @type {Error} */ (e) + this.#emitUpdate(joinRequest) + + try { + await this.#disconnectPeer(swarmPublicKey) + } catch { + // ignore disconnect errors + } + } finally { + this.#pending.delete(inviteIdString) + } + } + + /** + * @param {JoinRequest} joinRequest + */ + #emitUpdate(joinRequest) { + /** @type {JoinRequestUpdate} */ + const update = { + status: joinRequest.status, + inviteId: joinRequest.inviteId, + error: joinRequest.error, + projectId: joinRequest.projectId, + } + this.emit('join-request-update', update) + } + + /** + * Get a join request by invite ID. + * + * @param {string} inviteId Hex invite ID + * @returns {JoinRequest} + * @throws {JoinRequestNotFoundError} + */ + getJoinRequestById(inviteId) { + const pending = this.#pending.get(inviteId) + if (!pending) { + throw new JoinRequestNotFoundError({ inviteId }) + } + return pending.joinRequest + } + + /** + * Get all active (in-flight) join requests. + * + * @returns {JoinRequest[]} + */ + getJoinRequests() { + return [...this.#pending.values()].map((p) => p.joinRequest) + } + + /** + * Cancel an in-flight join request. + * + * @param {string} inviteId Hex invite ID + * @returns {void} + */ + cancelJoinRequest(inviteId) { + const pending = this.#pending.get(inviteId) + if (!pending) { + throw new JoinRequestNotFoundError({ inviteId }) + } + pending.abortController.abort(new Error('Join project cancelled')) + this.#pending.delete(inviteId) + } +} + +/** @param {unknown} _x */ +function noop(_x) {} diff --git a/test/invite-link-joiner.js b/test/invite-link-joiner.js new file mode 100644 index 000000000..45f39abe2 --- /dev/null +++ b/test/invite-link-joiner.js @@ -0,0 +1,149 @@ +import test from 'node:test' +import { TypedEmitter } from 'tiny-typed-emitter' +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { Transform } from 'streamx' +import { pEvent } from 'p-event' + +import { InviteLinkJoiner } from '../src/invite/invite-link-joiner.js' +import { makeInviteURL } from '../src/invite/invite-urls.js' + +/** @import { RemoteAuthedNoiseStream } from '../src/discovery/remote-discovery.js' */ +/** @import { InviteApiEvents, Invite, InviteApi } from '../src/invite/invite-api.js' */ + +/** + * @param {Buffer} handshakePublicKey + * @returns {RemoteAuthedNoiseStream} + */ +function mockConnection(handshakePublicKey) { + const connection = /** @type {RemoteAuthedNoiseStream} */ ( + /** @type {unknown} */ (new Transform()) + ) + connection.handshakePublicKey = handshakePublicKey + connection.isTrusted = true + connection.remotePublicKey = randomBytes(32) + return connection +} + +/** + * @typedef {object} MockInviteApiOptions + * @property {string} [projectId] Project ID returned by accept (default: random) + */ + +/** + * @extends {InviteApi} + */ +class MockInviteApi extends TypedEmitter { + /** @type {string} */ + #projectId + + /** + * @param {MockInviteApiOptions} [opts] + */ + constructor({ projectId = randomBytes(20).toString('hex') } = {}) { + super() + this.#projectId = projectId + } + + /** + * @param {Pick} _invite + * @returns {Promise} + */ + async accept(_invite) { + return this.#projectId + } +} + +test('happy path: connect, redeem, accept, complete', async () => { + const swarmPublicKey = randomBytes(32) + const handshakePublicKey = randomBytes(32) + const inviteId = randomBytes(32) + const projectId = randomBytes(20).toString('hex') + const url = makeInviteURL({ + inviteIdString: inviteId.toString('hex'), + swarmPublicKey: swarmPublicKey.toString('hex'), + invitorName: 'invitor', + projectName: 'project', + expiresAt: Date.now() + 60_000, + }) + + const connection = mockConnection(handshakePublicKey) + const inviteApi = new MockInviteApi({ projectId }) + + /** @type {string[]} */ + const connectCalls = [] + /** @type {string[]} */ + const disconnectCalls = [] + /** @type {[string, { inviteId: Buffer }][]} */ + const redeemCalls = [] + + const joiner = new InviteLinkJoiner({ + connectPeer: async (swarmPublicKeyHex) => { + connectCalls.push(swarmPublicKeyHex) + return connection + }, + disconnectPeer: async (swarmPublicKeyHex) => { + disconnectCalls.push(swarmPublicKeyHex) + }, + sendRedeemInviteOverInternet: async (deviceId, redeem) => { + redeemCalls.push([deviceId, redeem]) + }, + inviteApi, + }) + + /** @type {import('../src/invite/invite-link-joiner.js').JoinRequestUpdate[]} */ + const updates = [] + joiner.on('join-request-update', (update) => updates.push(update)) + + const joinRequest = joiner.createJoinRequest(url) + + assert.equal(joinRequest.status, 'connecting') + assert.equal(joinRequest.inviteId, inviteId.toString('hex')) + + // Wait for 'connecting' update + await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + assert.equal(updates.length, 2) + assert.equal(updates[1].status, 'connecting') + + // Verify connectPeer was called + assert.deepEqual(connectCalls, [swarmPublicKey.toString('hex')]) + + // Status should now be 'connected' + await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + assert.equal(updates[1].status, 'connected') + + // Verify redeem was sent with correct identity key + assert.equal(redeemCalls.length, 1) + assert.equal( + redeemCalls[0][0], + handshakePublicKey.toString('hex'), + 'redeem sent to identity key, not swarm key' + ) + assert.ok( + redeemCalls[0][1].inviteId.equals(inviteId), + 'redeem sent with correct inviteId' + ) + + // Simulate the inviter sending back the invite + inviteApi.emit('invite-received', { + invitorDeviceId: handshakePublicKey.toString('hex'), + inviteId: inviteId.toString('hex'), + }) + + // Wait for 'accepted' update + await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + assert.equal(updates[2].status, 'accepted') + + // Wait for 'completed' update + await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + assert.equal(updates[3].status, 'completed') + assert.equal(updates[3].projectId, projectId) + + // Verify join request is removed from pending + assert.throws(() => joiner.getJoinRequestById(inviteId.toString('hex')), { + code: 'JOIN_REQUEST_NOT_FOUND_ERROR', + }) + + // Close the connection + connection.end() +}) From d71cea014053135bf95ad4645b31e960407dd772 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 14 Jul 2026 16:27:06 -0400 Subject: [PATCH 093/103] test: fix tests for invite joiner --- test/invite-link-joiner.js | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/test/invite-link-joiner.js b/test/invite-link-joiner.js index 45f39abe2..d60dd6a3e 100644 --- a/test/invite-link-joiner.js +++ b/test/invite-link-joiner.js @@ -9,7 +9,7 @@ import { InviteLinkJoiner } from '../src/invite/invite-link-joiner.js' import { makeInviteURL } from '../src/invite/invite-urls.js' /** @import { RemoteAuthedNoiseStream } from '../src/discovery/remote-discovery.js' */ -/** @import { InviteApiEvents, Invite, InviteApi } from '../src/invite/invite-api.js' */ +/** @import { Invite, InviteApi } from '../src/invite/invite-api.js' */ /** * @param {Buffer} handshakePublicKey @@ -30,9 +30,6 @@ function mockConnection(handshakePublicKey) { * @property {string} [projectId] Project ID returned by accept (default: random) */ -/** - * @extends {InviteApi} - */ class MockInviteApi extends TypedEmitter { /** @type {string} */ #projectId @@ -88,28 +85,45 @@ test('happy path: connect, redeem, accept, complete', async () => { sendRedeemInviteOverInternet: async (deviceId, redeem) => { redeemCalls.push([deviceId, redeem]) }, - inviteApi, + inviteApi: /** @type {InviteApi} */ (/** @type {unknown} */ (inviteApi)), }) /** @type {import('../src/invite/invite-link-joiner.js').JoinRequestUpdate[]} */ const updates = [] joiner.on('join-request-update', (update) => updates.push(update)) + const onConnecting = pEvent(joiner, 'join-request-update', { + timeout: 1000, + filter: ({ status }) => status === 'connecting', + }) + const onConnected = pEvent(joiner, 'join-request-update', { + timeout: 1000, + filter: ({ status }) => status === 'connected', + }) + const onAccepted = pEvent(joiner, 'join-request-update', { + timeout: 1000, + filter: ({ status }) => status === 'accepted', + }) + const onCompleted = pEvent(joiner, 'join-request-update', { + timeout: 1000, + filter: ({ status }) => status === 'completed', + }) + const joinRequest = joiner.createJoinRequest(url) assert.equal(joinRequest.status, 'connecting') assert.equal(joinRequest.inviteId, inviteId.toString('hex')) - // Wait for 'connecting' update - await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + // Wait for 'connecting' update if we dont have it yet + await onConnecting assert.equal(updates.length, 2) - assert.equal(updates[1].status, 'connecting') + assert.equal(updates[0].status, 'connecting') // Verify connectPeer was called assert.deepEqual(connectCalls, [swarmPublicKey.toString('hex')]) // Status should now be 'connected' - await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + await onConnected assert.equal(updates[1].status, 'connected') // Verify redeem was sent with correct identity key @@ -131,11 +145,11 @@ test('happy path: connect, redeem, accept, complete', async () => { }) // Wait for 'accepted' update - await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + await onAccepted assert.equal(updates[2].status, 'accepted') // Wait for 'completed' update - await pEvent(joiner, 'join-request-update', { timeout: 1000 }) + await onCompleted assert.equal(updates[3].status, 'completed') assert.equal(updates[3].projectId, projectId) From 3bccac6069541ee9b5e73e49e16860ea6e5efb82 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 11:40:53 -0400 Subject: [PATCH 094/103] feat: replace old invite link API with new one --- docs/guides/invite-over-internet.md | 103 ++++++++++++---- src/invite/invite-link-joiner.js | 6 +- src/mapeo-manager.js | 152 ++++++----------------- test-e2e/invite-over-internet.js | 183 ++++++++++++++++++++++------ 4 files changed, 261 insertions(+), 183 deletions(-) diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md index 64c7255f2..084f7e6af 100644 --- a/docs/guides/invite-over-internet.md +++ b/docs/guides/invite-over-internet.md @@ -1,4 +1,4 @@ -# Inviting New Mebers Over the Internet +# Inviting New Members Over the Internet ## Concepts @@ -7,15 +7,12 @@ - Invitor: Set up project - Invitor: create invite link - Invitor: send link out of bounds -- Invitor: wait for invite-link-join-request +- Invitor: wait for `invite-link-join-request` - Invitee: get Invitor name and project name from the link - Invitee: use invite link to connect and join - - On accept gets a regular project invite and auto accepts/joins it - - resolves to new project id - - rejects if you can't connect - - rejects if you get denied - - rejects if you disconnect part way through - - rejects if you cancel the join + - Creates a join request and listens to `join-request-update` events for progress + - On `completed` the join request contains the resolved project ID + - On `failed` the join request contains the error - Invitor: Get `invite-link-join-request` - Invitor: Accept invite link request which sends them an invite and follows the usual flow - **OR** Invitor: Deny invite link request which disconnects and errors them out @@ -69,28 +66,82 @@ const { invitorName, projectName, expiresAt, - inviteIdString + inviteIdString, } = parseInviteURL(url) -manager.on('invite-link-join-connected', (url) => { - // Connected to the invitor and waiting to join or get denied - // Show waiting for accept screen +// Listen for join progress updates +// update.status: 'connecting' | 'connected' | 'accepted' | 'completed' | 'failed' +// update.projectId is set on 'completed' +// update.error is set on 'failed' +manager.inviteLinks.on('join-request-update', (update) => { + switch (update.status) { + case 'connecting': + // Showing "waiting to connect" screen + break + case 'connected': + // Connected to the invitor and waiting for accept/deny + // Show "waiting for accept" screen + break + case 'accepted': + // Invitor has accepted, initial sync in progress + // Show "joining project" screen + break + case 'completed': + // Join succeeded — update.projectId has the project public ID + break + case 'failed': + // Join failed — update.error has the reason + break + } }) -manager.on('invite-link-join-accepted', (url) => { - // Invitor has accepted us and we're now joining - // Show now joining screen -}) +// Start the join (fire-and-forget, returns a JoinRequest synchronously) +const { + inviteId, + swarmPublicKey, + url: originalUrl, + status, // 'connecting' | 'connected' | 'accepted' | 'completed' | 'failed' + error, // Error | null — set on 'failed' + projectId, // string | undefined — set on 'completed' +} = manager.inviteLinks.createJoinRequest(url) +// ExistingJoinRequestError — already joining with this invite ID + +// Cancel the join at any time (use inviteId from the URL or joinRequest) +manager.inviteLinks.cancelJoinRequest(inviteIdString) +// JoinRequestNotFoundError — no in-flight join for this invite ID + +// You can also list all in-flight join requests +const requests = manager.inviteLinks.getJoinRequests() +``` -// Connect to the invitor, send request, join project with initial sync -// Show Waiting to connect screen until we get the connected event or react to the errors -// Once resolved show joined screen -const projectId = await manager.joinProjectFromLink(url) -// InviteRedeemConnectionClosedError => Connection error page -// UnknownInviteIDError => Invite has expried page -// InviteDeniedByInviterError => Request denied page +### Awaiting Join Completion -await manager.cancelJoinProjectFromLink(url) -// InvalidInternetInviteURLError -``` +For a simpler promise-based flow, use `p-event` to wait for the terminal state: +```JavaScript +import {pEvent} from 'p-event' + +const {inviteIdString} = parseInviteURL(url) + +// Wait for completed or failed status +const update = await pEvent(manager.inviteLinks, 'join-request-update', { + timeout: 60_000, + filter: (u) => + u.inviteId === inviteIdString && + (u.status === 'completed' || u.status === 'failed'), +}) + +if (update.status === 'completed') { + const projectId = update.projectId + // joined! +} else { + throw update.error + // InviteRedeemConnectionClosedError — connection lost + // UnknownInviteIDError — invite expired or invalid + // InviteDeniedByInviterError — invitor denied the request + // JoinProjectCancelledError — cancelled by invitee +} + +// Start the join (must be set up before awaiting) +manager.inviteLinks.createJoinRequest(url) +``` diff --git a/src/invite/invite-link-joiner.js b/src/invite/invite-link-joiner.js index bd9a354b1..9fc934ad2 100644 --- a/src/invite/invite-link-joiner.js +++ b/src/invite/invite-link-joiner.js @@ -5,6 +5,7 @@ import { ExistingJoinRequestError, InviteRedeemConnectionClosedError, JoinRequestNotFoundError, + JoinProjectCancelledError, } from '../errors.js' /** @import { RemoteDiscovery } from '../discovery/remote-discovery.js' */ @@ -271,14 +272,15 @@ export class InviteLinkJoiner extends TypedEmitter { * Cancel an in-flight join request. * * @param {string} inviteId Hex invite ID + * @param {Error} [reason] Reason for cancellation. Defaults to a generic cancellation error. * @returns {void} */ - cancelJoinRequest(inviteId) { + cancelJoinRequest(inviteId, reason) { const pending = this.#pending.get(inviteId) if (!pending) { throw new JoinRequestNotFoundError({ inviteId }) } - pending.abortController.abort(new Error('Join project cancelled')) + pending.abortController.abort(reason ?? new JoinProjectCancelledError()) this.#pending.delete(inviteId) } } diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 35414f139..09a8468de 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -32,7 +32,6 @@ import { deNullify, getDeviceId, keyToId, - noop, projectIdToNonce, projectKeyToId, projectKeyToProjectInviteId, @@ -59,23 +58,19 @@ import { FailedToSetIsArchiveDeviceError, NotFoundError, ProjectExistsError, - InviteRedeemConnectionClosedError, InvalidMapShareReceiverError, - InviteDeniedByInviterError, - JoinProjectCancelledError, - InvalidInternetInviteURLError, InitialSyncFailedError, UnknownInviteIDRedeemAttemptError, RPCDisconnectBeforeAckError, UnknownInviteIDError, + InviteDeniedByInviterError, } from './errors.js' import { WebSocket } from 'ws' import { excludeKeys } from 'filter-obj' import { migrate } from './lib/drizzle-helpers.js' import { RemoteDiscovery } from './discovery/remote-discovery.js' -import { pEvent } from 'p-event' import { InviteLinksApi } from './invite/invite-links-api.js' -import { parseInviteURL } from './invite/invite-urls.js' +import { InviteLinkJoiner } from './invite/invite-link-joiner.js' import { kHandleRedeemInviteOverInternet } from './member-api.js' /** @import { DenyInviteOverInternet, MapShareExtension } from './generated/rpc.js' */ @@ -153,8 +148,6 @@ const RPC_FEATURES = [ * @property {(mapShare: MapShare) => void} map-share Emitted when a project has recieved a map share request * @property {(e: Error, mapShare: MapShareExtension) => void} map-share-error - Emitted when an incoming map share fails to be recieved due to formatting issues * @property {(projectId: string, deviceId: string, inviteId: string) => void} invite-link-join-request Emitted when an invite over the internet link has been redeemed, accept the deviceId to add them - * @property {(url: string) => void} invite-link-join-connected Emitted when we've connected to the invitor for an invite over the internet link - * @property {(url: string) => void} invite-link-join-accepted Emitted when the invitor has accepted our request to join * @property {(err: Error, deviceId: string, inviteId: string) => void} invite-link-join-request-error Emitted when an invite over the internet has failed to be redeemed */ @@ -176,6 +169,7 @@ export class MapeoManager extends TypedEmitter { #projectMigrationsFolder #deviceId #localPeers + #inviteLinkStore #inviteLinks #invite #fastify @@ -190,10 +184,6 @@ export class MapeoManager extends TypedEmitter { /** @type {Set>}*/ #pendingTrustedTimers = new Set() - // Maps invite ID (hex string) to AbortController for in-flight redeem attempts - /** @type {Map} */ - #outboundRedeemInvites = new Map() - /** * @param {Object} opts * @param {Buffer} opts.rootKey 16-bytes of random data that uniquely identify the device, used to derive a 32-byte master key, which is used to derive all the keypairs used for Mapeo @@ -358,22 +348,41 @@ export class MapeoManager extends TypedEmitter { }) this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) - this.#inviteLinks = new InviteLinksApi(this.#db, (shouldListen) => { + this.#inviteLinkStore = new InviteLinksApi(this.#db, (shouldListen) => { if (shouldListen) return this.#remoteDiscovery.start() else return this.#remoteDiscovery.stop() }) + + this.#inviteLinks = new InviteLinkJoiner({ + connectPeer: this.#remoteDiscovery.connectPeer.bind( + this.#remoteDiscovery + ), + disconnectPeer: this.#remoteDiscovery.disconnectPeer.bind( + this.#remoteDiscovery + ), + sendRedeemInviteOverInternet: + this.#localPeers.sendRedeemInviteOverInternet.bind(this.#localPeers), + inviteApi: this.#invite, + }) } get deviceId() { return this.#deviceId } + /** + * @returns {import('./invite/invite-link-joiner.js').InviteLinkJoiner} + */ + get inviteLinks() { + return this.#inviteLinks + } + /** * @returns {KeyPair} */ get #swarmIdentity() { return this.#keyManager.deriveSwarmIdentity( - new Date(this.#inviteLinks.getSeedTime()) + new Date(this.#inviteLinkStore.getSeedTime()) ) } @@ -716,7 +725,7 @@ export class MapeoManager extends TypedEmitter { sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, - inviteLinks: this.#inviteLinks, + inviteLinks: this.#inviteLinkStore, logger: this.#loggerBase, getMediaBaseUrl: this.#getMediaBaseUrl.bind(this), isArchiveDevice, @@ -817,100 +826,6 @@ export class MapeoManager extends TypedEmitter { return result } - /** - * Attempt to join a project over the internet - * @param {string} url - * @param {object} [options] - * @param {number} [options.timeout] - * @returns {Promise} - */ - async joinProjectFromLink(url, { timeout = 60_000 } = {}) { - const { swarmPublicKey, inviteIdString } = parseInviteURL(url) - const inviteId = Buffer.from(inviteIdString, 'hex') - - const redeemAbortController = new AbortController() - const signal = redeemAbortController.signal - this.#outboundRedeemInvites.set(inviteIdString, redeemAbortController) - - const connection = await this.#remoteDiscovery.connectPeer(swarmPublicKey, { - timeout, - signal: redeemAbortController.signal, - }) - - signal.addEventListener( - 'abort', - () => this.#remoteDiscovery.disconnectPeer(swarmPublicKey), - { once: true } - ) - - const onClose = pEvent(connection, 'close').then( - () => { - throw new InviteRedeemConnectionClosedError() - }, - // Handle `error` event on connection if there's sudden closes - (e) => { - throw new InviteRedeemConnectionClosedError({ cause: e }) - } - ) - // It's okay if this rejection never gets handled - onClose.catch(noop) - - this.emit('invite-link-join-connected', url) - - try { - // Use the identity key from the handshake, not the swarm key from the URL - const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') - - const onInvited = pEvent(this.#invite, 'invite-received', { - filter: (invite) => invite.invitorDeviceId === identityPublicKeyHex, - signal, - }) - - // This is to make sure all rejectable promises are listend to synchronously - // Else we get a PromiseRejectionHandledWarning in the tests - const [invite] = await Promise.race([ - Promise.all([ - onInvited, - this.#localPeers.sendRedeemInviteOverInternet(identityPublicKeyHex, { - inviteId, - }), - ]), - onClose, - ]) - - this.emit('invite-link-join-accepted', url) - - const projectId = await this.#invite.accept(invite) - - return projectId - } catch (e) { - // Disconnect from them on error - await this.#remoteDiscovery.disconnectPeer(swarmPublicKey) - throw e - } finally { - this.#outboundRedeemInvites.delete(inviteIdString) - connection.end() - } - } - - /** - * Cancel an in-flight join project attempt initiated via joinProjectFromLink. - * If no matching redeem attempt is found, nothing happens. - * - * @param {string} url - The invite URL - * @returns {Promise} - */ - async cancelJoinProjectFromLink(url) { - const { inviteIdString } = parseInviteURL(url) - const ac = this.#outboundRedeemInvites.get(inviteIdString) - if (ac) { - ac.abort(new JoinProjectCancelledError()) - this.#outboundRedeemInvites.delete(inviteIdString) - } else { - throw new InvalidInternetInviteURLError() - } - } - /** * Add a project to this device. After adding a project the client should * await `project.$waitForInitialSync()` to ensure that the device has @@ -1368,14 +1283,19 @@ export class MapeoManager extends TypedEmitter { inviteIdString.slice(0, 7), peerId ) - const ac = this.#outboundRedeemInvites.get(inviteIdString) - if (ac) { + try { const err = reason === 'unknown_invite_id' ? new UnknownInviteIDError() : new InviteDeniedByInviterError({ reason }) - ac.abort(err) - this.#outboundRedeemInvites.delete(inviteIdString) + this.#inviteLinks.cancelJoinRequest(inviteIdString, err) + } catch (e) { + this.#l.log( + 'No pending join request for denied invite %S from %S, error: %s', + inviteIdString.slice(0, 7), + peerId, + ensureError(e).message + ) } } @@ -1386,7 +1306,7 @@ export class MapeoManager extends TypedEmitter { */ async #handleRedeemInviteOverInternet(peerId, inviteId) { const inviteIdString = inviteId.toString('hex') - const invite = await this.#inviteLinks.getById(inviteIdString) + const invite = await this.#inviteLinkStore.getById(inviteIdString) if (!invite) { try { @@ -1446,7 +1366,7 @@ export class MapeoManager extends TypedEmitter { for (const timer of this.#pendingTrustedTimers) { clearTimeout(timer) } - await this.#inviteLinks.close() + await this.#inviteLinkStore.close() await this.#remoteDiscovery.close() // This added for workers PR // await this.#projectSettingsIndexWriter.close() diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js index 5c228e3cc..428a2d019 100644 --- a/test-e2e/invite-over-internet.js +++ b/test-e2e/invite-over-internet.js @@ -16,6 +16,7 @@ import { ensureKnownError, InviteNotYetRedeemedError, InviteDeniedByInviterError, + InviteAbortedError, JoinProjectCancelledError, UnknownInviteIDError, } from '../src/errors.js' @@ -24,6 +25,28 @@ import { temporaryDirectory } from 'tempy' import { kWaitForInitialSyncWithPeer } from '../src/sync/sync-api.js' import { LocalPeers } from '../src/local-peers.js' +/** + * Await a join request update for a given inviteId until it reaches a terminal + * state. Resolves with the update on 'completed', rejects on 'failed'. + * + * @param {import('../src/invite/invite-link-joiner.js').InviteLinkJoiner} joiner + * @param {string} inviteId + * @param {{ timeout?: number }} [opts] + * @returns {Promise} + */ +function awaitJoinComplete(joiner, inviteId, opts = {}) { + const { timeout } = opts + return pEvent(joiner, 'join-request-update', { + timeout, + filter: (update) => + update.inviteId === inviteId && + (update.status === 'completed' || update.status === 'failed'), + }).then((update) => { + if (update.status === 'completed') return update + throw update.error + }) +} + test('invite over internet and join from URL', async (t) => { const managers = await createManagers(2, t, 'device_type_unspecified', { useTestnet: true, @@ -41,6 +64,8 @@ test('invite over internet and join from URL', async (t) => { roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, rejectionEvents: ['invite-link-join-request-error'], @@ -49,15 +74,21 @@ test('invite over internet and join from URL', async (t) => { const abortOnFailedJoin = new AbortController() - const onConnected = pEvent(invitee, 'invite-link-join-connected', { + const onConnected = pEvent(invitee.inviteLinks, 'join-request-update', { signal: abortOnFailedJoin.signal, timeout: 5000, + filter: (update) => + update.inviteId === inviteId && update.status === 'connected', + }) + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, }) - const onInvited = invitee.joinProjectFromLink(url) + onJoinComplete.catch((e) => abortOnFailedJoin.abort(e)) - onInvited.catch((e) => abortOnFailedJoin.abort(e)) + invitee.inviteLinks.createJoinRequest(url) - const [connectedURL, [invitedProjectId, deviceId, inviteId]] = + const [connectedUpdate, [invitedProjectId, deviceId, redeemInviteId]] = await Promise.all([ onConnected, /** @type {[string, string, string]} */ ( @@ -65,26 +96,28 @@ test('invite over internet and join from URL', async (t) => { ), ]) - assert.equal(connectedURL, url) + assert.equal(connectedUpdate.inviteId, inviteId) assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) - const onAccepted = pEvent(invitee, 'invite-link-join-accepted', { + const onAccepted = pEvent(invitee.inviteLinks, 'join-request-update', { signal: abortOnFailedJoin.signal, timeout: 5000, + filter: (update) => + update.inviteId === inviteId && update.status === 'accepted', }) // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji - const [acceptedURL, reason] = await Promise.all([ + const [acceptedUpdate, reason] = await Promise.all([ onAccepted, - project.$member.acceptInviteLinkRequest(inviteId, deviceId), + project.$member.acceptInviteLinkRequest(redeemInviteId, deviceId), ]) - assert.equal(acceptedURL, url) + assert.equal(acceptedUpdate.inviteId, inviteId) assert.equal(reason, InviteResponse_Decision.ACCEPT) - const gotProjectId = await onInvited + const { projectId: gotProjectId } = await onJoinComplete assert.equal(gotProjectId, projectId, 'joined expected project') @@ -147,15 +180,21 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { 'Pending internet invites loaded on reload' ) + const { inviteIdString: inviteId } = parseInviteURL(url) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, rejectionEvents: ['invite-link-join-request-error'], timeout: 5000, }) - const onInvited = invitee.joinProjectFromLink(url) + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) - const [invitedProjectId, deviceId, inviteId] = + const [invitedProjectId, deviceId, redeemInviteId] = /** @type {[string, string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -166,13 +205,13 @@ test('invite over internet, close, reopen, and join from URL', async (t) => { // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji const reason = await project.$member.acceptInviteLinkRequest( - inviteId, + redeemInviteId, deviceId ) assert.equal(reason, InviteResponse_Decision.ACCEPT) - const gotProjectId = await onInvited + const { projectId: gotProjectId } = await onJoinComplete assert.equal(gotProjectId, projectId, 'joined expected project') }) @@ -193,6 +232,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) // First invitee joins const onFirstInviteRedeemAttempt = pEvent( @@ -200,7 +240,15 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onFirstInvited = invitee1.joinProjectFromLink(url) + const onFirstJoinComplete = awaitJoinComplete( + invitee1.inviteLinks, + inviteId, + { + timeout: 5000, + } + ) + + invitee1.inviteLinks.createJoinRequest(url) const [firstProjectId, firstDeviceId, firstInviteId] = /** @type {[String, string, string]} */ ( @@ -213,7 +261,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { firstDeviceId ) assert.equal(firstReason, InviteResponse_Decision.ACCEPT) - await onFirstInvited + await onFirstJoinComplete // Second invitee joins via the same URL const onSecondInviteRedeemAttempt = pEvent( @@ -221,7 +269,15 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { 'invite-link-join-request', { multiArgs: true, timeout: 5000 } ) - const onSecondInvited = invitee2.joinProjectFromLink(url) + const onSecondJoinComplete = awaitJoinComplete( + invitee2.inviteLinks, + inviteId, + { + timeout: 5000, + } + ) + + invitee2.inviteLinks.createJoinRequest(url) const [secondProjectId, secondDeviceId, secondInviteId] = /** @type {[String, string, string]} */ ( @@ -239,7 +295,7 @@ test('invite over internet can be redeemed by multiple peers', async (t) => { secondDeviceId ) assert.equal(secondReason, InviteResponse_Decision.ACCEPT) - await onSecondInvited + await onSecondJoinComplete // Verify invite is still pending for future redeemers const pending = await project.$member.listInviteLinks() @@ -272,11 +328,13 @@ test('invite over internet errors if invitor deviceID is invalid', async (t) => ...parsed, swarmPublicKey: randomBytes(32).toString('hex'), }) + const { inviteIdString: inviteId } = parsed // Try to join with invalid device ID - should fail immediately // The invitee won't be able to connect to the non-existent device + invitee.inviteLinks.createJoinRequest(modifiedUrl, { timeout: 1000 }) await assert.rejects( - invitee.joinProjectFromLink(modifiedUrl, { timeout: 1000 }), + awaitJoinComplete(invitee.inviteLinks, inviteId, { timeout: 2000 }), (err) => ensureKnownError(err).code === TimeoutError.code ) }) @@ -298,13 +356,19 @@ test('invite over internet errors if inviter closes before accepting', async (t) roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, rejectionEvents: ['invite-link-join-request-error'], timeout: 50000, }) - const onInvited = invitee.joinProjectFromLink(url) + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 50000, + }) + + invitee.inviteLinks.createJoinRequest(url) const [invitedProjectId, deviceId, attemptedRedeemId] = /** @type {[string, string, string]} */ ( @@ -317,7 +381,7 @@ test('invite over internet errors if inviter closes before accepting', async (t) await Promise.all([ // The invitee's join should fail because the invitor disconnected assert.rejects( - onInvited, + onJoinComplete, (err) => ensureKnownError(err).code === InviteRedeemConnectionClosedError.code ), @@ -352,6 +416,7 @@ test('invite over internet can be denied by inviter', async (t) => { const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, @@ -359,9 +424,13 @@ test('invite over internet can be denied by inviter', async (t) => { timeout: 5000, }) - const onInvited = invitee.joinProjectFromLink(url) + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) - const [invitedProjectId, deviceId, inviteId] = + const [invitedProjectId, deviceId, redeemInviteId] = /** @type {[string, string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -372,10 +441,10 @@ test('invite over internet can be denied by inviter', async (t) => { // Deny the invite and wait for invitee's join to fail simultaneously await Promise.all([ assert.rejects( - onInvited, + onJoinComplete, (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code ), - project.$member.denyInviteLinkRequest(inviteId, deviceId), + project.$member.denyInviteLinkRequest(redeemInviteId, deviceId), ]) }) @@ -395,6 +464,7 @@ test('invite over internet can be cancelled by invitee', async (t) => { const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, @@ -402,29 +472,38 @@ test('invite over internet can be cancelled by invitee', async (t) => { timeout: 5000, }) - const onInvited = invitee.joinProjectFromLink(url) + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) - const [invitedProjectId, deviceId, inviteId] = + const [invitedProjectId, deviceId, redeemInviteId] = /** @type {[string, string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) assert.equal(invitedProjectId, projectId) assert.equal(deviceId, invitee.deviceId) + assert.equal( + redeemInviteId, + inviteId, + 'Redeem invite ID matches URL invite ID' + ) // Cancel the join and wait for invitee's join to fail simultaneously await Promise.all([ assert.rejects( - onInvited, + onJoinComplete, (err) => ensureKnownError(err).code === JoinProjectCancelledError.code ), - invitee.cancelJoinProjectFromLink(url), + invitee.inviteLinks.cancelJoinRequest(inviteId), ]) // Accepting after the invitee disconnected should also fail await assert.rejects( () => project.$member.acceptInviteLinkRequest(inviteId, deviceId), - (err) => ensureKnownError(err).code === InviteNotYetRedeemedError.code, + (err) => ensureKnownError(err).code === InviteAbortedError.code, 'Accepting after cancel causes an error' ) }) @@ -451,6 +530,7 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = ...parsed, inviteIdString: randomBytes(32).toString('hex'), }) + const { inviteIdString: modifiedInviteId } = parseInviteURL(modifiedUrl) // Expect the invitor to emit an error when the invalid invite is attempted const onError = /** @type {Promise<[Error, string, string]>} */ ( @@ -462,6 +542,16 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = ) ) + const onJoinComplete = awaitJoinComplete( + invitee.inviteLinks, + modifiedInviteId, + { + timeout: 5000, + } + ) + + invitee.inviteLinks.createJoinRequest(modifiedUrl) + // We need to listen to both promises at the same time to avoid warnings await Promise.all([ onError.then(([error, peerId]) => { @@ -475,7 +565,7 @@ test('invite over internet errors if invitee uses invalid inviteId', async (t) = }), assert.rejects( // Try to join with invalid invite ID - invitee.joinProjectFromLink(modifiedUrl), + onJoinComplete, (err) => ensureKnownError(err).code === UnknownInviteIDError.code ), ]) @@ -504,19 +594,24 @@ test('invite over internet can be cancelled before connection to non-existing pe ...parsed, swarmPublicKey: randomBytes(32).toString('hex'), }) + const { inviteIdString: inviteId } = parsed + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) // Start join — it will try to connect to the non-existing peer - const onInvited = invitee.joinProjectFromLink(modifiedUrl) + invitee.inviteLinks.createJoinRequest(modifiedUrl) // Give the connection attempt a moment to start, then cancel await new Promise((resolve) => setTimeout(resolve, 200)) await Promise.all([ assert.rejects( - onInvited, + onJoinComplete, (err) => ensureKnownError(err).code === JoinProjectCancelledError.code ), - invitee.cancelJoinProjectFromLink(modifiedUrl), + invitee.inviteLinks.cancelJoinRequest(inviteId), ]) }) @@ -543,6 +638,7 @@ test('invite over the internet removes project and removes member when failing t const url = await project.$member.createInviteLink({ roleId: MEMBER_ROLE_ID, }) + const { inviteIdString: inviteId } = parseInviteURL(url) const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { multiArgs: true, @@ -550,9 +646,13 @@ test('invite over the internet removes project and removes member when failing t timeout: 5000, }) - const onInvited = invitee.joinProjectFromLink(url) + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) - const [invitedProjectId, deviceId, inviteId] = + const [invitedProjectId, deviceId, redeemInviteId] = /** @type {[string, string, string]} */ ( /**@type unknown*/ (await onInviteRedeemAttempt) ) @@ -563,12 +663,12 @@ test('invite over the internet removes project and removes member when failing t // Show the user the device ID and their name and have them verify the invitee sees the same device ID // We should either take the first 4-8 bytes from the deviceID or derive something visual like emoji const onInviteeAccepted = project.$member.acceptInviteLinkRequest( - inviteId, + redeemInviteId, deviceId ) await Promise.all([ - assert.rejects(onInvited), + assert.rejects(onJoinComplete), assert.rejects(onInviteeAccepted), ]) @@ -586,7 +686,12 @@ test('invite over the internet removes project and removes member when failing t project.$member.acceptInviteLinkRequest(inviteId, deviceId) }) - const gotProjectId = await invitee.joinProjectFromLink(url) + invitee.inviteLinks.createJoinRequest(url) + const { projectId: gotProjectId } = await awaitJoinComplete( + invitee.inviteLinks, + inviteId, + { timeout: 5000 } + ) assert.equal(gotProjectId, projectId, 'Invited to project') }) From bffd48dd5a3632b2201bf08311cdb8d9d24d14e9 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 13:46:37 -0400 Subject: [PATCH 095/103] chore: fixes and cleanup for invite links api --- src/invite/invite-links-api.js | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/invite/invite-links-api.js b/src/invite/invite-links-api.js index ab6f88e18..39ebaa927 100644 --- a/src/invite/invite-links-api.js +++ b/src/invite/invite-links-api.js @@ -57,7 +57,6 @@ export class InviteLinksApiForProject { * @returns {Promise} */ async create(data) { - await this.#inviteLinksApi.ready() return this.#inviteLinksApi.create({ ...data, projectId: this.#projectId, @@ -70,7 +69,6 @@ export class InviteLinksApiForProject { * @returns {Promise} */ async getById(inviteId) { - await this.#inviteLinksApi.ready() const link = await this.#inviteLinksApi.getById(inviteId) if (!link) return link if (link.projectId !== this.#projectId) { @@ -84,7 +82,6 @@ export class InviteLinksApiForProject { * @returns {Promise} */ async getAll() { - await this.#inviteLinksApi.ready() return this.#inviteLinksApi.getAllForProject(this.#projectId) } @@ -94,7 +91,6 @@ export class InviteLinksApiForProject { * @returns {Promise} */ async delete(inviteId) { - await this.#inviteLinksApi.ready() return this.#inviteLinksApi.delete(inviteId) } @@ -103,7 +99,6 @@ export class InviteLinksApiForProject { * @returns {Promise} */ async deleteAll() { - await this.#inviteLinksApi.ready() return this.#inviteLinksApi.deleteAllFrom(this.#projectId) } } @@ -152,9 +147,8 @@ export class InviteLinksApi extends ReadyResource { .from(inviteLinksTable) .where(eq(inviteLinksTable.projectId, sql.placeholder('projectId'))) .prepare(), - getExpired: db - .select() - .from(inviteLinksTable) + deleteExpired: db + .delete(inviteLinksTable) .where( sql`${inviteLinksTable.expiresAt} < ${sql.placeholder('cutoff')}` ) @@ -183,18 +177,14 @@ export class InviteLinksApi extends ReadyResource { } /** - * Delete all invite links whose createdAt timestamp is older than 24 hours. + * Delete all invite links that have expired. + * Stop listening over internet if none are left. */ async #clearExpired() { const cutoff = Date.now() - const expired = this.#sql.getExpired.all({ cutoff }) - for (const row of expired) { - await this.#db - .delete(inviteLinksTable) - .where(eq(inviteLinksTable.inviteId, row.inviteId)) - } + const result = this.#sql.deleteExpired.run({ cutoff }) // Update the listen state once, after all deletions - if (expired.length > 0) { + if (result.changes > 0) { await this.#checkSetShouldListenOverInternet(false) } } @@ -235,7 +225,7 @@ export class InviteLinksApi extends ReadyResource { */ async #checkSetShouldListenOverInternet(direction) { const count = this.#sql.getAll.all().length - if (direction && count === 1) { + if (direction && count) { await this.#setShouldListenOverInternet(true) } else if (!direction && count === 0) { await this.#setShouldListenOverInternet(false) From 8ba1699a4b57e0ffcc635a97fd34459b5f69829b Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 14:47:48 -0400 Subject: [PATCH 096/103] fix: remote discovery should detect keypair rotation on swarm resume, respect force close param --- src/discovery/remote-discovery.js | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index b2f49e765..23e87e2f5 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -49,6 +49,8 @@ export class RemoteDiscovery extends TypedEmitter { #sm #identityKeypair #deriveSwarmIdentityKeypair + /** @type {KeyPair?}*/ + #lastKeyPair = null #swarmOpts /** @type {Set} */ #shouldTrustKeys = new Set() @@ -82,9 +84,24 @@ export class RemoteDiscovery extends TypedEmitter { } async #start() { + const keyPair = this.#deriveSwarmIdentityKeypair() + if (this.#swarm) { + if ( + !this.#lastKeyPair || + this.#lastKeyPair.publicKey.equals(keyPair.publicKey) + ) { + this.#l.log('Resuming swarm') + await this.#swarm.resume() + return + } else { + this.#l.log('Swarm key changed, destroying old swarm') + await this.#swarm.destroy() + } + } this.#l.log('Initializing swarm') + this.#lastKeyPair = keyPair const swarm = new Hyperswarm({ - keyPair: this.#deriveSwarmIdentityKeypair(), + keyPair, maxPeers: 16, ...this.#swarmOpts, }) @@ -114,11 +131,18 @@ export class RemoteDiscovery extends TypedEmitter { } /** - * @param {object} [_opts] + * @param {object} [opts] + * @param {boolean} [opts.force=false] Force-close open connections */ - async #stop(_opts) { + async #stop(opts) { this.#l.log('Suspending swarm') await this.#swarm?.suspend() + if (opts?.force && this.#connections.size) { + this.#l.log('Force closing existing connections') + for (const connection of this.#connections) { + connection.end() + } + } } async close() { From 104c72edb583339708d27447116ab471aa0b791d Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 15:19:16 -0400 Subject: [PATCH 097/103] fix: leave peer on remote discovery connect peer timeout --- src/discovery/remote-discovery.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index 23e87e2f5..a005e55ba 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -244,6 +244,8 @@ export class RemoteDiscovery extends TypedEmitter { return socket } catch (e) { + // We should stop trying to connect if we time out + swarm.leavePeer(noisePublicKey) if (e instanceof EventTimeoutError) { throw new TimeoutError('Timed out waiting for peer') } From 0cd379a321635b508165d4140249a2f462b08996 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 15:44:42 -0400 Subject: [PATCH 098/103] chore: More robust allow list for sending RPC --- src/discovery/remote-discovery.js | 2 +- src/local-peers.js | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index a005e55ba..bc8f8070c 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -49,7 +49,7 @@ export class RemoteDiscovery extends TypedEmitter { #sm #identityKeypair #deriveSwarmIdentityKeypair - /** @type {KeyPair?}*/ + /** @type {Keypair?} */ #lastKeyPair = null #swarmOpts /** @type {Set} */ diff --git a/src/local-peers.js b/src/local-peers.js index b5b160e77..1854d826a 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -88,15 +88,25 @@ const MESSAGE_TYPES = { const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) /** + * RPC methods allowed to be _received_ from an untrusted peer. * @type {Set} */ -const ALLOWED_UNTRUSTED_RPC = new Set([ +const ALLOWED_RECEIVE_UNTRUSTED_RPC = new Set([ 'DeviceInfo', 'RedeemInviteOverInternet', 'RedeemInviteOverInternetAck', 'DenyInviteOverInternetAck', ]) +/** + * RPC methods allowed to be _sent_ to an untrusted peer. + * @type {Set} + */ +const ALLOWED_SEND_UNTRUSTED_RPC = new Set([ + 'RedeemInviteOverInternet', + 'DenyInviteOverInternet', +]) + export const kTestOnlySendRawInvite = Symbol('testOnlySendRawInvite') /** @@ -675,6 +685,7 @@ export class LocalPeers extends TypedEmitter { async sendDenyInviteOverInternet(deviceId, deny) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('DenyInviteOverInternet', peer) await peer.sendDenyInviteOverInternet(deny) } @@ -687,6 +698,7 @@ export class LocalPeers extends TypedEmitter { async sendRedeemInviteOverInternet(deviceId, redeem) { await this.#waitForPendingConnections() const peer = await this.#getPeerByDeviceId(deviceId) + checkTrusted('RedeemInviteOverInternet', peer) await peer.sendRedeemInviteOverInternet(redeem) } @@ -947,7 +959,7 @@ export class LocalPeers extends TypedEmitter { if (!peer) return // TODO: report error - this should not happen // If the peer isn't trusted, ignore anything not allowed // Allow acknowledge messages by default - if (!peer.isTrusted && !ALLOWED_UNTRUSTED_RPC.has(type)) { + if (!peer.isTrusted && !ALLOWED_RECEIVE_UNTRUSTED_RPC.has(type)) { throw new UntrustedRPCMethodError({ type, peerId: peer.id, @@ -1264,11 +1276,12 @@ export function peerIdFromNoise(stream) { } /** - * @param {string} type + * @param {keyof typeof MESSAGE_TYPES} type * @param {Peer} peer */ function checkTrusted(type, peer) { if (peer.isTrusted) return + if (ALLOWED_SEND_UNTRUSTED_RPC.has(type)) return throw new UntrustedRPCMethodError({ type, peerId: peer.id, From 4eafa5cbeea66035778abd8973f471612b49ddab Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Wed, 15 Jul 2026 15:49:50 -0400 Subject: [PATCH 099/103] fix: Handle peer redeeming multiple invites over internet and disconnecting --- src/member-api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index 5ec24dac2..8c4f76716 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -313,7 +313,6 @@ export class MemberApi extends TypedEmitter { for (const deviceIds of this.#redeemedInvites.values()) { if (deviceIds.has(peer.deviceId)) { deviceIds.delete(peer.deviceId) - break } } } From e4fa9c20368f1dbb15e5b556f068e21643ea844a Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 21 Jul 2026 11:53:08 -0400 Subject: [PATCH 100/103] fix: simplify and stabilize member api --- src/mapeo-manager.js | 5 +---- src/member-api.js | 40 +++++++++++----------------------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 09a8468de..3c6068059 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -1328,10 +1328,7 @@ export class MapeoManager extends TypedEmitter { const project = await this.getProject(projectId) - await project.$member[kHandleRedeemInviteOverInternet]( - peerId, - inviteIdString - ) + await project.$member[kHandleRedeemInviteOverInternet](peerId, invite) this.emit('invite-link-join-request', projectId, peerId, inviteIdString) } diff --git a/src/member-api.js b/src/member-api.js index 8c4f76716..ca10f3303 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -38,7 +38,6 @@ import { RPCDisconnectBeforeAckError, InvalidInternetInviteURLError, InviteAlreadyRedeemedError, - UnknownInviteIDRedeemAttemptError, InviteNotYetRedeemedError, PeerDisconnectedSinceRedeemingInviteError, UnknownInviteIDError, @@ -320,26 +319,12 @@ export class MemberApi extends TypedEmitter { /** * Handle an incoming redeem attempt from the RPC layer. * @param {string} peerId - * @param {string} inviteIdString + * @param {InviteLinkRecord} invite */ - async [kHandleRedeemInviteOverInternet](peerId, inviteIdString) { + async [kHandleRedeemInviteOverInternet](peerId, invite) { + const inviteIdString = invite.inviteId this.#l.log('Got incoming invite redeem %S from %S', inviteIdString, peerId) - const invite = await this.#inviteLinks.getById(inviteIdString) - if (!invite) { - this.#l.log( - 'Incoming invite was invalid, disconnecting', - inviteIdString.slice(0, 7) - ) - - await this.denyInviteLinkRequest( - inviteIdString, - peerId, - DenyInviteOverInternet_DenyReason.unknown_invite_id - ) - throw new UnknownInviteIDRedeemAttemptError() - } - const redeemedSet = this.#redeemedInvites.get(inviteIdString) if (redeemedSet?.has(peerId)) { this.#l.log( @@ -351,17 +336,12 @@ export class MemberApi extends TypedEmitter { throw new InviteAlreadyRedeemedError() } - try { - if (!redeemedSet) { - this.#redeemedInvites.set(inviteIdString, new Set([peerId])) - } else { - redeemedSet.add(peerId) - } - return inviteIdString - } catch (e) { - await this.#disconnectFromPeer(peerId) - throw e + if (!redeemedSet) { + this.#redeemedInvites.set(inviteIdString, new Set([peerId])) + } else { + redeemedSet.add(peerId) } + return inviteIdString } /** @@ -908,7 +888,9 @@ export class MemberApi extends TypedEmitter { const memberInfo = { deviceId, role } try { - const deviceInfo = await this.#getDeviceInfo(deviceId) + const deviceInfo = await this.#getDeviceInfo(deviceId).catch( + () => undefined + ) memberInfo.name = deviceInfo?.name memberInfo.deviceType = deviceInfo?.deviceType From b395f4dc6bcc140cdbfc6f8eefb0ed751d0a9db2 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 21 Jul 2026 14:17:19 -0400 Subject: [PATCH 101/103] feat: Use AuthedNoiseStream for both local and remote discovery --- src/discovery/local-discovery.js | 29 ++++++++++++++++---------- src/discovery/remote-discovery.js | 22 +++++++++---------- src/invite/invite-link-joiner.js | 5 +++-- src/lib/noise-secret-stream-helpers.js | 16 +++++++++----- src/local-peers.js | 21 ++++++++----------- src/mapeo-manager.js | 10 +++++---- test/discovery/remote-discovery.js | 24 ++++++++++----------- test/invite-link-joiner.js | 14 ++++++------- 8 files changed, 77 insertions(+), 64 deletions(-) diff --git a/src/discovery/local-discovery.js b/src/discovery/local-discovery.js index ed79eb0fd..82bade835 100644 --- a/src/discovery/local-discovery.js +++ b/src/discovery/local-discovery.js @@ -11,10 +11,10 @@ import { Logger } from '../logger.js' import { ensureKnownError, getErrorCode } from '../errors.js' import { ServerNotListeningError } from '../errors.js' -/** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ +/** @import { OpenedNoiseStream, AuthedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ /** @typedef {{ publicKey: Buffer, secretKey: Buffer }} Keypair */ -/** @typedef {OpenedNoiseStream} OpenedNetNoiseStream */ +/** @typedef {AuthedNoiseStream & OpenedNoiseStream} AuthedNetNoiseStream */ /** @satisfies {import('node:net').ServerOpts | import('node:net').TcpNetConnectOpts} */ const TCP_KEEP_ALIVE_OPTIONS = { @@ -28,7 +28,7 @@ export const ERR_DUPLICATE = 'Duplicate connection' /** * @typedef {Object} DiscoveryEvents - * @property {(connection: OpenedNetNoiseStream) => void} connection + * @property {(connection: AuthedNetNoiseStream) => void} connection */ /** @@ -38,7 +38,7 @@ export class LocalDiscovery extends TypedEmitter { #identityKeypair #name = randomBytes(8).toString('hex') #server - /** @type {Map} */ + /** @type {Map} */ #noiseConnections = new Map() #sm #log @@ -166,15 +166,15 @@ export class LocalDiscovery extends TypedEmitter { secretStream.off('error', this.#handleSocketError) this.#handleNoiseStreamConnection( // We know the NoiseStream is open at this point, so we can coerce the type - /** @type {OpenedNetNoiseStream} */ + /** @type {OpenedNoiseStream} */ (secretStream) ) }) } /** - * @param {OpenedNetNoiseStream} existing - * @param {OpenedNetNoiseStream} keeping + * @param {AuthedNetNoiseStream} existing + * @param {OpenedNoiseStream} keeping * @returns {void} */ #handleConnectionSwap(existing, keeping) { @@ -199,7 +199,7 @@ export class LocalDiscovery extends TypedEmitter { } /** - * @param {OpenedNetNoiseStream} conn + * @param {OpenedNoiseStream} conn * @returns {void} */ #handleNoiseStreamConnection(conn) { @@ -259,16 +259,23 @@ export class LocalDiscovery extends TypedEmitter { return } - this.#noiseConnections.set(remoteId, conn) + // Mark local connections as authenticated and trusted before storing + // @ts-expect-error adding AuthedNoiseStream properties to OpenedNoiseStream + conn.authenticatedPublicKey = conn.remotePublicKey + // @ts-expect-error adding AuthedNoiseStream properties to OpenedNoiseStream + conn.isTrusted = true - conn.on('close', () => { + const authedConn = /** @type {AuthedNetNoiseStream} */ (conn) + this.#noiseConnections.set(remoteId, authedConn) + + authedConn.on('close', () => { this.#log('closed connection with %h', remotePublicKey) this.#noiseConnections.delete(remoteId) }) // No 'error' listeners attached to `conn` at this point, it's up to the // consumer to attach an 'error' listener to avoid uncaught errors. - this.emit('connection', conn) + this.emit('connection', authedConn) } /** diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index bc8f8070c..c76d3ab6a 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -16,11 +16,12 @@ import { import { openedNoiseSecretStream } from '../lib/noise-secret-stream-helpers.js' import pDefer from 'p-defer' -/** @import {OpenedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ +/** @import {OpenedNoiseStream, AuthedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ /** @import {Keypair} from './local-discovery.js' */ /** @import {Duplex, Readable} from "streamx" */ -/** @typedef {OpenedNoiseStream & {handshakePublicKey:Buffer, isTrusted: boolean}} RemoteAuthedNoiseStream */ +// Re-export for consumers that import from this module +/** @typedef {AuthedNoiseStream} RemoteAuthedNoiseStream */ /** * @typedef {Object} DiscoveryEvents @@ -54,9 +55,9 @@ export class RemoteDiscovery extends TypedEmitter { #swarmOpts /** @type {Set} */ #shouldTrustKeys = new Set() - /** @type {Set} */ + /** @type {Set} */ #connections = new Set() - /** @type {Map>}*/ + /** @type {Map>} */ #pendingHandshakes = new Map() /** @@ -167,8 +168,8 @@ export class RemoteDiscovery extends TypedEmitter { for (const connection of this.#connections) { if ( connection.remotePublicKey?.equals(noisePublicKey) || - ('handshakePublicKey' in connection && - connection.handshakePublicKey.equals(noisePublicKey)) + ('authenticatedPublicKey' in connection && + connection.authenticatedPublicKey.equals(noisePublicKey)) ) { this.#l.log('Disconnecting from peer %S', publicKey) connection.end() @@ -194,7 +195,7 @@ export class RemoteDiscovery extends TypedEmitter { // If the connection closed, try again if (opened.destroyed) return this.#findExistingPeer(noisePublicKey) // @ts-ignore Some connections might not be handshaked, wait for them to be - if (!existingConnection.handshakePublicKey) { + if (!existingConnection.authenticatedPublicKey) { const success = await this.#pendingHandshakes.get(existingConnection) if (!success) return this.#findExistingPeer(noisePublicKey) } @@ -297,10 +298,9 @@ export class RemoteDiscovery extends TypedEmitter { throw new InvalidIdentityProofError({ cause: e }) } - // @ts-ignore - socket.handshakePublicKey = msg.publicKey - // @ts-ignore - this.emit('connection', socket) + // @ts-expect-error adding AuthedNoiseStream properties + socket.authenticatedPublicKey = msg.publicKey + this.emit('connection', /** @type {AuthedNoiseStream} */ (socket)) this.#pendingHandshakes.delete(socket) pendingDefer.resolve(true) } catch (err) { diff --git a/src/invite/invite-link-joiner.js b/src/invite/invite-link-joiner.js index 9fc934ad2..fdf52b5f6 100644 --- a/src/invite/invite-link-joiner.js +++ b/src/invite/invite-link-joiner.js @@ -50,7 +50,7 @@ import { /** * @typedef {object} InviteLinkJoinerOptions - * @property {(swarmPublicKey: string, opts?: ConnectPeerOptions) => Promise} options.connectPeer Connect to a remote peer + * @property {(swarmPublicKey: string, opts?: ConnectPeerOptions) => Promise} options.connectPeer Connect to a remote peer * @property {(swarmPublicKey: string) => Promise} options.disconnectPeer Disconnect from a remote peer * @property {(deviceId: string, redeem: RedeemInvite) => Promise} options.sendRedeemInviteOverInternet Send redeem request to a peer * @property {Pick} options.inviteApi Invite API (on + accept only) @@ -182,7 +182,8 @@ export class InviteLinkJoiner extends TypedEmitter { this.#emitUpdate(joinRequest) // Use the identity key from the handshake, not the swarm key from the URL - const identityPublicKeyHex = connection.handshakePublicKey.toString('hex') + const identityPublicKeyHex = + connection.authenticatedPublicKey.toString('hex') const onInvited = pEvent(this.#inviteApi, 'invite-received', { filter: (invite) => invite.invitorDeviceId === identityPublicKeyHex, diff --git a/src/lib/noise-secret-stream-helpers.js b/src/lib/noise-secret-stream-helpers.js index de2f5c1d8..08070c666 100644 --- a/src/lib/noise-secret-stream-helpers.js +++ b/src/lib/noise-secret-stream-helpers.js @@ -1,13 +1,19 @@ /** @import { Duplex as NodeDuplex } from 'node:stream' */ /** @import { Duplex as StreamxDuplex } from 'streamx' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ -/** @import {RemoteAuthedNoiseStream} from "../discovery/remote-discovery.js" */ - /** * @internal * @typedef {NodeDuplex | StreamxDuplex} RawStream */ +/** + * A noise stream that has been authenticated with a stable public key and + * has an explicit trust flag. Both LocalDiscovery and RemoteDiscovery emit + * streams of this shape, so downstream code does not need runtime type checks. + * + * @typedef {OpenedNoiseStream & { authenticatedPublicKey: Buffer, isTrusted: boolean }} AuthedNoiseStream + */ + /** * @template {RawStream} [T=RawStream] * @typedef {NoiseSecretStream & { destroyed: true }} DestroyedNoiseStream @@ -29,12 +35,12 @@ * stream is opened) * * @template {RawStream} T - * @param {NoiseSecretStream|RemoteAuthedNoiseStream} stream - * @returns {Promise | DestroyedNoiseStream | RemoteAuthedNoiseStream>} + * @param {NoiseSecretStream|AuthedNoiseStream} stream + * @returns {Promise | DestroyedNoiseStream | AuthedNoiseStream>} */ export async function openedNoiseSecretStream(stream) { await stream.opened - return /** @type {OpenedNoiseStream | DestroyedNoiseStream | RemoteAuthedNoiseStream} */ ( + return /** @type {OpenedNoiseStream | DestroyedNoiseStream | AuthedNoiseStream} */ ( stream ) } diff --git a/src/local-peers.js b/src/local-peers.js index 7b2064aa1..e6a8f69d4 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -40,7 +40,7 @@ import { /** @import NoiseStream from '@hyperswarm/secret-stream' */ /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */ /** @import {DeferredPromise} from 'p-defer' */ -/** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ +/** @import {AuthedNoiseStream} from "./lib/noise-secret-stream-helpers.js" */ /** * @typedef {InviteAck|InviteCancelAck|InviteResponseAck|ProjectJoinDetailsAck} AckResponse @@ -757,11 +757,11 @@ export class LocalPeers extends TypedEmitter { /** * Connect to a peer over an existing NoiseSecretStream * - * @param {NoiseStream|RemoteAuthedNoiseStream} stream - * @param {boolean} [isTrusted] + * @param {NoiseStream|AuthedNoiseStream} stream + * @param {boolean} isTrusted * @returns {import('./types.js').ReplicationStream} */ - connect(stream, isTrusted = true) { + connect(stream, isTrusted) { const noiseStream = stream.noiseStream const outerStream = noiseStream.rawStream const protomux = @@ -816,7 +816,7 @@ export class LocalPeers extends TypedEmitter { } /** - * @param {Protomux} protomux + * @param {Protomux} protomux * @param {boolean} isTrusted * @param {() => void} done */ @@ -1262,17 +1262,14 @@ function chooseDevicePeer(devicePeers) { } /** - * @param {OpenedNoiseStream|RemoteAuthedNoiseStream} stream + * @param {AuthedNoiseStream|OpenedNoiseStream} stream */ export function peerIdFromNoise(stream) { const publicKey = - 'handshakePublicKey' in stream - ? stream.handshakePublicKey + 'authenticatedPublicKey' in stream + ? stream.authenticatedPublicKey : stream.remotePublicKey - - const peerId = keyToId(publicKey) - - return peerId + return keyToId(publicKey) } /** diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index b493af4c1..70df077f3 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -40,6 +40,7 @@ import { validateMapShareExtension, } from './utils.js' import { openedNoiseSecretStream } from './lib/noise-secret-stream-helpers.js' +/** @import {AuthedNoiseStream} from './lib/noise-secret-stream-helpers.js' */ import { omit } from './lib/omit.js' import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js' import BlobServerPlugin from './fastify-plugins/blobs.js' @@ -80,7 +81,6 @@ import { kHandleRedeemInviteOverInternet } from './member-api.js' /** @import { CoreStorage, KeyPair, Namespace } from './types.js' */ /** @import { DeviceInfoParam, ProjectInfo } from './schema/client.js' */ /** @import { ProjectSettings, ProjectSettingsValue } from '@comapeo/schema' */ -/** @import {RemoteAuthedNoiseStream} from "./discovery/remote-discovery.js" */ /** @typedef {SetNonNullable} ValidatedProjectKeys */ /** @typedef {Pick & { projectName: string, projectColor?: string, projectDescription?: string, sendStats?: boolean, invitorWroteDeviceInfo? : boolean, leaveOnFail?: boolean }} ProjectToAddDetails */ @@ -420,10 +420,10 @@ export class MapeoManager extends TypedEmitter { } /** - * @param {NoiseSecretStream|RemoteAuthedNoiseStream} noiseStream + * @param {AuthedNoiseStream} noiseStream */ #replicate(noiseStream) { - const isTrusted = `isTrusted` in noiseStream ? noiseStream.isTrusted : true + const isTrusted = noiseStream.isTrusted const replicationStream = this.#localPeers.connect(noiseStream, isTrusted) noiseStream.resume() @@ -440,7 +440,9 @@ export class MapeoManager extends TypedEmitter { features: RPC_FEATURES, } - const peerId = peerIdFromNoise(openedNoiseStream) + const peerId = peerIdFromNoise( + /** @type {AuthedNoiseStream} */ (openedNoiseStream) + ) if (!isTrusted) { const timer = setTimeout(async () => { diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js index 6a1860064..4d50530f6 100644 --- a/test/discovery/remote-discovery.js +++ b/test/discovery/remote-discovery.js @@ -87,32 +87,32 @@ test('RemoteDiscovery - connect two instances and verify keypair', async (t) => const peerId2 = keyToPublicId(identityKeypair2.publicKey) assert.equal( - keyToPublicId(inboundStream.handshakePublicKey), + keyToPublicId(inboundStream.authenticatedPublicKey), peerId2, 'instance 1 connected to correct peer' ) assert.equal( - keyToPublicId(outboundStream.handshakePublicKey), + keyToPublicId(outboundStream.authenticatedPublicKey), peerId1, 'instance 2 connected to correct peer' ) - // Verify remotePublicKey and handshakePublicKey are as expected + // Verify remotePublicKey and authenticatedPublicKey are as expected assert.ok( inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), 'inbound remotePublicKey should match swarmKeypair2' ) assert.ok( - inboundStream.handshakePublicKey.equals(identityKeypair2.publicKey), - 'inbound handshakePublicKey should match identityKeypair2' + inboundStream.authenticatedPublicKey.equals(identityKeypair2.publicKey), + 'inbound authenticatedPublicKey should match identityKeypair2' ) assert.ok( outboundStream.remotePublicKey.equals(swarmKeypair1.publicKey), 'outbound remotePublicKey should match swarmKeypair1' ) assert.ok( - outboundStream.handshakePublicKey.equals(identityKeypair1.publicKey), - 'outbound handshakePublicKey should match identityKeypair1' + outboundStream.authenticatedPublicKey.equals(identityKeypair1.publicKey), + 'outbound authenticatedPublicKey should match identityKeypair1' ) // Set up data listeners before writing @@ -390,7 +390,7 @@ test('RemoteDiscovery - connectPeer returns same socket for duplicate connection assert.equal(gotConnection, connection, 'Got existing connection') assert( - gotConnection.handshakePublicKey.equals(identityKeypair2.publicKey), + gotConnection.authenticatedPublicKey.equals(identityKeypair2.publicKey), 'Handshake was valid' ) }) @@ -456,11 +456,11 @@ test('RemoteDiscovery - connect two peers to a third peer', async (t) => { const inboundStream2 = await onConnectionFromPeer2 assert.ok( - inboundStream2.handshakePublicKey.equals(identityKeypair2.publicKey), + inboundStream2.authenticatedPublicKey.equals(identityKeypair2.publicKey), 'peer 1 should see peer 2 identity' ) assert.ok( - outboundStream2.handshakePublicKey.equals(identityKeypair1.publicKey), + outboundStream2.authenticatedPublicKey.equals(identityKeypair1.publicKey), 'peer 2 should see peer 1 identity' ) @@ -473,11 +473,11 @@ test('RemoteDiscovery - connect two peers to a third peer', async (t) => { const inboundStream3 = await onConnectionFromPeer3 assert.ok( - inboundStream3.handshakePublicKey.equals(identityKeypair3.publicKey), + inboundStream3.authenticatedPublicKey.equals(identityKeypair3.publicKey), 'peer 1 should see peer 3 identity' ) assert.ok( - outboundStream3.handshakePublicKey.equals(identityKeypair1.publicKey), + outboundStream3.authenticatedPublicKey.equals(identityKeypair1.publicKey), 'peer 3 should see peer 1 identity' ) diff --git a/test/invite-link-joiner.js b/test/invite-link-joiner.js index d60dd6a3e..043555160 100644 --- a/test/invite-link-joiner.js +++ b/test/invite-link-joiner.js @@ -12,14 +12,14 @@ import { makeInviteURL } from '../src/invite/invite-urls.js' /** @import { Invite, InviteApi } from '../src/invite/invite-api.js' */ /** - * @param {Buffer} handshakePublicKey + * @param {Buffer} authenticatedPublicKey * @returns {RemoteAuthedNoiseStream} */ -function mockConnection(handshakePublicKey) { +function mockConnection(authenticatedPublicKey) { const connection = /** @type {RemoteAuthedNoiseStream} */ ( /** @type {unknown} */ (new Transform()) ) - connection.handshakePublicKey = handshakePublicKey + connection.authenticatedPublicKey = authenticatedPublicKey connection.isTrusted = true connection.remotePublicKey = randomBytes(32) return connection @@ -53,7 +53,7 @@ class MockInviteApi extends TypedEmitter { test('happy path: connect, redeem, accept, complete', async () => { const swarmPublicKey = randomBytes(32) - const handshakePublicKey = randomBytes(32) + const authenticatedPublicKey = randomBytes(32) const inviteId = randomBytes(32) const projectId = randomBytes(20).toString('hex') const url = makeInviteURL({ @@ -64,7 +64,7 @@ test('happy path: connect, redeem, accept, complete', async () => { expiresAt: Date.now() + 60_000, }) - const connection = mockConnection(handshakePublicKey) + const connection = mockConnection(authenticatedPublicKey) const inviteApi = new MockInviteApi({ projectId }) /** @type {string[]} */ @@ -130,7 +130,7 @@ test('happy path: connect, redeem, accept, complete', async () => { assert.equal(redeemCalls.length, 1) assert.equal( redeemCalls[0][0], - handshakePublicKey.toString('hex'), + authenticatedPublicKey.toString('hex'), 'redeem sent to identity key, not swarm key' ) assert.ok( @@ -140,7 +140,7 @@ test('happy path: connect, redeem, accept, complete', async () => { // Simulate the inviter sending back the invite inviteApi.emit('invite-received', { - invitorDeviceId: handshakePublicKey.toString('hex'), + invitorDeviceId: authenticatedPublicKey.toString('hex'), inviteId: inviteId.toString('hex'), }) From f27a6f49c13b9fb25b404bd575ceed0df95d147c Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 21 Jul 2026 15:09:33 -0400 Subject: [PATCH 102/103] feat: Allow setShouldListenOverInternet to be called more than once in tests --- test/invite-links-api.js | 42 +++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/test/invite-links-api.js b/test/invite-links-api.js index a14361664..7ae10b454 100644 --- a/test/invite-links-api.js +++ b/test/invite-links-api.js @@ -735,10 +735,12 @@ test('setShouldListenOverInternet - create() second invite does not call callbac opts: { roleId: MEMBER_ROLE_ID }, }) - assert.equal( - getShouldListenOverInternet().length, - 1, - 'callback only called once on second create' + const calls = getShouldListenOverInternet() + assert.ok(calls.length >= 1, 'callback called at least once') + assert.strictEqual( + calls.at(-1), + true, + 'last call is still true (second create does not flip)' ) }) @@ -822,11 +824,13 @@ test('setShouldListenOverInternet - delete() does not call when invites remain', }) await api.delete(inviteId.toString('hex')) - // Still 1 invite after delete, no callback call - assert.equal( - getShouldListenOverInternet().length, - 1, - 'only one callback call' + // Still 1 invite after delete, callback should not set false + const calls = getShouldListenOverInternet() + assert.ok(calls.length >= 1, 'callback called at least once') + assert.strictEqual( + calls.at(-1), + true, + 'last call is still true (invites remain)' ) }) @@ -874,16 +878,22 @@ test('setShouldListenOverInternet - deleteAllFrom() sets false only when last in // Delete all from first project, other still has invite await api.deleteAllFrom(PROJECT_ID) - // Still 1 invite (from other project), no callback call - assert.equal(getShouldListenOverInternet().length, 1) + // Still 1 invite (from other project), callback should not set false + assert.strictEqual( + getShouldListenOverInternet().at(-1), + true, + 'last call is still true (invites remain in other project)' + ) // Delete all from second project await api.deleteAllFrom('other-project') - // 0 invites remain - assert.deepEqual( - getShouldListenOverInternet(), - [true, false], - 'called true on create, false on deleteAllFrom last project' + // 0 invites remain - last call should be false + const calls = getShouldListenOverInternet() + assert.ok(calls.includes(true), 'callback was called with true') + assert.strictEqual( + calls.at(-1), + false, + 'last call is false (all invites removed)' ) }) From 60f76c0a35cb47d98024bd71d055fd4078785c24 Mon Sep 17 00:00:00 2001 From: Mauve Signweaver Date: Tue, 21 Jul 2026 17:39:11 -0400 Subject: [PATCH 103/103] fix: handle connections emitting finish instead of close, avoid infinite recursion --- src/discovery/remote-discovery.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/discovery/remote-discovery.js b/src/discovery/remote-discovery.js index c76d3ab6a..a759108a6 100644 --- a/src/discovery/remote-discovery.js +++ b/src/discovery/remote-discovery.js @@ -189,20 +189,31 @@ export class RemoteDiscovery extends TypedEmitter { * @returns {Promise} */ async #findExistingPeer(noisePublicKey) { + let shouldRetry = false for (const existingConnection of this.#connections) { if (!existingConnection.remotePublicKey?.equals(noisePublicKey)) continue const opened = await openedNoiseSecretStream(existingConnection) - // If the connection closed, try again - if (opened.destroyed) return this.#findExistingPeer(noisePublicKey) + // If the connection closed, skip it and continue the loop + if (opened.destroyed) { + shouldRetry = true + continue + } // @ts-ignore Some connections might not be handshaked, wait for them to be if (!existingConnection.authenticatedPublicKey) { const success = await this.#pendingHandshakes.get(existingConnection) - if (!success) return this.#findExistingPeer(noisePublicKey) + if (!success) { + shouldRetry = true + continue + } } // @ts-ignore return opened } + // If we encountered a closed connection, recurse once to retry + // after the 'close' event may have removed it from #connections + if (shouldRetry) return this.#findExistingPeer(noisePublicKey) + return null } @@ -265,6 +276,7 @@ export class RemoteDiscovery extends TypedEmitter { const pendingDefer = pDefer() this.#pendingHandshakes.set(socket, pendingDefer.promise) socket.once('close', () => this.#connections.delete(socket)) + socket.once('finish', () => this.#connections.delete(socket)) try { const remotePublicKeyString = socket.remotePublicKey.toString('hex') // @ts-ignore