diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml new file mode 100644 index 00000000..046e77b7 --- /dev/null +++ b/.github/workflows/lockfile.yml @@ -0,0 +1,13 @@ +name: Check Lockfile +on: pull_request +jobs: + lockfile: + name: Lockfile check + runs-on: ubuntu-latest + steps: + - name: Check out a copy of the repo + uses: actions/checkout@v4 + - name: Check package-lock.json version has not been changed + uses: mansona/npm-lockfile-version@v1 + with: + version: 3 diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index b645742b..e469eb7e 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -21,9 +21,9 @@ jobs: - '16.x' - '18.x' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - run: npm ci diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000..0e02c2a9 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +# Exit early if this was only a file checkout, not a branch change ($3 == 1) +[ "$3" = 0 ] && exit 0 + +oldRef=$1 +newRef=$2 + + +changed() { + git diff --name-only "$oldRef" "$newRef" | grep "^$1" > /dev/null 2>&1 +} + +if changed 'package-lock.json'; then + echo "📦 package-lock.json changed. Run npm install to bring your dependencies up to date." +fi diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 00000000..cb0dff85 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,9 @@ +#!/usr/bin/env sh + +changed() { + git diff --name-only HEAD@{1} HEAD | grep "^$1" > /dev/null 2>&1 +} + +if changed 'package-lock.json'; then + echo "📦 package-lock.json changed. Run npm install to bring your dependencies up to date." +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..d24fdfc6 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/package-lock.json b/package-lock.json index ea2f52ca..e5a40e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@fastify/type-provider-typebox": "^3.3.0", "@hyperswarm/secret-stream": "^6.1.2", "@mapeo/crypto": "^1.0.0-alpha.8", - "@mapeo/schema": "^3.0.0-next.10", + "@mapeo/schema": "^3.0.0-next.11", "@mapeo/sqlite-indexer": "^1.0.0-alpha.6", "@sinclair/typebox": "^0.29.6", "b4a": "^1.6.3", @@ -42,7 +42,6 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "quickbit-universal": "^2.2.0", - "rpc-reflector": "^1.3.11", "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "sub-encoder": "^2.1.1", @@ -955,14 +954,6 @@ "better-sqlite3": "^8.4.0" } }, - "node_modules/@msgpack/msgpack": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-1.12.2.tgz", - "integrity": "sha512-Vwhc3ObxmDZmA5hY8mfsau2rJ4vGPvzbj20QSZ2/E1GDPF61QVyjLfNHak9xmel6pW4heRt3v1fHa6np9Ehfeg==", - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1988,11 +1979,6 @@ "version": "0.0.1", "license": "MIT" }, - "node_modules/const-max-uint32": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/const-max-uint32/-/const-max-uint32-1.0.2.tgz", - "integrity": "sha512-T8/9bffg5RThuejasJWrwqxs3Q0fsJvyl7/33IB6svroD8JC93E7X60AuuOnDE8RlP6Jlb5FxmlrVDpl9KiU2Q==" - }, "node_modules/convert-source-map": { "version": "1.9.0", "dev": true, @@ -2596,17 +2582,6 @@ } } }, - "node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -4440,21 +4415,6 @@ "graceful-fs": "^4.1.11" } }, - "node_modules/length-prefixed-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/length-prefixed-stream/-/length-prefixed-stream-2.0.0.tgz", - "integrity": "sha512-dvjTuWTKWe0oEznQcG6a9osfiYknCs7DEFJMP88n9Y581IFhYh1sZIgAFcuDOojKB0G7ftPreKhh4D0kh/VPjQ==", - "dependencies": { - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "varint": "^5.0.0" - } - }, - "node_modules/length-prefixed-stream/node_modules/varint": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", - "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==" - }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -6400,43 +6360,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rpc-reflector": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/rpc-reflector/-/rpc-reflector-1.3.11.tgz", - "integrity": "sha512-TIf/RHJy11q/xmNBj0Xj2Z4GVPP1aLeaZXSRtthcMnXNuK+tv7SpZldB5Jk6RFHzA9TgxhcWvLHdHrdlEDKH0w==", - "dependencies": { - "@msgpack/msgpack": "^1.12.1", - "@types/node": "^18.16.19", - "duplexify": "^4.1.2", - "eventemitter3": "^5.0.1", - "is-stream": "^2.0.1", - "length-prefixed-stream": "^2.0.0", - "p-timeout": "^4.1.0", - "pump": "^3.0.0", - "serialize-error": "^8.1.0", - "through2": "^4.0.2", - "validate.io-array-like": "^1.0.2" - } - }, - "node_modules/rpc-reflector/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rpc-reflector/node_modules/p-timeout": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", - "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", - "engines": { - "node": ">=10" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -6582,31 +6505,6 @@ "dev": true, "license": "MIT" }, - "node_modules/serialize-error": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", - "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/set-cookie-parser": { "version": "2.6.0", "dev": true, @@ -6944,11 +6842,6 @@ "tiny-typed-emitter": "^2.1.0" } }, - "node_modules/stream-shift": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" - }, "node_modules/streamx": { "version": "2.15.1", "license": "MIT", @@ -7242,14 +7135,6 @@ "node": ">=12.22" } }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "dependencies": { - "readable-stream": "3" - } - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -7636,28 +7521,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validate.io-array-like": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/validate.io-array-like/-/validate.io-array-like-1.0.2.tgz", - "integrity": "sha512-rGLiN0cvY9OWzQcWP+RtqZR/MK9RUz3gKDTCcRLtEQ/BvlanMF5PyqtVIN+CgrIBCv/ypfme9v7r4yMJPYpbNA==", - "dependencies": { - "const-max-uint32": "^1.0.2", - "validate.io-integer-primitive": "^1.0.0" - } - }, - "node_modules/validate.io-integer-primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/validate.io-integer-primitive/-/validate.io-integer-primitive-1.0.0.tgz", - "integrity": "sha512-4ARGKA4FImVWJgrgttLYsYJmDGwxlhLfDCdq09gyVgohLKKRUfD3VAo1L2vTRCLt6hDhDtFKdZiuYUTWyBggwg==", - "dependencies": { - "validate.io-number-primitive": "^1.0.0" - } - }, - "node_modules/validate.io-number-primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/validate.io-number-primitive/-/validate.io-number-primitive-1.0.0.tgz", - "integrity": "sha512-8rlCe7N0TRTd50dwk4WNoMXNbX/4+RdtqE3TO6Bk0GJvAgbQlfL5DGr/Pl9ZLbWR6CutMjE2cu+yOoCnFWk+Qw==" - }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/package.json b/package.json index 5b28a024..6028cd33 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "db:generate:project": "drizzle-kit generate:sqlite --schema src/schema/project.js --out drizzle/project", "db:generate:client": "drizzle-kit generate:sqlite --schema src/schema/client.js --out drizzle/client", "prepack": "npm run build:types", - "postinstall": "patch-package" + "postinstall": "patch-package", + "prepare": "husky install" }, "files": [ "src", @@ -32,6 +33,12 @@ "semi": false, "singleQuote": true }, + "lint-staged": { + "*.js": [ + "eslint --cache --fix" + ], + "*.{js,css,md}": "prettier --write" + }, "eslintConfig": { "env": { "commonjs": true, @@ -79,7 +86,9 @@ "drizzle-kit": "^0.19.12", "eslint": "^8.39.0", "fastify": "^4.20.0", + "husky": "^8.0.0", "light-my-request": "^5.10.0", + "lint-staged": "^14.0.1", "math-random-seed": "^2.0.0", "nanobench": "^3.0.0", "npm-run-all": "^4.1.5", diff --git a/src/capabilities.js b/src/capabilities.js index f62680ba..64d8345f 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -20,7 +20,7 @@ export const BLOCKED_ROLE_ID = '9e6d29263cba36c9' * @property {string} name * @property {Record} docs * @property {RoleId[]} roleAssignment - * @property {'allowed' | 'blocked'} sync + * @property {Record} sync */ /** @@ -43,7 +43,41 @@ export const CREATOR_CAPABILITIES = { ] }), roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID], - sync: 'allowed', + sync: { + auth: 'allowed', + config: 'allowed', + data: 'allowed', + blobIndex: 'allowed', + blob: 'allowed', + }, +} + +/** + * These are the capabilities assumed for a device when no capability record can + * be found. This can happen when an invited device did not manage to sync with + * the device that invited them, and they then try to sync with someone else. We + * want them to be able to sync the auth and config store, because that way they + * may be able to receive their role record, and they can get the project config + * so that they can start collecting data. + * + * @type {Capability} + */ +export const NO_ROLE_CAPABILITIES = { + name: 'No Role', + docs: mapObject(currentSchemaVersions, (key) => { + return [ + key, + { readOwn: true, writeOwn: true, readOthers: false, writeOthers: false }, + ] + }), + roleAssignment: [], + sync: { + auth: 'allowed', + config: 'allowed', + data: 'blocked', + blobIndex: 'blocked', + blob: 'blocked', + }, } /** @type {Record} */ @@ -57,7 +91,13 @@ export const DEFAULT_CAPABILITIES = { ] }), roleAssignment: [], - sync: 'allowed', + sync: { + auth: 'allowed', + config: 'allowed', + data: 'allowed', + blobIndex: 'allowed', + blob: 'allowed', + }, }, [COORDINATOR_ROLE_ID]: { name: 'Coordinator', @@ -68,7 +108,13 @@ export const DEFAULT_CAPABILITIES = { ] }), roleAssignment: [COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID], - sync: 'allowed', + sync: { + auth: 'allowed', + config: 'allowed', + data: 'allowed', + blobIndex: 'allowed', + blob: 'allowed', + }, }, [BLOCKED_ROLE_ID]: { name: 'Blocked', @@ -84,7 +130,13 @@ export const DEFAULT_CAPABILITIES = { ] }), roleAssignment: [], - sync: 'blocked', + sync: { + auth: 'blocked', + config: 'blocked', + data: 'blocked', + blobIndex: 'blocked', + blob: 'blocked', + }, }, } @@ -135,7 +187,9 @@ export class Capabilities { if (authCoreId === this.#projectCreatorAuthCoreId) { return CREATOR_CAPABILITIES } else { - return DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID] + // When no role assignment exists, e.g. a newly added device which has + // not yet synced role records. + return NO_ROLE_CAPABILITIES } } if (!isKnownRoleId(roleId)) { diff --git a/src/discovery/mdns.js b/src/discovery/mdns.js index 79bc3963..072de699 100644 --- a/src/discovery/mdns.js +++ b/src/discovery/mdns.js @@ -182,10 +182,20 @@ export class MdnsDiscovery extends TypedEmitter { const existing = this.#noiseConnections.get(remoteId) if (existing) { + const keyCompare = Buffer.compare( + this.#identityKeypair.publicKey, + remotePublicKey + ) const keepExisting = + // These first two checks check if a peer tried to connect twice. In + // this case we keep the existing connection. (isInitiator && existing.isInitiator) || (!isInitiator && !existing.isInitiator) || - Buffer.compare(this.#identityKeypair.publicKey, remotePublicKey) > 0 + // If each peer tried to connect to the other at the same time, then we + // tie-break based on public key comparison (the initiator need to check + // the opposite of the non-initiator, because the keys are the other way + // around for them) + (isInitiator ? keyCompare > 0 : keyCompare <= 0) if (keepExisting) { this.#log(`keeping existing, destroying new`) conn.on('error', noop) diff --git a/test-e2e/capabilities.js b/test-e2e/capabilities.js index 9a2ee145..3192c85e 100644 --- a/test-e2e/capabilities.js +++ b/test-e2e/capabilities.js @@ -7,7 +7,6 @@ import { DEFAULT_CAPABILITIES, CREATOR_CAPABILITIES, MEMBER_ROLE_ID, - BLOCKED_ROLE_ID, } from '../src/capabilities.js' import { randomBytes } from 'crypto' @@ -58,9 +57,15 @@ test('New device without capabilities', async (t) => { const ownCapabilities = await project.$getOwnCapabilities() t.alike( - ownCapabilities, - DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID], - 'A new device before sync is blocked' + ownCapabilities.sync, + { + auth: 'allowed', + config: 'allowed', + data: 'blocked', + blobIndex: 'blocked', + blob: 'blocked', + }, + 'A new device before sync can sync auth and config namespaces, but not other namespaces' ) await t.exception(async () => { const deviceId = randomBytes(32).toString('hex') diff --git a/tests/discovery/mdns.js b/tests/discovery/mdns.js index 41b15a2b..f253b39e 100644 --- a/tests/discovery/mdns.js +++ b/tests/discovery/mdns.js @@ -75,24 +75,12 @@ test('deduplicate incoming connections', async (t) => { await discovery.stop({ force: true }) }) -// These tests are failing randomly due to a race condition when de-duplicating connections. -// TODO: Fix the race condition and re-enable these tests, and try to write a test that will consistently reproduce -test.skip(`mdns - discovery of 20 peers with random time instantiation`, async (t) => { - await testMultiple(t, { period: 2000, nPeers: 20 }) +test(`mdns - discovery of 30 peers with random time instantiation`, async (t) => { + await testMultiple(t, { period: 2000, nPeers: 30 }) }) -// These tests are failing randomly due to a race condition when de-duplicating connections. -// TODO: Fix the race condition and re-enable these tests, and try to write a test that will consistently reproduce -test.skip(`mdns - discovery of 20 peers instantiated at the same time`, async (t) => { - await testMultiple(t, { period: 0, nPeers: 20 }) -}) - -test(`mdns - discovery of 3 peers with random time instantiation`, async (t) => { - await testMultiple(t, { period: 2000, nPeers: 3 }) -}) - -test(`mdns - discovery of 3 peers instantiated at the same time`, async (t) => { - await testMultiple(t, { period: 0, nPeers: 3 }) +test(`mdns - discovery of 30 peers instantiated at the same time`, async (t) => { + await testMultiple(t, { period: 0, nPeers: 30 }) }) /**