diff --git a/docs/guides/invite-over-internet.md b/docs/guides/invite-over-internet.md new file mode 100644 index 000000000..084f7e6af --- /dev/null +++ b/docs/guides/invite-over-internet.md @@ -0,0 +1,147 @@ +# Inviting New Members 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 + - 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 + +## Examples + +## Invitor Flow + +```JavaScript +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) + // 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) + +// 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 + } +}) + +// 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() +``` + +### Awaiting Join Completion + +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/drizzle/client/0005_thin_iron_patriot.sql b/drizzle/client/0005_thin_iron_patriot.sql new file mode 100644 index 000000000..12bdf1299 --- /dev/null +++ b/drizzle/client/0005_thin_iron_patriot.sql @@ -0,0 +1,12 @@ +CREATE TABLE `inviteLinks` ( + `inviteId` text PRIMARY KEY NOT NULL, + `projectId` text NOT NULL, + `inviteIdBuffer` blob NOT NULL, + `url` text NOT NULL, + `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 new file mode 100644 index 000000000..09f1197b8 --- /dev/null +++ b/drizzle/client/meta/0005_snapshot.json @@ -0,0 +1,323 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4338fa73-975a-4ccd-b236-fc8ef47e3573", + "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": {} + }, + "inviteLinks": { + "name": "inviteLinks", + "columns": { + "inviteId": { + "name": "inviteId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "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 + }, + "seedTime": { + "name": "seedTime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "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..029f8bb55 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": 1780948369238, + "tag": "0005_thin_iron_patriot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7740b0c86..8072644c8 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", @@ -24,10 +24,11 @@ "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.1.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", @@ -40,6 +41,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", @@ -193,57 +195,6 @@ "@bufbuild/buf-win32-x64": "1.26.1" } }, - "node_modules/@bufbuild/buf-darwin-arm64": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.26.1.tgz", - "integrity": "sha512-nmyWiT/59RFja0ZuXFxjNGoAMDPTApU66CZUUevkFVWbNB9nzeQDjx2vsJyACY64k5fTgZiaelSiyppwObQknw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-darwin-x64": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.26.1.tgz", - "integrity": "sha512-jl5WmUv30OW8JiRLid9+mVx1XVH0XttpUfkQfmqDFdUHGfdy4XWYK8kr84YyWu0SiMTIt1mPXkqG5UM3x+tdIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-linux-aarch64": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.26.1.tgz", - "integrity": "sha512-EedR2KDW/yDIxQKWuq1Y/g7IuwTgvelqylGVO7muMxt2JWShobyUaU6GIU8JB4yhIbqRQYCL2KqBsvDJbJtCUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@bufbuild/buf-linux-x64": { "version": "1.26.1", "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.26.1.tgz", @@ -261,40 +212,6 @@ "node": ">=12" } }, - "node_modules/@bufbuild/buf-win32-arm64": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.26.1.tgz", - "integrity": "sha512-/ayymSD12gBetN98ErkH0CBGRLTmtYAp4fmbPuvq8zuJcL0eiAnK6d7ZFjTc+vDMuKY/aelQN7dj9WhzdYAQSQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-win32-x64": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.26.1.tgz", - "integrity": "sha512-k9Dy3Z9P96wYR43lUhUo0jbjMSo001+MRBlsadEYiw85POqx6RWVaGyHLrxC2Ly7g+aGMisey050OjqfCWtKTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@comapeo/cloud": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@comapeo/cloud/-/cloud-0.4.0.tgz", @@ -399,6 +316,26 @@ "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", + "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", @@ -525,6 +462,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", @@ -916,6 +874,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", @@ -1296,6 +1274,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", @@ -1477,896 +1475,86 @@ } }, "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@emnapi/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", - "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.17.6", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", - "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.17.19", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.5.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.0.0", - "get-tsconfig": "^4.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", - "cpu": [ - "arm64" - ], + "node_modules/@esbuild-kit/core-utils": { + "version": "3.1.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "dependencies": { + "esbuild": "~0.17.6", + "source-map-support": "^0.5.21" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "sunos" + "linux" ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", - "cpu": [ - "arm64" - ], + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.17.19", "dev": true, + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=18" + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", - "cpu": [ - "ia32" - ], + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.5.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@esbuild-kit/core-utils": "^3.0.0", + "get-tsconfig": "^4.4.0" } }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/linux-x64": { "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "cpu": [ "x64" ], @@ -2374,7 +1562,7 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=18" @@ -3095,19 +2283,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" } @@ -3180,17 +2366,6 @@ "node": ">= 10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz", - "integrity": "sha512-z8YVS3XszxFTO73iwvFDNpQIzdMmSDTP/mB3E/ucR37V3Sx57hSExcXyMoNwaucWxnsWf4xfbZv0iZ30jr0M4Q==", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.3.1", - "@emnapi/runtime": "^1.3.1", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@node-rs/crc32": { "version": "1.10.6", "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.6.tgz", @@ -3219,126 +2394,6 @@ "@node-rs/crc32-win32-x64-msvc": "1.10.6" } }, - "node_modules/@node-rs/crc32-android-arm-eabi": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz", - "integrity": "sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-android-arm64": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz", - "integrity": "sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-darwin-arm64": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz", - "integrity": "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-darwin-x64": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz", - "integrity": "sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-freebsd-x64": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz", - "integrity": "sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz", - "integrity": "sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm64-gnu": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz", - "integrity": "sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm64-musl": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz", - "integrity": "sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/crc32-linux-x64-gnu": { "version": "1.10.6", "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz", @@ -3369,66 +2424,6 @@ "node": ">= 10" } }, - "node_modules/@node-rs/crc32-wasm32-wasi": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz", - "integrity": "sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/crc32-win32-arm64-msvc": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz", - "integrity": "sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-win32-ia32-msvc": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz", - "integrity": "sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-win32-x64-msvc": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz", - "integrity": "sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -3605,17 +2600,9 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/b4a": { "version": "1.6.0", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3869,6 +2856,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, @@ -4297,14 +3293,67 @@ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" }, - "node_modules/balanced-match": { - "version": "1.0.2", - "license": "MIT" + "node_modules/balanced-match": { + "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==" + "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", @@ -4317,6 +3366,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", @@ -4332,13 +3411,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": { @@ -4452,6 +3559,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", @@ -4461,10 +3577,58 @@ "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", - "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" @@ -4936,13 +4100,23 @@ } }, "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" } }, + "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", @@ -5246,6 +4420,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", @@ -5608,6 +4797,70 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "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, @@ -6347,6 +5600,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", @@ -7282,6 +6544,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", @@ -7298,6 +6642,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", @@ -8010,6 +7369,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, @@ -8907,6 +8275,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, @@ -10400,6 +9774,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, @@ -10436,6 +9819,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", @@ -10873,6 +10268,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.17.7.tgz", + "integrity": "sha512-Zf6hNtWhFyF4XP5OOsXkBTEx9JFPiN0TQx4wSe+Vqeuczewgk2vT4IZhF4gka55uelm052BD5BaHavNqUNZd+A==", + "dev": true, + "dependencies": { + "@shikijs/core": "1.17.7", + "@shikijs/engine-javascript": "1.17.7", + "@shikijs/engine-oniguruma": "1.17.7", + "@shikijs/types": "1.17.7", + "@shikijs/vscode-textmate": "^9.2.2", + "@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, @@ -10891,6 +10309,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", @@ -11232,16 +10656,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": { @@ -11664,6 +11086,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", @@ -11738,6 +11169,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" @@ -11979,6 +11416,27 @@ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==" }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "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", @@ -12061,6 +11519,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", @@ -12183,8 +11647,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", @@ -12258,7 +11721,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": { @@ -12331,9 +11796,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 e9a3be79d..2dd93137f 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", @@ -179,7 +183,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", @@ -188,10 +192,11 @@ "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.1.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", @@ -204,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/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/proto/rpc.proto b/proto/rpc.proto index b500d7ef5..64c8a6ac2 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 { @@ -36,6 +37,20 @@ message ProjectJoinDetails { EncryptionKeys encryptionKeys = 3; } +message RedeemInviteOverInternet { + bytes inviteId = 1; +} + +message DenyInviteOverInternet { + enum DenyReason { + unspecified = 0; + unknown_invite_id = 1; + invitor_denied = 2; + } + bytes inviteId = 1; + DenyReason reason = 2; +} + message DeviceInfo { enum DeviceType { device_type_unspecified = 0; @@ -69,6 +84,14 @@ message ProjectJoinDetailsAck { bytes inviteId = 1; } +message RedeemInviteOverInternetAck { + bytes inviteId = 1; +} + +message DenyInviteOverInternetAck { + bytes inviteId = 1; +} + message MapShareExtension { // URLs to map share repeated string mapShareUrls = 1; diff --git a/src/blob-store/index.js b/src/blob-store/index.js index 525e91d3e..a2b7f1bbe 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' */ @@ -123,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) @@ -132,11 +133,10 @@ 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 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/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/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 new file mode 100644 index 000000000..a759108a6 --- /dev/null +++ b/src/discovery/remote-discovery.js @@ -0,0 +1,429 @@ +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 { SwarmHandshake } from '../generated/handshake.js' +import { + ensureKnownError, + HandshakeTooLargeError, + InvalidIdentityProofError, + TimeoutError, + UnableToReadHandshakeError, +} from '../errors.js' + +import { openedNoiseSecretStream } from '../lib/noise-secret-stream-helpers.js' +import pDefer from 'p-defer' + +/** @import {OpenedNoiseStream, AuthedNoiseStream} from '../lib/noise-secret-stream-helpers.js' */ +/** @import {Keypair} from './local-discovery.js' */ +/** @import {Duplex, Readable} from "streamx" */ + +// Re-export for consumers that import from this module +/** @typedef {AuthedNoiseStream} RemoteAuthedNoiseStream */ + +/** + * @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' +) + +// 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} + */ +export class RemoteDiscovery extends TypedEmitter { + #l + /** @type {Hyperswarm?} */ + #swarm = null + #sm + #identityKeypair + #deriveSwarmIdentityKeypair + /** @type {Keypair?} */ + #lastKeyPair = null + #swarmOpts + /** @type {Set} */ + #shouldTrustKeys = new Set() + /** @type {Set} */ + #connections = new Set() + /** @type {Map>} */ + #pendingHandshakes = new Map() + + /** + * @param {Object} opts + * @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, + swarm: swarmOpts, + }) { + super() + this.#l = Logger.create('RemoteDiscovery', logger) + this.#identityKeypair = identityKeypair + this.#deriveSwarmIdentityKeypair = deriveSwarmIdentityKeypair + this.#swarmOpts = swarmOpts + this.#sm = new StartStopStateMachine({ + start: this.#start.bind(this), + stop: this.#stop.bind(this), + }) + } + + 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, + 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)) + this.#l.log('Starting listen') + await swarm.listen() + this.#l.log('Listening') + await swarm.resume() + this.#swarm = swarm + } + + /** + * Start listening for incoming connections + */ + async start() { + return this.#sm.start() + } + + /** + * @param {object} [opts] + * @param {boolean} [opts.force=false] Force-close open connections + * @returns {Promise} + */ + async stop(opts) { + return this.#sm.stop(opts) + } + + /** + * @param {object} [opts] + * @param {boolean} [opts.force=false] Force-close open connections + */ + 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() { + await this.#swarm?.destroy() + this.#l.log('Closed swarm') + } + + /** + * @param {OpenedNoiseStream} socket + */ + async [kTestOnlyHandleHyperswarmConnection](socket) { + 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) || + ('authenticatedPublicKey' in connection && + connection.authenticatedPublicKey.equals(noisePublicKey)) + ) { + this.#l.log('Disconnecting from peer %S', publicKey) + connection.end() + await pEvent(connection, 'close') + return + } + } + // TODO: Error on unknown peer? + this.#l.log( + 'Error: Cannot disconnect from peer %S, not connected', + publicKey + ) + } + + /** + * @param {Buffer} noisePublicKey + * @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, 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) { + 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 + } + + /** + * Connect to another peer by their NOISE public key + * @param {string} publicKey + * @param {object} [opts] + * @param {number} [opts.timeout] + * @param {AbortSignal} [opts.signal] + * @returns {Promise} + */ + async connectPeer(publicKey, { timeout = 60_000, signal } = {}) { + 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) + if (existing) return existing + + const onAbort = () => { + this.#l.log('Leave peer for %s', publicKey) + 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 + + 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') + } + throw e + } finally { + signal?.removeEventListener('abort', onAbort) + this.#shouldTrustKeys.delete(publicKey) + } + } + + /** + * @param {OpenedNoiseStream} socket + */ + async #handleHyperswarmConnection(socket) { + this.#connections.add(socket) + 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 + socket.isTrusted = this.#shouldTrustKeys.has(remotePublicKeyString) + + const firstData = readHandshakeBuffer(socket) + const keyPair = this.#identityKeypair + // Sign the Noise handshake hash with our stable key + const handshakeBuffer = makeSwarmHandshake(socket.handshakeHash, keyPair) + + 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-expect-error adding AuthedNoiseStream properties + socket.authenticatedPublicKey = msg.publicKey + this.emit('connection', /** @type {AuthedNoiseStream} */ (socket)) + this.#pendingHandshakes.delete(socket) + pendingDefer.resolve(true) + } catch (err) { + socket.end() + this.emit('error', ensureKnownError(err)) + pendingDefer.resolve(false) + } + } +} + +/** + * + * @param {Readable|Duplex} stream + * @param {number} length + * @returns {Promise} + */ +async function readChunk(stream, length) { + let data = stream.read() + + if (!data) { + try { + await pEvent(stream, 'readable', { + timeout: 10_000, + rejectionEvents: ['error', 'close'], + }) + data = stream.read() + } catch { + throw new UnableToReadHandshakeError() + } + } + + stream.pause() + + if (!data) { + // This should never happen + throw new UnableToReadHandshakeError() + } + + if (data.length === length) return data + + if (data.length > length) { + const slice = data.subarray(0, length) + const remainder = data.subarray(length) + stream.unshift(remainder) + return slice + } + + const remainingBytes = length - data.length + const remainingData = await readChunk(stream, remainingBytes) + const result = new Uint8Array(length) + result.set(data, 0) + result.set(remainingData, data.length) + return result +} + +/** + * + * @param {Readable|Duplex} stream + * @returns {Promise} + */ +export async function readHandshakeBuffer(stream) { + const handshakeLengthBytes = await readChunk(stream, LENGTH_BYTES_LENGTH) + + 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) + + return Buffer.from(data) +} + +/** + * + * @param {Buffer|null} handshakeHash + * @param {import('../types.js').KeyPair} keyPair + * @returns + */ +export function makeSwarmHandshake(handshakeHash, keyPair) { + const sig = new Uint8Array(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 lengthPrefix(handshakeBuffer) +} + +/** + * Convert a buffer to its length prefixed version using UInt16LE + * @param {Uint8Array} buffer + * @return + */ +export function lengthPrefix(buffer) { + const fullBuffer = new Uint8Array(buffer.length + LENGTH_BYTES_LENGTH) + + new DataView(fullBuffer.buffer, fullBuffer.byteOffset).setUint16( + 0, + buffer.length, + true + ) + + fullBuffer.set(buffer, LENGTH_BYTES_LENGTH) + + return fullBuffer +} diff --git a/src/errors.js b/src/errors.js index eba9d925f..f5e1b804b 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', @@ -38,7 +42,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, }) @@ -430,6 +434,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', @@ -442,6 +452,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}', @@ -515,6 +531,115 @@ 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 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', + 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 MissingInviteURLParameter = createErrorClass({ + code: 'MISSING_INVITE_URL_PARAMETER', + message: 'Invite URL is missing the {paramName} parameter', + 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', + status: 409, +}) + +export const InviteDeniedByInviterError = createErrorClass({ + code: 'INVITE_DENIED_BY_INVITER_ERROR', + message: 'Invitation denied by project inviter, reason: {reason}', + status: 403, +}) + +export const JoinProjectCancelledError = createErrorClass({ + code: 'JOIN_PROJECT_CANCELLED_ERROR', + message: 'Join project was cancelled', + status: 499, +}) + +export const InitialSyncFailedError = createErrorClass({ + code: 'INITIAL_SYNC_FAILED_ERROR', + message: 'Failed to perform initial sync with peers', + 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}', + status: 405, +}) + /** * @param {unknown} err * @returns {null} @@ -551,6 +676,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/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/generated/rpc.d.ts b/src/generated/rpc.d.ts index 022d1348a..e3b969ad7 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; @@ -34,6 +35,22 @@ export interface ProjectJoinDetails { projectKey: Buffer; encryptionKeys: EncryptionKeys | undefined; } +export interface RedeemInviteOverInternet { + inviteId: Buffer; +} +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; @@ -70,6 +87,12 @@ export interface InviteResponseAck { export interface ProjectJoinDetailsAck { inviteId: Buffer; } +export interface RedeemInviteOverInternetAck { + inviteId: Buffer; +} +export interface DenyInviteOverInternetAck { + inviteId: Buffer; +} export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -118,6 +141,18 @@ 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 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; @@ -148,6 +183,18 @@ 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 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 ce65ff963..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", @@ -130,6 +166,7 @@ function createBaseInvite() { invitorName: "", sendStats: false, invitorWroteDeviceInfo: false, + leaveOnFail: false, }; } export var Invite = { @@ -165,6 +202,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 +274,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 +292,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 +304,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; }, }; @@ -419,6 +466,100 @@ 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 createBaseDenyInviteOverInternet() { + return { inviteId: Buffer.alloc(0), reason: DenyInviteOverInternet_DenyReason.unspecified }; +} +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); + } + if (message.reason !== DenyInviteOverInternet_DenyReason.unspecified) { + writer.uint32(16).int32(denyInviteOverInternet_DenyReasonToNumber(message.reason)); + } + 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; + case 2: + if (tag !== 16) { + break; + } + message.reason = denyInviteOverInternet_DenyReasonFromJSON(reader.int32()); + 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, _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; + }, +}; function createBaseDeviceInfo() { return { name: "", features: [] }; } @@ -659,6 +800,90 @@ 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 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 898bd252f..6cf92e307 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 { @@ -78,6 +79,57 @@ export interface ProjectJoinDetails { encryptionKeys: EncryptionKeys | undefined; } +export interface RedeemInviteOverInternet { + inviteId: Buffer; +} + +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 { name: string; deviceType?: DeviceInfo_DeviceType | undefined; @@ -188,6 +240,14 @@ export interface ProjectJoinDetailsAck { inviteId: Buffer; } +export interface RedeemInviteOverInternetAck { + inviteId: Buffer; +} + +export interface DenyInviteOverInternetAck { + inviteId: Buffer; +} + export interface MapShareExtension { /** URLs to map share */ mapShareUrls: string[]; @@ -221,6 +281,7 @@ function createBaseInvite(): Invite { invitorName: "", sendStats: false, invitorWroteDeviceInfo: false, + leaveOnFail: false, }; } @@ -256,6 +317,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; }, @@ -336,6 +400,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; @@ -360,6 +431,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; }, }; @@ -534,6 +606,107 @@ 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 createBaseDenyInviteOverInternet(): DenyInviteOverInternet { + return { inviteId: Buffer.alloc(0), reason: DenyInviteOverInternet_DenyReason.unspecified }; +} + +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); + } + if (message.reason !== DenyInviteOverInternet_DenyReason.unspecified) { + writer.uint32(16).int32(denyInviteOverInternet_DenyReasonToNumber(message.reason)); + } + 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; + case 2: + if (tag !== 16) { + break; + } + + message.reason = denyInviteOverInternet_DenyReasonFromJSON(reader.int32()); + 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); + message.reason = object.reason ?? DenyInviteOverInternet_DenyReason.unspecified; + return message; + }, +}; + function createBaseDeviceInfo(): DeviceInfo { return { name: "", features: [] }; } @@ -793,6 +966,96 @@ 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 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/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/invite/invite-link-joiner.js b/src/invite/invite-link-joiner.js new file mode 100644 index 000000000..fdf52b5f6 --- /dev/null +++ b/src/invite/invite-link-joiner.js @@ -0,0 +1,290 @@ +import { TypedEmitter } from 'tiny-typed-emitter' +import { pEvent } from 'p-event' +import { parseInviteURL } from './invite-urls.js' +import { + ExistingJoinRequestError, + InviteRedeemConnectionClosedError, + JoinRequestNotFoundError, + JoinProjectCancelledError, +} 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.authenticatedPublicKey.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 + * @param {Error} [reason] Reason for cancellation. Defaults to a generic cancellation error. + * @returns {void} + */ + cancelJoinRequest(inviteId, reason) { + const pending = this.#pending.get(inviteId) + if (!pending) { + throw new JoinRequestNotFoundError({ inviteId }) + } + pending.abortController.abort(reason ?? new JoinProjectCancelledError()) + this.#pending.delete(inviteId) + } +} + +/** @param {unknown} _x */ +function noop(_x) {} diff --git a/src/invite/invite-links-api.js b/src/invite/invite-links-api.js new file mode 100644 index 000000000..39ebaa927 --- /dev/null +++ b/src/invite/invite-links-api.js @@ -0,0 +1,367 @@ +import { eq, sql } from 'drizzle-orm' +import ReadyResource from 'ready-resource' +import { inviteLinksTable } from '../schema/client.js' +import { isRoleIdForNewInvite } from '../roles.js' +import { + ensureKnownError, + getErrorCode, + InviteLinkAlreadyExistsError, +} from '../errors.js' +import { deNullify } from '../utils.js' + +/** @import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' */ +/** @import { InviteOptions } from '../member-api.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 + * @property {string} [roleName] + * @property {string} [roleDescription] + * @property {number} createdAt Timestamp when created + * @property {number} expiresAt Timestamp when the invite expires + */ + +/** + * @typedef {object} InviteLinkCreate + * @property {string} projectId + * @property {string} inviteId + * @property {Buffer} inviteIdBuffer + * @property {string} url + * @property {InviteOptions} opts + */ + +export const DEFAULT_INVITE_EXPIRY_MS = 24 * 60 * 60 * 1000 + +export class InviteLinksApiForProject { + #projectId + /** @type {InviteLinksApi} */ + #inviteLinksApi + + /** + * + * @param {string} projectId + * @param {InviteLinksApi} inviteLinksApi + */ + constructor(projectId, inviteLinksApi) { + this.#projectId = projectId + this.#inviteLinksApi = inviteLinksApi + } + + /** + * Create a new invite link record + * @param {Omit} data + * @returns {Promise} + */ + async create(data) { + return this.#inviteLinksApi.create({ + ...data, + projectId: this.#projectId, + }) + } + + /** + * Get an invite link by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + const link = await this.#inviteLinksApi.getById(inviteId) + if (!link) return link + if (link.projectId !== this.#projectId) { + return undefined + } + return link + } + + /** + * Get all invite links for the project + * @returns {Promise} + */ + async getAll() { + return this.#inviteLinksApi.getAllForProject(this.#projectId) + } + + /** + * Delete an invite link + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + return this.#inviteLinksApi.delete(inviteId) + } + + /** + * Delete all invite links for the project + * @returns {Promise} + */ + async deleteAll() { + return this.#inviteLinksApi.deleteAllFrom(this.#projectId) + } +} + +/** + * API for CRUD operations on invite links over internet + */ +/** + * @type {ReadyResource} + */ +export class InviteLinksApi extends ReadyResource { + /** @type {BetterSQLite3Database} */ + #db + #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, + expiryMs = DEFAULT_INVITE_EXPIRY_MS + ) { + super() + this.#db = db + this.#setShouldListenOverInternet = setShouldListenOverInternet + this.#expiryMs = expiryMs + this.#sql = { + getById: db + .select() + .from(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, sql.placeholder('inviteId'))) + .limit(1) + .prepare(), + getAll: db.select().from(inviteLinksTable).prepare(), + getAllForProject: db + .select() + .from(inviteLinksTable) + .where(eq(inviteLinksTable.projectId, sql.placeholder('projectId'))) + .prepare(), + deleteExpired: db + .delete(inviteLinksTable) + .where( + sql`${inviteLinksTable.expiresAt} < ${sql.placeholder('cutoff')}` + ) + .prepare(), + getOldest: db + .select() + .from(inviteLinksTable) + .orderBy(sql`${inviteLinksTable.createdAt} ASC`) + .limit(1) + .prepare(), + } + } + + 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) { + await this.#setShouldListenOverInternet(true) + } + this.#scheduleExpired() + } + + async _close() { + this.#cancelScheduleExpired() + } + + /** + * Delete all invite links that have expired. + * Stop listening over internet if none are left. + */ + async #clearExpired() { + const cutoff = Date.now() + const result = this.#sql.deleteExpired.run({ cutoff }) + // Update the listen state once, after all deletions + if (result.changes > 0) { + await this.#checkSetShouldListenOverInternet(false) + } + } + + /** + * 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 delay = Math.max(0, oldest.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. + * 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) { + 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() + } + } + + getSeedTime() { + const oldest = this.#sql.getOldest.get() + if (!oldest) return Date.now() + return oldest.seedTime + } + + /** + * Create a new invite link record + * @param {InviteLinkCreate} data + * @returns {Promise} + */ + async create(data) { + await this.ready() + try { + await this.#db.insert(inviteLinksTable).values({ + projectId: data.projectId, + inviteId: data.inviteId, + inviteIdBuffer: data.inviteIdBuffer, + url: data.url, + roleId: data.opts.roleId, + roleName: data.opts.roleName, + roleDescription: data.opts.roleDescription, + createdAt: Date.now(), + expiresAt: Date.now() + this.#expiryMs, + seedTime: this.getSeedTime(), + }) + + 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 InviteLinkAlreadyExistsError({ inviteId: data.inviteId }) + } + throw ensureKnownError(err) + } + } + + /** + * Get a invite link by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + await this.ready() + 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 {InviteLinkRecord} */ (deNullify(row)) + } + + /** + * Get all invite links + * @returns {Promise} + */ + async getAll() { + await this.ready() + 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 {InviteLinkRecord} */ (deNullify(row)) + }) + } + + /** + * Get all invite links + * @param {string} projectId + * @returns {Promise} + */ + async getAllForProject(projectId) { + await this.ready() + 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 {InviteLinkRecord} */ (deNullify(row)) + }) + } + + /** + * Delete a invite link + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + await this.ready() + await this.#db + .delete(inviteLinksTable) + .where(eq(inviteLinksTable.inviteId, inviteId)) + + await this.#checkSetShouldListenOverInternet(false) + } + + /** + * Delete all invite links + * @returns {Promise} + */ + async deleteAll() { + await this.ready() + await this.#db.delete(inviteLinksTable) + await this.#setShouldListenOverInternet(false) + this.#cancelScheduleExpired() + } + + /** + * Delete all invite links in a specific project + * @param {string} projectId + */ + async deleteAllFrom(projectId) { + await this.ready() + await this.#db + .delete(inviteLinksTable) + .where(eq(inviteLinksTable.projectId, projectId)) + + await this.#checkSetShouldListenOverInternet(false) + } +} diff --git a/src/invite/invite-urls.js b/src/invite/invite-urls.js new file mode 100644 index 000000000..63b675686 --- /dev/null +++ b/src/invite/invite-urls.js @@ -0,0 +1,104 @@ +/** + * @typedef {object} InviteLinkParams + * @property {string} inviteIdString + * @property {string} swarmPublicKey + * @property {string} invitorName + * @property {string} projectName + * @property {number} expiresAt + */ + +import { + MissingInviteURLParameter, + InvalidInviteURLKeyParameterError, +} from '../errors.js' +import { CrockfordBase32 } from 'crockford-base32' + +/** + * @type {Record} + */ +export const URL_PARAM_MAPPINGS = { + inviteIdString: 'i', + swarmPublicKey: 'd', + invitorName: 'n', + projectName: 'p', + expiresAt: 'e', +} + +export const INTERNET_INVITE_PAGE = 'https://a.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 + const decoded = CrockfordBase32.decode(value) + if (decoded.length !== 32) { + throw new InvalidInviteURLKeyParameterError({ + paramName: optName, + byteLength: decoded.length, + }) + } + // @ts-ignore Type narrowing on computed key not precise enough + opts[/**@type {keyof InviteLinkParams}*/ (optName)] = + decoded.toString('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 = CrockfordBase32.encode(Buffer.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/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/src/lib/noise-secret-stream-helpers.js b/src/lib/noise-secret-stream-helpers.js index 7c50c65ea..08070c666 100644 --- a/src/lib/noise-secret-stream-helpers.js +++ b/src/lib/noise-secret-stream-helpers.js @@ -1,12 +1,19 @@ /** @import { Duplex as NodeDuplex } from 'node:stream' */ /** @import { Duplex as StreamxDuplex } from 'streamx' */ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */ - /** * @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 @@ -28,10 +35,12 @@ * stream is opened) * * @template {RawStream} T - * @param {NoiseSecretStream} stream - * @returns {Promise | DestroyedNoiseStream>} + * @param {NoiseSecretStream|AuthedNoiseStream} stream + * @returns {Promise | DestroyedNoiseStream | AuthedNoiseStream>} */ export async function openedNoiseSecretStream(stream) { await stream.opened - return /** @type {OpenedNoiseStream | DestroyedNoiseStream} */ (stream) + return /** @type {OpenedNoiseStream | DestroyedNoiseStream | AuthedNoiseStream} */ ( + stream + ) } diff --git a/src/local-peers.js b/src/local-peers.js index 75c85845a..e6a8f69d4 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -16,6 +16,10 @@ import { ProjectJoinDetailsAck, DeviceInfo_RPCFeatures, MapShareExtension, + RedeemInviteOverInternet, + RedeemInviteOverInternetAck, + DenyInviteOverInternet, + DenyInviteOverInternetAck, } from './generated/rpc.js' import pDefer from 'p-defer' import { Logger } from './logger.js' @@ -29,10 +33,14 @@ import { InvalidInviteError, InvalidProjectJoinDetailsError, MapShareNotSupportedByPeerError, + UntrustedRPCMethodError, + ensureKnownError, } from './errors.js' + /** @import NoiseStream from '@hyperswarm/secret-stream' */ /** @import { OpenedNoiseStream } from './lib/noise-secret-stream-helpers.js' */ /** @import {DeferredPromise} from 'p-defer' */ +/** @import {AuthedNoiseStream} from "./lib/noise-secret-stream-helpers.js" */ /** * @typedef {InviteAck|InviteCancelAck|InviteResponseAck|ProjectJoinDetailsAck} AckResponse @@ -72,9 +80,33 @@ const MESSAGE_TYPES = { InviteResponseAck: 7, ProjectJoinDetailsAck: 8, MapShareExtension: 9, + RedeemInviteOverInternet: 10, + RedeemInviteOverInternetAck: 11, + DenyInviteOverInternet: 12, + DenyInviteOverInternetAck: 13, } 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_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') /** @@ -101,6 +133,7 @@ class Peer { /** @type {PeerState} */ #state = 'connecting' #deviceId + #isTrusted #channel #connected /** @type {string | undefined} */ @@ -122,11 +155,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() @@ -193,6 +228,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') { @@ -219,9 +266,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() @@ -410,6 +457,70 @@ 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} + */ + 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( @@ -463,17 +574,23 @@ 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 * @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-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 * @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 + * @property {(peerId:string) => void} peer-trusted Emitted when a previously untrusted peer gets marked as trusted */ /** @extends {TypedEmitter} */ @@ -518,6 +635,7 @@ export class LocalPeers extends TypedEmitter { if (!peer.supportsMapShare()) { throw new MapShareNotSupportedByPeerError() } + checkTrusted('MapShareExtension', peer) await peer.sendMapShare(mapShare) } @@ -529,6 +647,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) } @@ -540,6 +659,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) } @@ -552,9 +672,36 @@ 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) } + /** + * 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) + checkTrusted('DenyInviteOverInternet', peer) + await peer.sendDenyInviteOverInternet(deny) + } + + /** + * 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) + checkTrusted('RedeemInviteOverInternet', peer) + await peer.sendRedeemInviteOverInternet(redeem) + } + /** * @param {string} deviceId * @param {ProjectJoinDetails} details @@ -562,6 +709,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) } @@ -573,6 +721,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) } @@ -586,13 +735,33 @@ 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 + 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 * - * @param {NoiseStream} stream + * @param {NoiseStream|AuthedNoiseStream} stream + * @param {boolean} isTrusted * @returns {import('./types.js').ReplicationStream} */ - connect(stream) { + connect(stream, isTrusted) { const noiseStream = stream.noiseStream const outerStream = noiseStream.rawStream const protomux = @@ -623,7 +792,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 @@ -647,17 +816,18 @@ export class LocalPeers extends TypedEmitter { } /** - * @param {Protomux} protomux + * @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 // 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 @@ -675,9 +845,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}`) } }, } @@ -701,6 +870,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() }, @@ -712,6 +886,7 @@ export class LocalPeers extends TypedEmitter { const existingDevicePeers = this.#peers.get(peerId) || new Set() const peer = new Peer({ + isTrusted, peerId, protomux, channel, @@ -729,7 +904,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} */ @@ -782,52 +957,85 @@ 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_RECEIVE_UNTRUSTED_RPC.has(type)) { + throw new UntrustedRPCMethodError({ + type, + peerId: peer.id, + }) + } + switch (type) { case 'Invite': { const invite = parseInvite(value) - const peerId = keyToId(protomux.stream.remotePublicKey) - this.emit('invite', peerId, 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, - 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': { const inviteCancel = parseInviteCancel(value) - const peerId = keyToId(protomux.stream.remotePublicKey) - this.emit('invite-cancel', peerId, 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, - 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': { const inviteResponse = parseInviteResponse(value) - const peerId = keyToId(protomux.stream.remotePublicKey) - this.emit('invite-response', peerId, 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) + 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': { const details = parseProjectJoinDetails(value) - const peerId = keyToId(protomux.stream.remotePublicKey) - this.emit('got-project-details', peerId, 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': { @@ -845,29 +1053,49 @@ export class LocalPeers extends TypedEmitter { case 'InviteAck': { const ack = InviteAck.decode(value) peer.receiveAck('InviteAck', ack) - const peerId = keyToId(protomux.stream.remotePublicKey) - 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 = keyToId(protomux.stream.remotePublicKey) - 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 = keyToId(protomux.stream.remotePublicKey) - 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 = keyToId(protomux.stream.remotePublicKey) - 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) + this.emit('invite-over-internet-redeemed-ack', peer.id, ack) + break + } + case 'DenyInviteOverInternet': { + const deny = DenyInviteOverInternet.decode(value) + 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': { + const ack = DenyInviteOverInternetAck.decode(value) + peer.receiveAck('DenyInviteOverInternetAck', ack) + this.emit('invite-over-internet-denied-ack', peer.id, ack) break } /* c8 ignore next 2 */ @@ -1032,3 +1260,27 @@ function chooseDevicePeer(devicePeers) { // @ts-ignore return pick } + +/** + * @param {AuthedNoiseStream|OpenedNoiseStream} stream + */ +export function peerIdFromNoise(stream) { + const publicKey = + 'authenticatedPublicKey' in stream + ? stream.authenticatedPublicKey + : stream.remotePublicKey + return keyToId(publicKey) +} + +/** + * @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, + }) +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index b2c09c42b..70df077f3 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -40,13 +40,14 @@ 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' 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' @@ -58,21 +59,31 @@ import { FailedToSetIsArchiveDeviceError, NotFoundError, ProjectExistsError, + InvalidMapShareReceiverError, + 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 { InviteLinksApi } from './invite/invite-links-api.js' +import { InviteLinkJoiner } from './invite/invite-link-joiner.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' */ -/** @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' */ /** @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 */ @@ -105,6 +116,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' @@ -133,6 +147,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 link 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 */ /** @@ -153,14 +169,20 @@ export class MapeoManager extends TypedEmitter { #projectMigrationsFolder #deviceId #localPeers + #inviteLinkStore + #inviteLinks #invite #fastify #localDiscovery + #remoteDiscovery #loggerBase #l #defaultConfigPath #makeWebsocket #defaultIsArchiveDevice + #untrustedTimeout + /** @type {Set>}*/ + #pendingTrustedTimers = new Set() /** * @param {Object} opts @@ -175,7 +197,9 @@ 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] */ constructor({ rootKey, @@ -184,11 +208,13 @@ export class MapeoManager extends TypedEmitter { clientMigrationsFolder, coreStorage, fastify, + swarm, defaultConfigPath, customMapPath, 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() @@ -196,6 +222,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) @@ -236,6 +263,30 @@ export class MapeoManager extends TypedEmitter { }) ) + this.#localPeers.on('invite-over-internet-denied', (peerId, deny) => + 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.#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, @@ -289,12 +340,52 @@ export class MapeoManager extends TypedEmitter { logger, }) this.#localDiscovery.on('connection', this.#replicate.bind(this)) + this.#remoteDiscovery = new RemoteDiscovery({ + identityKeypair: this.#keyManager.getIdentityKeypair(), + deriveSwarmIdentityKeypair: () => this.#swarmIdentity, + swarm, + logger, + }) + this.#remoteDiscovery.on('connection', this.#replicate.bind(this)) + + 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.#inviteLinkStore.getSeedTime()) + ) + } + /** * @param {'blobs' | 'icons' | 'maps'} mediaType * @returns {Promise} @@ -329,10 +420,13 @@ export class MapeoManager extends TypedEmitter { } /** - * @param {NoiseSecretStream} noiseStream + * @param {AuthedNoiseStream} noiseStream */ #replicate(noiseStream) { - const replicationStream = this.#localPeers.connect(noiseStream) + const isTrusted = noiseStream.isTrusted + const replicationStream = this.#localPeers.connect(noiseStream, isTrusted) + + noiseStream.resume() openedNoiseSecretStream(noiseStream) .then((openedNoiseStream) => { @@ -346,16 +440,35 @@ export class MapeoManager extends TypedEmitter { features: RPC_FEATURES, } - const peerId = keyToId(openedNoiseStream.remotePublicKey) + const peerId = peerIdFromNoise( + /** @type {AuthedNoiseStream} */ (openedNoiseStream) + ) + + if (!isTrusted) { + const timer = setTimeout(async () => { + this.#pendingTrustedTimers.delete(timer) - return this.#localPeers.sendDeviceInfo(peerId, deviceInfoToSend) + 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 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 ) }) @@ -610,9 +723,11 @@ export class MapeoManager extends TypedEmitter { ...projectKeys, projectMigrationsFolder: this.#projectMigrationsFolder, keyManager: this.#keyManager, + getSwarmPublicKey: () => this.#swarmIdentity.publicKey, sharedDb: this.#db, sharedIndexWriter: this.#projectSettingsIndexWriter, localPeers: this.#localPeers, + inviteLinks: this.#inviteLinkStore, logger: this.#loggerBase, getMediaBaseUrl: this.#getMediaBaseUrl.bind(this), isArchiveDevice, @@ -624,6 +739,18 @@ export class MapeoManager extends TypedEmitter { .where(eq(projectKeysTable.projectId, projectId)) .get()?.projectInfo }, + markInternetPeerAsTrusted: async (deviceId) => { + try { + await this.#localPeers.trustPeer(deviceId) + return true + } catch (e) { + // TODO: check error types + return false + } + }, + disconnectFromPeer: async (deviceId) => { + await this.#remoteDiscovery.disconnectPeer(deviceId) + }, }) return project @@ -719,6 +846,7 @@ export class MapeoManager extends TypedEmitter { projectDescription, sendStats = false, invitorWroteDeviceInfo = false, + leaveOnFail = false, }, { waitForSync = true } = {} ) => { @@ -818,9 +946,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) @@ -1101,7 +1266,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} */ @@ -1116,6 +1281,83 @@ 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 {DenyInviteOverInternet} deny + */ + #handleInviteDenied(peerId, { inviteId, reason }) { + const inviteIdString = inviteId.toString('hex') + this.#l.log( + 'Got deny for invite %S from %S', + inviteIdString.slice(0, 7), + peerId + ) + try { + const err = + reason === 'unknown_invite_id' + ? new UnknownInviteIDError() + : new InviteDeniedByInviterError({ reason }) + 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 + ) + } + } + + /** + * + * @param {string} peerId + * @param {Buffer} inviteId + */ + async #handleRedeemInviteOverInternet(peerId, inviteId) { + const inviteIdString = inviteId.toString('hex') + const invite = await this.#inviteLinkStore.getById(inviteIdString) + + if (!invite) { + 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 + 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, invite) + + 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, @@ -1128,6 +1370,11 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async close() { + for (const timer of this.#pendingTrustedTimers) { + clearTimeout(timer) + } + await this.#inviteLinkStore.close() + await this.#remoteDiscovery.close() // This added for workers PR // await this.#projectSettingsIndexWriter.close() await Promise.all( diff --git a/src/mapeo-project.js b/src/mapeo-project.js index e11131540..0d95fdef2 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -44,6 +44,7 @@ import { } from './utils.js' import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' +import { InviteLinksApiForProject } from './invite/invite-links-api.js' import { MemberApi } from './member-api.js' import { SyncApi, @@ -121,6 +122,7 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({ sendStats: false }) export class MapeoProject extends ReadyResource { #projectKey #deviceId + #getSwarmPublicKey #identityKeypair #coreManager #indexWriter @@ -151,6 +153,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.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 @@ -159,8 +162,11 @@ 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/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 + * @param {(deviceId: string) => Promise} opts.disconnectFromPeer * @param {Logger} [opts.logger] * */ @@ -177,14 +183,19 @@ export class MapeoProject extends ReadyResource { getMediaBaseUrl, makeWebsocket = (url) => new WebSocket(url), localPeers, + inviteLinks, logger, isArchiveDevice, getFallbackProjectInfo, + markInternetPeerAsTrusted, + disconnectFromPeer, + getSwarmPublicKey, }) { super() this.#l = Logger.create('project', logger) this.#deviceId = getDeviceId(keyManager) + this.#getSwarmPublicKey = getSwarmPublicKey this.#projectKey = projectKey this.#importingCategories = false this.#getFallbackProjectInfo = getFallbackProjectInfo @@ -403,19 +414,38 @@ 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, + inviteLinks: new InviteLinksApiForProject( + this.#projectPublicId, + inviteLinks + ), + getSwarmPublicKey: this.#getSwarmPublicKey, 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 + ) }, + markInternetPeerAsTrusted, + disconnectFromPeer, logger: this.#l, }) @@ -598,9 +628,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 624e02954..ca10f3303 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,20 +1,21 @@ 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 { + InviteResponse_Decision, + DenyInviteOverInternet_DenyReason, +} 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' +import { TypedEmitter } from 'tiny-typed-emitter' import { AlreadyBlockedError, DeviceIdNotForServerError, @@ -35,7 +36,13 @@ import { AlreadyInvitingError, InvalidResponseBodyError, RPCDisconnectBeforeAckError, + InvalidInternetInviteURLError, + InviteAlreadyRedeemedError, + InviteNotYetRedeemedError, + PeerDisconnectedSinceRedeemingInviteError, + UnknownInviteIDError, CannotBlockSelfError, + InitialSyncFailedError, } from './errors.js' import { wsCoreReplicator } from './lib/ws-core-replicator.js' import { @@ -47,10 +54,15 @@ import { ROLES, isRoleIdForNewInvite, } from './roles.js' -import { kCreateOrUpdateWithDocId } from './datatype/index.js' +import { DEFAULT_INVITE_EXPIRY_MS } from './invite/invite-links-api.js' +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, @@ -59,16 +71,20 @@ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID] * ProjectSettingsValue, * } 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' */ /** @import { projectSettingsTable } from './schema/client.js' */ -/** @import { ReplicationStream } from './types.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 */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ +/** @typedef {import('./datatype/index.js').ExcludeSchema} NewDeviceInfo */ + /** * @typedef {object} MemberInfo * @prop {string} deviceId @@ -80,77 +96,323 @@ 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 * @prop {DeviceInfo['deviceType']} deviceType */ +/** + * @typedef {object} InviteOptions + * @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] + */ + +/** + * @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 + */ + +/** + * @extends {TypedEmitter} + */ export class MemberApi extends TypedEmitter { #ownDeviceId #roles - #coreOwnership #encryptionKeys - #getProjectName #projectKey #rpc #makeWebsocket #getReplicationStream #waitForInitialSyncWithPeer - #dataTypes + #markInternetPeerAsTrusted + #disconnectFromPeer + #getProjectSettings + #getDeviceInfo + #setDeviceInfo + #getSwarmPublicKey + #inviteLinks #l /** @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 - * @param {import('./roles.js').Roles} opts.roles - * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership + * @param {() => Buffer} opts.getSwarmPublicKey + * @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 {Pick} opts.inviteLinks * @param {(url: string) => WebSocket} [opts.makeWebsocket] * @param {() => ReplicationStream} opts.getReplicationStream * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer - * @param {Object} opts.dataTypes - * @param {Pick} opts.dataTypes.deviceInfo - * @param {Pick} opts.dataTypes.project + * @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 * @param {Logger} [opts.logger] */ constructor({ deviceId, roles, - coreOwnership, encryptionKeys, - getProjectName, projectKey, rpc, + inviteLinks, makeWebsocket = (url) => new WebSocket(url), getReplicationStream, waitForInitialSyncWithPeer, - dataTypes, + markInternetPeerAsTrusted, + disconnectFromPeer, + getProjectSettings, + getDeviceInfo, + setDeviceInfo, + getSwarmPublicKey, 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.#inviteLinks = inviteLinks this.#makeWebsocket = makeWebsocket this.#getReplicationStream = getReplicationStream this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer - this.#dataTypes = dataTypes + this.#markInternetPeerAsTrusted = markInternetPeerAsTrusted + this.#disconnectFromPeer = disconnectFromPeer + this.#getProjectSettings = getProjectSettings + this.#getDeviceInfo = getDeviceInfo + this.#setDeviceInfo = setDeviceInfo + this.#getSwarmPublicKey = getSwarmPublicKey + + this.#rpc.on('peer-remove', this.#handlePeerRemove) + } + + async close() { + this.#rpc.removeListener('peer-remove', this.#handlePeerRemove) + } + + /** + * Start inviting somone over the internet. Returns a URL for the recipient to load. + * @param {InviteOptions} opts + */ + async createInviteLink(opts) { + 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 { 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, + inviteIdBuffer: inviteId, + url, + opts, + }) + + return url + } + + /** + * Cancel an invite over internet attempt. Omit the specific URL to cancel all instances + * @param {string} [url] + */ + async cancelInviteLink(url) { + if (!url) { + await this.#inviteLinks.deleteAll() + return + } + const { inviteIdString } = parseInviteURL(url) + await this.#cancelInviteLinkById(inviteIdString) + } + + /** + * Cancel an invite over internet attempt. + * @param {string} inviteIdString + */ + async #cancelInviteLinkById(inviteIdString) { + if (!(await this.#inviteLinks.getById(inviteIdString))) { + throw new InvalidInternetInviteURLError() + } + this.#redeemedInvites.delete(inviteIdString) + await this.#inviteLinks.delete(inviteIdString) + } + + /** + * Get the list of pending invites over the internet + * @returns {Promise[]>} + */ + async listInviteLinks() { + const invites = await this.#inviteLinks.getAll() + return invites.map(({ url, inviteId, createdAt, expiresAt, roleId }) => ({ + url, + inviteId, + roleId, + createdAt, + expiresAt, + })) + } + + /** + * When a peer disconnects, remove them from the redeemed invites set. + * @param {PeerInfoDisconnected} peer + */ + #handlePeerRemove = async (peer) => { + if (!peer) return + for (const deviceIds of this.#redeemedInvites.values()) { + if (deviceIds.has(peer.deviceId)) { + deviceIds.delete(peer.deviceId) + } + } + } + + /** + * Handle an incoming redeem attempt from the RPC layer. + * @param {string} peerId + * @param {InviteLinkRecord} invite + */ + async [kHandleRedeemInviteOverInternet](peerId, invite) { + const inviteIdString = invite.inviteId + this.#l.log('Got incoming invite redeem %S from %S', inviteIdString, peerId) + + const redeemedSet = this.#redeemedInvites.get(inviteIdString) + if (redeemedSet?.has(peerId)) { + this.#l.log( + 'Incoming invite was already redeemed, disconnecting', + inviteIdString.slice(0, 7) + ) + await this.#disconnectFromPeer(peerId) + + throw new InviteAlreadyRedeemedError() + } + + if (!redeemedSet) { + this.#redeemedInvites.set(inviteIdString, new Set([peerId])) + } else { + redeemedSet.add(peerId) + } + return inviteIdString + } + + /** + * Accept a specific device's attempt at redeeming an invite. + * @param {string} inviteId + * @param {string} deviceId + * @returns {Promise} + */ + async acceptInviteLinkRequest(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() + } + + const stillConnected = await this.#markInternetPeerAsTrusted(deviceId) + + if (!stillConnected) { + throw new PeerDisconnectedSinceRedeemingInviteError() + } + const { roleId, roleName, roleDescription } = pendingInvite + try { + const decision = await this.invite(deviceId, { + roleId, + roleName, + roleDescription, + leaveOnFail: true, + }) + return decision + } catch (e) { + await this.#disconnectFromPeer(deviceId) + throw e + } + } + + /** + * 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, + reason = DenyInviteOverInternet_DenyReason.invitor_denied + ) { + 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'), + reason, + }) + } catch { + // RPC may fail if the peer disconnected, that's ok + } + + await this.#disconnectFromPeer(deviceId) } /** @@ -158,18 +420,8 @@ export class MemberApi extends TypedEmitter { * 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] - * @returns {Promise<( - * typeof InviteResponse_Decision.ACCEPT | - * typeof InviteResponse_Decision.REJECT | - * typeof InviteResponse_Decision.ALREADY - * )>} + * @param {InviteOptions} opts + * @returns {Promise} */ async invite( deviceId, @@ -177,6 +429,7 @@ export class MemberApi extends TypedEmitter { roleId, roleName = ROLES[roleId]?.name, roleDescription, + leaveOnFail = false, __testOnlyInviteId, initialSyncTimeoutMs = 5000, peerInfo, @@ -206,9 +459,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() @@ -232,6 +484,7 @@ export class MemberApi extends TypedEmitter { invitorName, sendStats, invitorWroteDeviceInfo, + leaveOnFail, } const inviteResponse = await this.#sendInviteAndGetResponse( @@ -271,10 +524,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 { @@ -286,6 +536,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 @@ -478,7 +733,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() } @@ -601,22 +856,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>} @@ -632,12 +871,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>} @@ -654,13 +888,10 @@ export class MemberApi extends TypedEmitter { const memberInfo = { deviceId, role } try { - const configCoreId = await this.#coreOwnership.getCoreId( - deviceId, - 'config' + const deviceInfo = await this.#getDeviceInfo(deviceId).catch( + () => undefined ) - const deviceInfo = deviceInfoByConfigCoreId.get(configCoreId) - memberInfo.name = deviceInfo?.name memberInfo.deviceType = deviceInfo?.deviceType memberInfo.joinedAt = deviceInfo?.createdAt diff --git a/src/schema/client.js b/src/schema/client.js index 91a12c90c..fe2bc6f60 100644 --- a/src/schema/client.js +++ b/src/schema/client.js @@ -49,3 +49,16 @@ export const deviceSettingsTable = sqliteTable('deviceSettings', { (text('deviceInfo', { mode: 'json' })), isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }), }) + +export const inviteLinksTable = sqliteTable('inviteLinks', { + 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'), + seedTime: int('seedTime').notNull(), + createdAt: int('createdAt').notNull(), + expiresAt: int('expiresAt').notNull(), +}) 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 e9040d7bd..dded070ef 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) @@ -210,7 +210,7 @@ export class PeerSyncController { */ async #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,14 +300,10 @@ export class PeerSyncController { /** @type {(peer: any) => void} */ const handlePeerRemove = (peer) => { - if (!peer.remotePublicKey.equals(this.peerKey)) 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 f2268b425..45a1c19b3 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -8,16 +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') @@ -447,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() @@ -523,14 +532,14 @@ 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 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 } @@ -564,21 +573,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 } = 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', - protomux.stream.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) @@ -685,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 diff --git a/src/types.ts b/src/types.ts index dfd01ca7a..d4886f4ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,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] @@ -162,7 +163,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 diff --git a/test-e2e/invite-over-internet.js b/test-e2e/invite-over-internet.js new file mode 100644 index 000000000..428a2d019 --- /dev/null +++ b/test-e2e/invite-over-internet.js @@ -0,0 +1,755 @@ +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 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' +import { + UnknownInviteIDRedeemAttemptError, + TimeoutError, + InviteRedeemConnectionClosedError, + ensureKnownError, + InviteNotYetRedeemedError, + InviteDeniedByInviterError, + InviteAbortedError, + JoinProjectCancelledError, + UnknownInviteIDError, +} 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' +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, + }) + 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 { inviteIdString: inviteId } = parseInviteURL(url) + + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) + + const abortOnFailedJoin = new AbortController() + + 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, + }) + onJoinComplete.catch((e) => abortOnFailedJoin.abort(e)) + + invitee.inviteLinks.createJoinRequest(url) + + const [connectedUpdate, [invitedProjectId, deviceId, redeemInviteId]] = + await Promise.all([ + onConnected, + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ), + ]) + + assert.equal(connectedUpdate.inviteId, inviteId) + assert.equal(invitedProjectId, projectId) + assert.equal(deviceId, invitee.deviceId) + + 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 [acceptedUpdate, reason] = await Promise.all([ + onAccepted, + project.$member.acceptInviteLinkRequest(redeemInviteId, deviceId), + ]) + + assert.equal(acceptedUpdate.inviteId, inviteId) + assert.equal(reason, InviteResponse_Decision.ACCEPT) + + const { projectId: gotProjectId } = await onJoinComplete + + assert.equal(gotProjectId, projectId, 'joined expected project') + + // 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) => { + 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.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + await invitor.close() + invitor = createManager('invitor', t, { + coreStorage, + dbFolder, + }) + + project = await invitor.getProject(projectId) + + const pending = await project.$member.listInviteLinks() + + assert.deepEqual( + pending.map((p) => p.url), + [url], + '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 onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) + + const [invitedProjectId, deviceId, redeemInviteId] = + /** @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( + redeemInviteId, + deviceId + ) + + assert.equal(reason, InviteResponse_Decision.ACCEPT) + + const { projectId: gotProjectId } = await onJoinComplete + + 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, 'device_type_unspecified', { + useTestnet: true, + }) + 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.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + const { inviteIdString: inviteId } = parseInviteURL(url) + + // First invitee joins + const onFirstInviteRedeemAttempt = pEvent( + invitor, + 'invite-link-join-request', + { multiArgs: true, timeout: 5000 } + ) + const onFirstJoinComplete = awaitJoinComplete( + invitee1.inviteLinks, + inviteId, + { + timeout: 5000, + } + ) + + invitee1.inviteLinks.createJoinRequest(url) + + 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( + firstInviteId, + firstDeviceId + ) + assert.equal(firstReason, InviteResponse_Decision.ACCEPT) + await onFirstJoinComplete + + // Second invitee joins via the same URL + const onSecondInviteRedeemAttempt = pEvent( + invitor, + 'invite-link-join-request', + { multiArgs: true, timeout: 5000 } + ) + const onSecondJoinComplete = awaitJoinComplete( + invitee2.inviteLinks, + inviteId, + { + timeout: 5000, + } + ) + + invitee2.inviteLinks.createJoinRequest(url) + + 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( + secondInviteId, + secondDeviceId + ) + assert.equal(secondReason, InviteResponse_Decision.ACCEPT) + await onSecondJoinComplete + + // Verify invite is still pending for future redeemers + const pending = await project.$member.listInviteLinks() + 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) => { + 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 parsed = parseInviteURL(url) + const modifiedUrl = makeInviteURL({ + ...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( + awaitJoinComplete(invitee.inviteLinks, inviteId, { timeout: 2000 }), + (err) => ensureKnownError(err).code === TimeoutError.code + ) +}) + +test('invite over internet errors if inviter closes before accepting', 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 { inviteIdString: inviteId } = parseInviteURL(url) + + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 50000, + }) + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 50000, + }) + + invitee.inviteLinks.createJoinRequest(url) + + const [invitedProjectId, deviceId, attemptedRedeemId] = + /** @type {[string, string, string]} */ ( + /**@type unknown*/ (await onInviteRedeemAttempt) + ) + + assert.equal(invitedProjectId, projectId) + assert.equal(deviceId, invitee.deviceId) + + await Promise.all([ + // The invitee's join should fail because the invitor disconnected + assert.rejects( + onJoinComplete, + (err) => + ensureKnownError(err).code === InviteRedeemConnectionClosedError.code + ), + // Close the invitor before accepting + invitor.close(), + ]) + + await assert.rejects( + () => + project.$member.acceptInviteLinkRequest( + attemptedRedeemId, + invitee.deviceId + ), + (err) => ensureKnownError(err).code === InviteNotYetRedeemedError.code, + 'Accepting after a disconnect causes an error' + ) +}) + +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 { inviteIdString: inviteId } = parseInviteURL(url) + + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) + + const [invitedProjectId, deviceId, redeemInviteId] = + /** @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([ + assert.rejects( + onJoinComplete, + (err) => ensureKnownError(err).code === InviteDeniedByInviterError.code + ), + project.$member.denyInviteLinkRequest(redeemInviteId, deviceId), + ]) +}) + +test('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 { inviteIdString: inviteId } = parseInviteURL(url) + + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) + + 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( + onJoinComplete, + (err) => ensureKnownError(err).code === JoinProjectCancelledError.code + ), + invitee.inviteLinks.cancelJoinRequest(inviteId), + ]) + + // 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, + }) + 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 parsed = parseInviteURL(url) + const modifiedUrl = makeInviteURL({ + ...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]>} */ ( + /**@type unknown*/ ( + pEvent(invitor, 'invite-link-join-request-error', { + timeout: 5000, + multiArgs: true, + }) + ) + ) + + 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]) => { + // 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 + onJoinComplete, + (err) => ensureKnownError(err).code === UnknownInviteIDError.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'), + }) + const { inviteIdString: inviteId } = parsed + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + // Start join — it will try to connect to the non-existing peer + 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( + onJoinComplete, + (err) => ensureKnownError(err).code === JoinProjectCancelledError.code + ), + invitee.inviteLinks.cancelJoinRequest(inviteId), + ]) +}) + +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 { inviteIdString: inviteId } = parseInviteURL(url) + + const onInviteRedeemAttempt = pEvent(invitor, 'invite-link-join-request', { + multiArgs: true, + rejectionEvents: ['invite-link-join-request-error'], + timeout: 5000, + }) + + const onJoinComplete = awaitJoinComplete(invitee.inviteLinks, inviteId, { + timeout: 5000, + }) + + invitee.inviteLinks.createJoinRequest(url) + + const [invitedProjectId, deviceId, redeemInviteId] = + /** @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( + redeemInviteId, + deviceId + ) + + await Promise.all([ + assert.rejects(onJoinComplete), + 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 + + invitor.on('invite-link-join-request', (_projectId, deviceId, inviteId) => { + project.$member.acceptInviteLinkRequest(inviteId, deviceId) + }) + + invitee.inviteLinks.createJoinRequest(url) + const { projectId: gotProjectId } = await awaitJoinComplete( + invitee.inviteLinks, + inviteId, + { timeout: 5000 } + ) + + assert.equal(gotProjectId, projectId, 'Invited to project') +}) + +test('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 }) +}) 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) diff --git a/test-e2e/utils.js b/test-e2e/utils.js index f038af8fd..00ec5ece3 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -29,9 +29,11 @@ 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' */ +/** @import { TestNet } from 'hyperdht/testnet.js' */ const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) .pathname @@ -222,22 +224,33 @@ 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 } = {} ) { + /** + * @type {TestNet|null} + */ + let testnet = null + if (useTestnet) { + testnet = await createTestnet(count) + t.after(() => testnet?.destroy()) + } // @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/test-types/data-types.ts b/test-types/data-types.ts index c723d028a..2172d8f0e 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 { InviteLinksApi } from '../dist/invite/invite-links-api.js' type ObservationWithDerivedDocFields = Observation & DerivedDocFields type PresetWithDerivedDocFields = Preset & DerivedDocFields @@ -34,6 +35,7 @@ const mapeoProject = new MapeoProject({ coreStorage: () => new RAM(), keyManager: new KeyManager(randomBytes(32)), projectKey: randomBytes(32), + getSwarmPublicKey: () => randomBytes(32), encryptionKeys: { auth: randomBytes(32) }, sharedDb: drizzle(sqlite), sharedIndexWriter: new IndexWriter({ @@ -44,7 +46,10 @@ const mapeoProject = new MapeoProject({ getMediaBaseUrl: async (mediaType: 'blobs' | 'icons') => `http://127.0.0.1:8080/${mediaType}`, localPeers: new LocalPeers(), + inviteLinks: new InviteLinksApi(drizzle(sqlite), () => Promise.resolve()), getFallbackProjectInfo: () => ({ sendStats: false }), + markInternetPeerAsTrusted: async (_deviceId) => Promise.resolve(true), + disconnectFromPeer: async (_deviceId) => Promise.resolve(), }) ///// Observations diff --git a/test/discovery/remote-discovery.js b/test/discovery/remote-discovery.js new file mode 100644 index 000000000..4d50530f6 --- /dev/null +++ b/test/discovery/remote-discovery.js @@ -0,0 +1,524 @@ +import createTestnet from 'hyperdht/testnet.js' + +import test from 'node:test' +import assert from 'node:assert/strict' +import { KeyManager, keyToPublicId } from '@mapeo/crypto' +import { pEvent } from 'p-event' +import { + RemoteDiscovery, + readHandshakeBuffer, + kTestOnlyHandleHyperswarmConnection, + makeSwarmHandshake, + lengthPrefix, +} from '../../src/discovery/remote-discovery.js' +import { SwarmHandshake } from '../../src/generated/handshake.js' +import { + ensureKnownError, + HandshakeTooLargeError, + UnableToReadHandshakeError, + InvalidIdentityProofError, +} from '../../src/errors.js' +import { Duplex, Transform, Readable } 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() + 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') + + // 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' + ) + + // Verify the public IDs match + const peerId1 = keyToPublicId(identityKeypair1.publicKey) + const peerId2 = keyToPublicId(identityKeypair2.publicKey) + + assert.equal( + keyToPublicId(inboundStream.authenticatedPublicKey), + peerId2, + 'instance 1 connected to correct peer' + ) + assert.equal( + keyToPublicId(outboundStream.authenticatedPublicKey), + peerId1, + 'instance 2 connected to correct peer' + ) + + // Verify remotePublicKey and authenticatedPublicKey are as expected + assert.ok( + inboundStream.remotePublicKey.equals(swarmKeypair2.publicKey), + 'inbound remotePublicKey should match swarmKeypair2' + ) + assert.ok( + 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.authenticatedPublicKey.equals(identityKeypair1.publicKey), + 'outbound authenticatedPublicKey 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() +}) + +test('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'), + pEvent(inboundStream, '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 - 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({ + // @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( + readHandshakeBuffer(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, + deriveSwarmIdentityKeypair: () => 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 = mockConnection( + swarmKeypair2, + // Push the handshake data inside the read handler + lengthPrefix( + SwarmHandshake.encode({ + publicKey: identityKeypair1.publicKey, + signature: Buffer.alloc(64), + }).finish() + ) + ) + + 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('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, + deriveSwarmIdentityKeypair: () => 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 = mockConnection( + swarmKeypair2, + lengthPrefix(Buffer.from('Hello World!')) + ) + + 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('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, + deriveSwarmIdentityKeypair: () => 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.authenticatedPublicKey.equals(identityKeypair2.publicKey), + 'Handshake was valid' + ) +}) + +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() + 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, + 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(() => + 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.authenticatedPublicKey.equals(identityKeypair2.publicKey), + 'peer 1 should see peer 2 identity' + ) + assert.ok( + outboundStream2.authenticatedPublicKey.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.authenticatedPublicKey.equals(identityKeypair3.publicKey), + 'peer 1 should see peer 3 identity' + ) + assert.ok( + outboundStream3.authenticatedPublicKey.equals(identityKeypair1.publicKey), + 'peer 3 should see peer 1 identity' + ) + + inboundStream2.end() + outboundStream2.end() + inboundStream3.end() + outboundStream3.end() +}) + +/** + * @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/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/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/invite-link-joiner.js b/test/invite-link-joiner.js new file mode 100644 index 000000000..043555160 --- /dev/null +++ b/test/invite-link-joiner.js @@ -0,0 +1,163 @@ +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 { Invite, InviteApi } from '../src/invite/invite-api.js' */ + +/** + * @param {Buffer} authenticatedPublicKey + * @returns {RemoteAuthedNoiseStream} + */ +function mockConnection(authenticatedPublicKey) { + const connection = /** @type {RemoteAuthedNoiseStream} */ ( + /** @type {unknown} */ (new Transform()) + ) + connection.authenticatedPublicKey = authenticatedPublicKey + connection.isTrusted = true + connection.remotePublicKey = randomBytes(32) + return connection +} + +/** + * @typedef {object} MockInviteApiOptions + * @property {string} [projectId] Project ID returned by accept (default: random) + */ + +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 authenticatedPublicKey = 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(authenticatedPublicKey) + 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 {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 if we dont have it yet + await onConnecting + assert.equal(updates.length, 2) + assert.equal(updates[0].status, 'connecting') + + // Verify connectPeer was called + assert.deepEqual(connectCalls, [swarmPublicKey.toString('hex')]) + + // Status should now be 'connected' + await onConnected + assert.equal(updates[1].status, 'connected') + + // Verify redeem was sent with correct identity key + assert.equal(redeemCalls.length, 1) + assert.equal( + redeemCalls[0][0], + authenticatedPublicKey.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: authenticatedPublicKey.toString('hex'), + inviteId: inviteId.toString('hex'), + }) + + // Wait for 'accepted' update + await onAccepted + assert.equal(updates[2].status, 'accepted') + + // Wait for 'completed' update + await onCompleted + 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() +}) diff --git a/test/invite-links-api.js b/test/invite-links-api.js new file mode 100644 index 000000000..7ae10b454 --- /dev/null +++ b/test/invite-links-api.js @@ -0,0 +1,1175 @@ +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' +import { + DEFAULT_INVITE_EXPIRY_MS, + InviteLinksApi, + InviteLinksApiForProject, +} from '../src/invite/invite-links-api.js' +import { + MEMBER_ROLE_ID, + COORDINATOR_ROLE_ID, + BLOCKED_ROLE_ID, +} from '../src/roles.js' +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' + +/** + * @typedef {object} TestEnv + * @property {InviteLinksApi} api + * @property {InviteLinksApiForProject} projectApi + * @property {BetterSQLite3Database} db + * @property {(shouldListen: boolean) => Promise} setShouldListenOverInternet + * @property {() => boolean[]} getShouldListenOverInternet - Get ordered call log of setShouldListenOverInternet + */ + +/** + * @typedef {object} SeedOptionals + * @property {number} [createdAt] + * @property {number} [expiresAt] + * @property {number} [seedTime] + * @property {string} projectId + */ + +/** + * @typedef {Omit & SeedOptionals} SeedPendingInvite + */ + +/** + * @param {import('node:test').TestContext} t + * @param {{ seedPendingInvites?: SeedPendingInvite[], expiryMs?: number }} [opts] + * @returns {TestEnv} + */ +function setup( + t, + { seedPendingInvites = [], expiryMs = DEFAULT_INVITE_EXPIRY_MS } = {} +) { + const sqlite = new Database(':memory:') + const db = drizzle(sqlite) + migrate(db, { + migrationsFolder: new URL('../drizzle/client', import.meta.url).pathname, + }) + + // Seed pending invites before creating the API + for (const seed of seedPendingInvites) { + db.insert(inviteLinksTable) + .values({ + ...seed, + createdAt: seed.createdAt ?? Date.now(), + seedTime: seed.seedTime ?? Date.now(), + expiresAt: seed.expiresAt ?? Date.now() + expiryMs, + }) + .run() + } + + /** @type {boolean[]} */ + const shouldListenCalls = [] + /** + * @param {boolean} value + */ + async function setShouldListenOverInternet(value) { + shouldListenCalls.push(value) + } + + function getShouldListenOverInternet() { + return shouldListenCalls + } + + const api = new InviteLinksApi(db, setShouldListenOverInternet, expiryMs) + const projectApi = new InviteLinksApiForProject(PROJECT_ID, api) + + t.after(() => api.close()) + + return { + api, + projectApi, + db, + setShouldListenOverInternet, + getShouldListenOverInternet, + } +} + +test('create() - basic functionality', async (t) => { + const { api } = setup(t) + + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const url = 'https://example.com/invite/abc123' + + await api.create({ + projectId: PROJECT_ID, + 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 (t) => { + const { api } = setup(t) + + 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({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: url1, + opts: { + roleId: MEMBER_ROLE_ID, + }, + }) + + assert.rejects( + api.create({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: url2, + opts: { + roleId: COORDINATOR_ROLE_ID, + roleName: 'Coordinator', + }, + }), + { code: InviteLinkAlreadyExistsError.code }, + 'Second create throws an error' + ) +}) + +test('getById() - non-existent invite', async (t) => { + const { api } = setup(t) + + const result = await api.getById('non-existent-id') + assert.equal(result, undefined, 'returns undefined for non-existent invite') +}) + +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 (t) => { + const { api } = setup(t) + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + const invite3 = 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: PROJECT_ID, + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await api.create({ + projectId: PROJECT_ID, + 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('getAllForProject() - returns only invites for the project', async (t) => { + const { api } = setup(t) + + 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 (t) => { + const { api } = setup(t) + + const invites = await api.getAllForProject(PROJECT_ID) + assert.deepEqual( + invites, + [], + 'returns empty array when project has no invites' + ) +}) + +test('delete() - single invite', 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 }, + }) + + 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 (t) => { + const { api } = setup(t) + + 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 (t) => { + const { api } = setup(t) + + const invite1 = randomBytes(32) + const invite2 = randomBytes(32) + const invite3 = 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: PROJECT_ID, + inviteId: invite2.toString('hex'), + inviteIdBuffer: invite2, + url: 'https://example.com/2', + opts: { roleId: COORDINATOR_ROLE_ID }, + }) + + await api.create({ + projectId: PROJECT_ID, + 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 (t) => { + const { api } = setup(t) + + await api.deleteAll() + + const all = await api.getAll() + assert.equal(all.length, 0, 'no-op on empty database') +}) + +test('deleteAllFrom() - clears invites only for the project', async (t) => { + const { api } = setup(t) + + 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 (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 (t) => { + const { api, db } = setup(t) + + 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(inviteLinksTable).values({ + projectId: PROJECT_ID, + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + roleId: 'invalid-role-id', + createdAt: Date.now(), + seedTime: Date.now(), + expiresAt: Date.now() + DEFAULT_INVITE_EXPIRY_MS, + }) + + 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.getAllForProject(PROJECT_ID), + /Invalid roleId in database/, + 'throws error for invalid roleId on getAllForProject' + ) +}) + +test('Buffer persistence', 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 retrieved = await api.getById(inviteIdString) + assert.ok(retrieved?.inviteIdBuffer.equals(inviteId), 'buffer is identical') +}) + +test('Timestamp verification', async (t) => { + const { api } = setup(t) + + const beforeCreate = Date.now() + 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 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 (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, + // No roleName or roleDescription + }, + }) + + const retrieved = await api.getById(inviteIdString) + assert.equal( + retrieved?.roleName, + undefined, + 'roleName is undefined when not provided' + ) + assert.equal( + retrieved?.roleDescription, + undefined, + 'roleDescription is undefined when not provided' + ) +}) + +test('create() and getAll()', 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 all = await api.getAll() + assert.equal(all.length, 1) + assert.equal(all[0].inviteId, inviteIdString) +}) + +// Tests for InviteLinksApiForProject (scoped wrapper) + +test('InviteLinksApiForProject - create() auto-injects projectId', async (t) => { + const { projectApi } = setup(t) + + 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('InviteLinksApiForProject - getAll() returns scoped invites', async (t) => { + const { api, projectApi } = setup(t) + + 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('InviteLinksApiForProject - delete() removes invite', 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 }, + }) + + await projectApi.delete(inviteIdString) + + const retrieved = await projectApi.getById(inviteIdString) + assert.equal(retrieved, undefined, 'invite deleted') +}) + +test('InviteLinksApiForProject - deleteAll() removes only scoped invites', async (t) => { + const { api, projectApi } = setup(t) + + 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('InviteLinksApiForProject - getById() returns scoped invite', 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 retrieved = await projectApi.getById(inviteIdString) + assert(retrieved, 'invite can be retrieved') + assert.equal(retrieved.inviteId, inviteIdString) +}) + +test('InviteLinksApiForProject - getById() returns undefined for invite in other project', async (t) => { + const { api, projectApi } = setup(t) + + 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('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('InviteLinksApiForProject - delete() is no-op for non-existent invite', async (t) => { + const { projectApi } = setup(t) + + 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('InviteLinksApiForProject - deleteAll() is no-op on empty project', async (t) => { + const { api, projectApi } = setup(t) + + await projectApi.deleteAll() // should not throw + + const all = await api.getAll() + assert.equal(all.length, 0, 'no-op on empty project') +}) + +test('setShouldListenOverInternet - _open() sets true when invites exist', async (t) => { + const inviteId = randomBytes(32) + const { api, getShouldListenOverInternet } = setup(t, { + seedPendingInvites: [ + { + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/invite', + roleId: MEMBER_ROLE_ID, + }, + ], + }) + + await api.ready() + + assert.deepEqual( + getShouldListenOverInternet(), + [true], + 'called with true when invites exist after ready()' + ) +}) + +test('setShouldListenOverInternet - _open() does not call when db is empty', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + await api.ready() + + assert.deepEqual( + getShouldListenOverInternet(), + [], + 'never called when db is empty' + ) +}) + +test('setShouldListenOverInternet - create() sets true on first invite', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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 (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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 }, + }) + + 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)' + ) +}) + +test('setShouldListenOverInternet - create() after failure does not call callback', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + // 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 (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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 (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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, 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)' + ) +}) + +test('setShouldListenOverInternet - deleteAll() sets false regardless of count', async (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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 (t) => { + const { api, getShouldListenOverInternet } = setup(t) + + 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), 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 - 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)' + ) +}) + +const TEST_EXPIRY_MS = 50 // 50ms expiry for testing + +test('clearExpired - general cleanup via schedule', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, + seedPendingInvites: [ + { + projectId: PROJECT_ID, + 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 + }, + ], + }) + + await api.ready() + await new Promise((resolve) => setTimeout(resolve, TEST_EXPIRY_MS * 2)) + + const invites = await api.getAll() + assert.equal(invites.length, 0, 'invite was auto-cleaned by scheduled expiry') +}) + +test('scheduleExpired - creates first invite schedules expiry', async (t) => { + const { api } = setup(t, { expiryMs: TEST_EXPIRY_MS }) + + await api.ready() + const inviteId = randomBytes(32) + await api.create({ + projectId: PROJECT_ID, + inviteId: inviteId.toString('hex'), + inviteIdBuffer: inviteId, + url: 'https://example.com/fresh', + opts: { roleId: MEMBER_ROLE_ID }, + }) + + // Wait for invite to expire and scheduled cleanup to fire + await new Promise((resolve) => setTimeout(resolve, TEST_EXPIRY_MS + 200)) + + const invites = await api.getAll() + assert.equal( + invites.length, + 0, + 'singular invite was auto-cleaned by scheduled expiry' + ) +}) + +test('scheduleExpired - cancelling timer on close', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, + seedPendingInvites: [ + { + projectId: PROJECT_ID, + 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, 'invite still exists (close cancelled timer)') +}) + +test('scheduleExpired - deleting last invite cancels timer', async (t) => { + const { api } = setup(t, { + expiryMs: TEST_EXPIRY_MS, + seedPendingInvites: [ + { + projectId: PROJECT_ID, + 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') + + const invites = await api.getAll() + assert.equal(invites.length, 0, 'invite was 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)' + ) +}) + +// --------------------------------------------------------------------------- +// 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' + ) +}) 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)) -}) diff --git a/test/local-peers.js b/test/local-peers.js index 1616666fa..d0390ccbb 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() @@ -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, } @@ -453,3 +457,241 @@ function testProjectInviteId() { const projectKey = testProjectKey() return projectKeyToProjectInviteId(projectKey) } + +test('untrusted peer sends restricted RPC methods are rejected by receiver', async () => { + const r1 = new LocalPeers() + const r2 = new LocalPeers() + + // 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 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} */ + 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('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() + + // 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') + + // 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') +}) diff --git a/test/member-api.js b/test/member-api.js new file mode 100644 index 000000000..c65fcda7b --- /dev/null +++ b/test/member-api.js @@ -0,0 +1,368 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { KeyManager } from '@mapeo/crypto' + +import { MemberApi } from '../src/member-api.js' +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' */ +/** @import { InviteLinkCreate, InviteLinkRecord } from '../src/invite/invite-links-api.js' */ + +const MAX_URL_LENGTH = 256 + +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) + + assert(url.length < MAX_URL_LENGTH, 'URL smaller than the max') + + const parsedParams = parseInviteURL(url) + + 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({}) + + const url1 = await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const url2 = await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const pending = await member.listInviteLinks() + + assert.deepEqual( + pending.map((p) => p.url).sort(), + [url1, url2].sort(), + 'Both pending URLs returned' + ) + + // Verify persistence + 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') + assert.ok(persistedUrls.includes(url2), 'url2 is persisted') +}) + +test('Cancel invite over internet requests', async () => { + const { member, inviteLinks } = setup({}) + + const url1 = await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + const url2 = await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + // Verify both invites are persisted + let persisted = await inviteLinks.getAll() + assert.equal(persisted.length, 2, 'Two invites persisted initially') + + await member.cancelInviteLink(url1) + + 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() + assert.equal(persisted.length, 1, 'One invite remains after cancel') + assert.equal(persisted[0].url, url2, 'url2 is still persisted') + + await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + await member.createInviteLink({ + roleId: MEMBER_ROLE_ID, + }) + + // Verify 3 invites now persisted + persisted = await inviteLinks.getAll() + assert.equal( + persisted.length, + 3, + 'Three invites persisted after adding two more' + ) + + await member.cancelInviteLink() + + assert.deepEqual( + await member.listInviteLinks().then((r) => r.map((p) => p.url)), + [], + 'No URLs left' + ) + + // Verify persistence is cleared + 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 inviteLinks = new MockInviteLinksApiForProject() + const inviteId = randomBytes(32) + const inviteIdString = inviteId.toString('hex') + const swarmPublicKey = randomBytes(32).toString('hex') + const url = makeInviteURL({ + inviteIdString, + swarmPublicKey, + invitorName: 'hello', + projectName: 'world', + expiresAt: Date.now(), + }) + + // Pre-populate the mock with a pending invite + await inviteLinks.create({ + inviteId: inviteIdString, + inviteIdBuffer: inviteId, + url, + opts: { + roleId: MEMBER_ROLE_ID, + roleName: 'Member', + }, + }) + + const { member } = setup({ + inviteLinks, + }) + + const pending = await member.listInviteLinks() + assert.deepEqual( + pending.map((p) => p.url), + [url], + 'Pending invite loaded from persistence' + ) +}) + +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) { + return ROLES[MEMBER_ROLE_ID] + } + /** + * @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) + } +} + +/** + * In-memory mock of InviteLinksApi for testing + */ +class MockInviteLinksApiForProject { + /** @type {Map} */ + #invites = new Map() + #projectId + + constructor(projectId = 'example') { + this.#projectId = projectId + } + + /** + * Create a new pending invite record + * @param {Omit} data + * @returns {Promise} + */ + async create(data) { + this.#invites.set(data.inviteId, { + inviteId: data.inviteId, + projectId: this.#projectId, + inviteIdBuffer: data.inviteIdBuffer, + url: data.url, + roleId: data.opts.roleId, + roleName: data.opts.roleName, + roleDescription: data.opts.roleDescription, + createdAt: Date.now(), + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + }) + } + + /** + * Get a pending invite by invite ID + * @param {string} inviteId + * @returns {Promise} + */ + async getById(inviteId) { + return this.#invites.get(inviteId) + } + + /** + * Get all pending invites + * @returns {Promise} + */ + async getAll() { + return [...this.#invites.values()] + } + + /** + * @param {string} inviteId + * @returns {Promise} + */ + async delete(inviteId) { + this.#invites.delete(inviteId) + } + + /** + * @returns {Promise} + */ + async deleteAll() { + this.#invites.clear() + } +} + +/** + * + * @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 {(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 {MockInviteLinksApiForProject} [opts.inviteLinks] + * @returns + */ +function setup({ + rootKey = Buffer.alloc(16, 1), + getProjectSettings = () => + Promise.resolve({ name: 'example', sendStats: false }), + 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(), + makeWebsocket = () => { + throw new Error('Not implemented') + }, + getReplicationStream = () => { + throw new Error('Not implemented') + }, + markInternetPeerAsTrusted = () => Promise.resolve(true), + inviteLinks, +} = {}) { + 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 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: finalInviteLinks, + makeWebsocket, + getReplicationStream, + waitForInitialSyncWithPeer, + disconnectFromPeer, + markInternetPeerAsTrusted, + getProjectSettings, + getDeviceInfo, + setDeviceInfo, + getSwarmPublicKey: () => identityKeypair.publicKey, + }) + + return { + rpc, + roles, + member, + projectKey, + inviteLinks: finalInviteLinks, + } +} 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 new file mode 100644 index 000000000..f509d2616 --- /dev/null +++ b/types/hyperswarm.d.ts @@ -0,0 +1,77 @@ +declare module 'hyperswarm' { + import { TypedEmitter } from 'tiny-typed-emitter' + import NoiseSecretStream from '@hyperswarm/secret-stream' + + interface PeerInfo { + publicKey: Buffer + topics: Buffer[] + ban(banStatus: boolean) + } + + interface Keypair { + publicKey: Buffer + secretKey: Buffer + } + + interface SwarmEvents { + connection(socket: NoiseSecretStream, PeerInfo: PeerInfo): void + } + + interface SwarmOpts { + keyPair?: Keypair + maxPeers?: number + dht?: HyperDHT + } + + export default class Hyperswarm extends TypedEmitter { + constructor(swarmOpts: SwarmOpts) + get peers(): Map + get connections(): Set + listen(): Promise + flush(): Promise + suspend(): Promise + resume(): Promise + 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 + } +} + +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 +}