diff --git a/.c8rc.json b/.c8rc.json index 4304477..620afa6 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -1,8 +1,19 @@ { "all": true, "cache": false, - "extension": [".js"], - "include": ["dist/esm/src/**"], - "exclude": ["dist/esm/src/types/**"], - "reporter": ["text", "cobertura", "html"] -} + "extension": [ + ".js" + ], + "include": [ + "dist/esm/src/**" + ], + "exclude": [ + "dist/esm/src/types/**", + "dist/esm/src/**/*-types.js" + ], + "reporter": [ + "text", + "cobertura", + "html" + ] +} \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index caf385d..d5029b3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -6,7 +6,6 @@ module.exports = { }, plugins: [ '@typescript-eslint', - 'prettier', 'todo-plz', // for enforcing TODO formatting to require "github.com/TBD54566975/dwn-server/issues/" ], env: { @@ -14,7 +13,7 @@ module.exports = { browser: true, }, rules: { - 'prettier/prettier': 'error', + 'max-len': ['error', { code: 150, ignoreStrings: true }], curly: ['error', 'all'], 'no-console': 'off', '@typescript-eslint/explicit-function-return-type': ['error'], @@ -37,5 +36,4 @@ module.exports = { { commentPattern: '.*github.com/TBD54566975/dwn-server/issues/.*' }, ], }, - extends: ['prettier'], }; diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 340c984..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -**/*.yaml \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 18ab2db..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "singleQuote": true, - "plugins": ["@trivago/prettier-plugin-sort-imports"], - "importOrder": [ - "^@tbd54566975/(.*)$", - "", - "^./lib/(.*)$", - "^[./]" - ], - "importOrderCaseInsensitive": true, - "importOrderSeparation": true -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c60619..5e62fe3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,9 +70,9 @@ We take our open-source community seriously. Please adhere to our [Code of Condu ### Code Style -- Our preferred code style has been codified into `eslint` and `prettier`. - - Feel free to take a look onto [eslint config](https://github.com/TBD54566975/dwn-server/blob/main/.eslintrc.cjs) and [prettier config](https://github.com/TBD54566975/dwn-server/blob/main/.prettierrc.json). -- Running `npm run lint:fix` and `npm run prettier:fix`will auto-format as much they can. Everything they weren't able to will be printed out as errors or warnings. +- Our preferred code style has been codified into `eslint`. + - Feel free to take a look onto [eslint config](https://github.com/TBD54566975/dwn-server/blob/main/.eslintrc.cjs). +- Running `npm run lint:fix` will auto-format as much they can. Everything they weren't able to will be printed out as errors or warnings. - We have a pre-commit hook which would run both commands with attempt to autofix problems - It runs by [husky](https://github.com/TBD54566975/dwn-server/blob/main/.husky/pre-commit) and executes [lint-staged command](https://github.com/TBD54566975/dwn-server/blob/main/package.json#L89) - Make sure that no errors/warnings are introduced in your PR @@ -91,7 +91,6 @@ We take our open-source community seriously. Please adhere to our [Code of Condu | `npm run clean` | deletes compiled JS | | `npm run lint` | runs linter | | `npm run lint:fix` | runs linter and fixes auto-fixable problems | -| `npm run prettier:fix` | runs prettier and fixes auto-fixable problems | | `npm run test` | runs tests | | `npm run server` | starts server | | `npm run prepare` | prepares husky for pre-commit hooks (auto-runs with `npm install`) | diff --git a/README.md b/README.md index 0782fe1..7bf861d 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,6 @@ cloudflared tunnel --url http://localhost:3000 | `npm run clean` | deletes compiled JS | | `npm run lint` | runs linter | | `npm run lint:fix` | runs linter and fixes auto-fixable problems | -| `npm run prettier:fix` | runs prettier and fixes auto-fixable problems | | `npm run test` | runs tests | | `npm run server` | starts server | | `npm run prepare` | prepares husky for pre-commit hooks (auto-runs with `npm install`) | @@ -277,15 +276,19 @@ cloudflared tunnel --url http://localhost:3000 Configuration can be set using environment variables -| Env Var | Description | Default | -| ------------------------- | -------------------------------------------------------------------------------------- | ---------------------- | -| `DS_PORT` | Port that the server listens on | `3000` | -| `DS_MAX_RECORD_DATA_SIZE` | maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | -| `DS_WEBSOCKET_SERVER` | whether to enable listening over `ws:`. values: `on`,`off` | `on` | -| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | -| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | -| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | -| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | +| Env Var | Description | Default | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `DS_PORT` | Port that the server listens on | `3000` | +| `DS_MAX_RECORD_DATA_SIZE` | Maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | +| `DS_WEBSOCKET_SERVER` | Whether to enable listening over `ws:`. values: `on`,`off` | `on` | +| `DWN_REGISTRATION_STORE_URL` | URL to use for storage of registered DIDs | `sqlite://data/dwn.db` | +| `DWN_REGISTRATION_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | +| `DWN_REGISTRATION_PROOF_OF_WORK_INITIAL_MAX_HASH` | Initial maximum allowed hash in 64 char HEX string. The more leading zeros (smaller number) the higher the difficulty. | `false` | +| `DWN_TERMS_OF_SERVICE_FILE_PATH` | Required terms of service agreement if set. Value is path to the terms of service file. | unset | +| `DWN_STORAGE` | URL to use for storage by default. See [Storage Options](#storage-options) for details | `level://data` | +| `DWN_STORAGE_MESSAGES` | URL to use for storage of messages. | value of `DWN_STORAGE` | +| `DWN_STORAGE_DATA` | URL to use for data storage | value of `DWN_STORAGE` | +| `DWN_STORAGE_EVENTS` | URL to use for event storage | value of `DWN_STORAGE` | ### Storage Options @@ -297,3 +300,26 @@ Several storage formats are supported, and may be configured with the `DWN_STORA | Sqlite | `sqlite://dwn.db` | use three slashes for absolute paths, two for relative. Example shown creates a file `dwn.db` in the current working directory | | MySQL | `mysql://user:pass@host/db?debug=true&timezone=-0700` | [all URL options documented here](https://github.com/mysqljs/mysql#connection-options) | | PostgreSQL | `postgres:///dwn` | any options other than the URL scheme (`postgres://`) may also be specified via [standard environment variables](https://node-postgres.com/features/connecting#environment-variables) | + +## Registration Requirements + +There are multiple optional registration gates, each of which can be enabled (all are disabled by default). Tenants (DIDs) must comply with whatever +requirements are enabled before they are allowed to use the server. Tenants that have not completed the registration requirements will be met with a 401. Note that registration is tracked in a database, and only SQL-based databases are supported (LevelDB is not supported). Current registration +requirements are available at the `/info` endpoint. + +- **Proof of Work** (`DWN_REGISTRATION_PROOF_OF_WORK_ENABLED=true`) - new tenants must GET `/registration/proof-of-work` for a challenge, then generate a nonce that produces a string that has a sha256 hex sum starting with the specified (`complexity`) number of zeros (`0`) when added to the end of the challenge (`sha256(challenge + nonce)`). This nonce should be POSTed to `/registration/proof-of-work` with a JSON body including the `challenge`, the nonce in field `response` and `did`. Challenges expire after 5 minutes, and complexity will increase based on the number of successful proof-of-work registrations that have been completed within the last hour. This registration requirement is listed in `/info` as `proof-of-work-sha256-v0`. +- **Terms of Service** (`DWN_TERMS_OF_SERVICE_FILE_PATH=/path/to/terms-of-service.txt`) - new tenants must GET `/registration/terms-of-service` to fetch the terms. These terms must be displayed to the human end-user, who must actively accept them. When the user accepts the terms, send the sha256 hash of the accepted terms and the user's did via POST `/registration/terms-of-service`. The JSON body should have fields `termsOfServiceHash` and `did`. To change the terms, update the file and restart the server. Users that accepted the old terms will be blocked until they accept the new terms. This registration requirement is listed in `/info` as `terms-of-service`. + +## Server info + +the server exposes information about itself via the `/info` endpoint, which returns data in the following format: + +```json +{ + "server": "@web5/dwn-server", + "maxFileSize": 1073741824, + "registrationRequirements": ["proof-of-work-sha256-v0", "terms-of-service"], + "version": "0.1.5", + "sdkVersion": "0.2.6" +} +``` diff --git a/package-lock.json b/package-lock.json index eb72a04..b42c2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "@web5/dwn-server", "version": "0.1.9", "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.10", + "@tbd54566975/dwn-sdk-js": "0.2.12", "@tbd54566975/dwn-sql-store": "0.2.6", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", @@ -31,7 +33,6 @@ "dwn-server": "dist/esm/src/main.js" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/bytes": "3.1.1", "@types/chai": "4.3.4", "@types/express": "4.17.17", @@ -48,10 +49,8 @@ "crypto-browserify": "^3.12.0", "esbuild": "0.16.17", "eslint": "8.33.0", - "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "2.26.0", "eslint-plugin-mocha": "10.1.0", - "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-todo-plz": "^1.3.0", "http-proxy": "^1.18.1", "husky": "^8.0.0", @@ -61,7 +60,6 @@ "karma-mocha": "^2.0.1", "lint-staged": "^14.0.1", "mocha": "^10.2.0", - "prettier": "3.0.3", "puppeteer": "^21.4.0", "sinon": "16.1.0", "stream-browserify": "^3.0.0", @@ -96,117 +94,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator": { - "version": "7.17.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.7.tgz", - "integrity": "sha512-oLcVCTeIFadUoArDTwpluncplrYBmTCCZZgXCbgNGvOBBiSDDK3eWO4b/+eOTli5tKv1lg+a5/NAXg+nTcei1w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.17.0", - "jsesc": "^2.5.1", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", @@ -230,109 +117,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", - "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -545,20 +329,6 @@ "node": ">=8" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", @@ -568,15 +338,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -714,26 +475,6 @@ "node": ">= 8" } }, - "node_modules/@pkgr/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "fast-glob": "^3.3.0", - "is-glob": "^4.0.3", - "open": "^9.1.0", - "picocolors": "^1.0.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/@puppeteer/browsers": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.8.0.tgz", @@ -828,9 +569,9 @@ "dev": true }, "node_modules/@tbd54566975/dwn-sdk-js": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.10.tgz", - "integrity": "sha512-CoKO8+NciwWNzD4xRoAAgeElqQCXKM4Fc+zEHsUWD0M3E9v67hRWiTHI6AenUfQv1RSEB2H4GHUeUOHuEV72uw==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.12.tgz", + "integrity": "sha512-Y1ENGZcaHyqc+NG+EXFAN7k/zRqJ5JuBr9cbkpupqUuhwN9Xjdej8uuoGviHx/72jhpx9dKfcCBeHztiTMMZOg==", "dependencies": { "@ipld/dag-cbor": "9.0.3", "@js-temporal/polyfill": "0.4.4", @@ -896,6 +637,78 @@ "npm": ">=7.0.0" } }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.2.10.tgz", + "integrity": "sha512-CoKO8+NciwWNzD4xRoAAgeElqQCXKM4Fc+zEHsUWD0M3E9v67hRWiTHI6AenUfQv1RSEB2H4GHUeUOHuEV72uw==", + "dependencies": { + "@ipld/dag-cbor": "9.0.3", + "@js-temporal/polyfill": "0.4.4", + "@noble/ed25519": "2.0.0", + "@noble/secp256k1": "2.0.0", + "abstract-level": "1.0.3", + "ajv": "8.12.0", + "blockstore-core": "4.2.0", + "cross-fetch": "4.0.0", + "eciesjs": "0.4.5", + "interface-blockstore": "5.2.3", + "interface-store": "5.1.2", + "ipfs-unixfs-exporter": "13.1.5", + "ipfs-unixfs-importer": "15.1.5", + "level": "8.0.0", + "lodash": "4.17.21", + "lru-cache": "9.1.2", + "ms": "2.1.3", + "multiformats": "11.0.2", + "randombytes": "2.1.0", + "readable-stream": "4.4.2", + "ulidx": "2.1.0", + "uuid": "8.3.2", + "varint": "6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js/node_modules/@ipld/dag-cbor": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-9.0.3.tgz", + "integrity": "sha512-A2UFccS0+sARK9xwXiVZIaWbLbPxLGP3UZOjBeOMWfDY04SXi8h1+t4rHBzOlKYF/yWNm3RbFLyclWO7hZcy4g==", + "dependencies": { + "cborg": "^2.0.1", + "multiformats": "^12.0.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js/node_modules/@ipld/dag-cbor/node_modules/multiformats": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-12.1.3.tgz", + "integrity": "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw==", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js/node_modules/cborg": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-2.0.5.tgz", + "integrity": "sha512-xVW1rSIw1ZXbkwl2XhJ7o/jAv0vnVoQv/QlfQxV8a7V5PlA4UU/AcIiXqmpyybwNWy/GPQU1m/aBVNIWr7/T0w==", + "bin": { + "cborg": "cli.js" + } + }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/@tbd54566975/dwn-sdk-js/node_modules/multiformats": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", + "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@tbd54566975/dwn-sql-store/node_modules/cborg": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.0.5.tgz", @@ -913,35 +726,20 @@ "npm": ">=7.0.0" } }, + "node_modules/@tbd54566975/dwn-sql-store/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true }, - "node_modules/@trivago/prettier-plugin-sort-imports": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz", - "integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==", - "dev": true, - "dependencies": { - "@babel/generator": "7.17.7", - "@babel/parser": "^7.20.5", - "@babel/traverse": "7.23.2", - "@babel/types": "7.17.0", - "javascript-natural-sort": "0.7.1", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "@vue/compiler-sfc": "3.x", - "prettier": "2.x - 3.x" - }, - "peerDependenciesMeta": { - "@vue/compiler-sfc": { - "optional": true - } - } - }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -1694,15 +1492,6 @@ "prebuild-install": "^7.1.1" } }, - "node_modules/big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1770,12 +1559,12 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -1783,7 +1572,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -1805,18 +1594,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "dev": true, - "dependencies": { - "big-integer": "^1.6.44" - }, - "engines": { - "node": ">= 5.10.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2009,21 +1786,6 @@ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", "dev": true }, - "node_modules/bundle-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", - "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", - "dev": true, - "dependencies": { - "run-applescript": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2797,40 +2559,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/default-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", - "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", - "dev": true, - "dependencies": { - "bundle-name": "^3.0.0", - "default-browser-id": "^3.0.0", - "execa": "^7.1.1", - "titleize": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", - "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", - "dev": true, - "dependencies": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", @@ -2844,18 +2572,6 @@ "node": ">= 0.4" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3415,20 +3131,8 @@ "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-import-resolver-node": { @@ -3547,35 +3251,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, "node_modules/eslint-plugin-todo-plz": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-todo-plz/-/eslint-plugin-todo-plz-1.3.0.tgz", @@ -3998,6 +3673,29 @@ "node": ">= 0.10.0" } }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4011,6 +3709,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4076,12 +3788,6 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -4279,9 +3985,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "dev": true, "funding": [ { @@ -4579,15 +4285,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/globalthis": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", @@ -5241,21 +4938,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5289,24 +4971,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -5475,33 +5139,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-wsl/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5683,12 +5320,6 @@ "npm": ">=7.0.0" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "dev": true - }, "node_modules/js-sdsl": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz", @@ -5722,18 +5353,6 @@ "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==" }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7192,24 +6811,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", - "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", - "dev": true, - "dependencies": { - "default-browser": "^4.0.0", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -7557,12 +7158,6 @@ "split2": "^4.1.0" } }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -7656,33 +7251,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", - "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -7994,9 +7562,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -8235,110 +7803,6 @@ "inherits": "^2.0.1" } }, - "node_modules/run-applescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", - "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/run-applescript/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/run-applescript/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==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/run-applescript/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/run-applescript/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8846,15 +8310,6 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sparse-array": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/sparse-array/-/sparse-array-1.3.2.tgz", @@ -9160,22 +8615,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/synckit": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz", - "integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==", - "dev": true, - "dependencies": { - "@pkgr/utils": "^2.3.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -9282,18 +8721,6 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, - "node_modules/titleize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", - "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -9306,15 +8733,6 @@ "node": ">=8.17.0" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9641,15 +9059,6 @@ "node": ">= 0.8" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index a244bc5..f941729 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,14 @@ "url": "https://github.com/TBD54566975/dwn-server/issues" }, "dependencies": { - "@tbd54566975/dwn-sdk-js": "0.2.10", + "@tbd54566975/dwn-sdk-js": "0.2.12", "@tbd54566975/dwn-sql-store": "0.2.6", "better-sqlite3": "^8.5.0", + "body-parser": "^1.20.2", "bytes": "3.1.2", "cors": "2.8.5", "express": "4.18.2", + "kysely": "^0.26.3", "loglevel": "^1.8.1", "loglevel-plugin-prefix": "^0.8.4", "multiformats": "11.0.2", @@ -46,7 +48,6 @@ "ws": "8.12.0" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/bytes": "3.1.1", "@types/chai": "4.3.4", "@types/express": "4.17.17", @@ -63,10 +64,8 @@ "crypto-browserify": "^3.12.0", "esbuild": "0.16.17", "eslint": "8.33.0", - "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "2.26.0", "eslint-plugin-mocha": "10.1.0", - "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-todo-plz": "^1.3.0", "http-proxy": "^1.18.1", "husky": "^8.0.0", @@ -76,7 +75,6 @@ "karma-mocha": "^2.0.1", "lint-staged": "^14.0.1", "mocha": "^10.2.0", - "prettier": "3.0.3", "puppeteer": "^21.4.0", "sinon": "16.1.0", "stream-browserify": "^3.0.0", @@ -90,7 +88,6 @@ "clean": "rimraf dist && rimraf generated/*", "lint": "eslint . --ext .ts --max-warnings 0", "lint:fix": "eslint . --ext .ts --fix", - "prettier:fix": "prettier . --write --ignore-unknown", "test": "npm run build:esm && cp -R tests/fixtures dist/esm/tests && c8 mocha", "server": "npm run build:esm && node dist/esm/src/main.js", "prepare": "husky install" @@ -102,9 +99,7 @@ }, "lint-staged": { "*.{js,ts}": [ - "eslint --fix", - "prettier --ignore-unknown --write" - ], - "*": "prettier --ignore-unknown --write" + "eslint --fix" + ] } -} +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 4e52380..44d71da 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,17 +8,17 @@ export const config = { // port that server listens on port: parseInt(process.env.DS_PORT || '3000'), // whether to enable 'ws:' - webSocketServerEnabled: - { on: true, off: false }[process.env.DS_WEBSOCKET_SERVER] ?? true, - // where to store persistant data - messageStore: - process.env.DWN_STORAGE_MESSAGES || - process.env.DWN_STORAGE || - 'level://data', - dataStore: - process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', - eventLog: - process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + webSocketServerEnabled: { on: true, off: false }[process.env.DS_WEBSOCKET_SERVER] ?? true, + // where to store persistent data + messageStore: process.env.DWN_STORAGE_MESSAGES || process.env.DWN_STORAGE || 'level://data', + dataStore: process.env.DWN_STORAGE_DATA || process.env.DWN_STORAGE || 'level://data', + eventLog: process.env.DWN_STORAGE_EVENTS || process.env.DWN_STORAGE || 'level://data', + + // tenant registration feature configuration + registrationStoreUrl: process.env.DWN_REGISTRATION_STORE_URL || process.env.DWN_STORAGE || 'sqlite://data/dwn.db', + registrationProofOfWorkEnabled: process.env.DWN_REGISTRATION_PROOF_OF_WORK_ENABLED === 'true', + registrationProofOfWorkInitialMaxHash: process.env.DWN_REGISTRATION_PROOF_OF_WORK_INITIAL_MAX_HASH, + termsOfServiceFilePath: process.env.DWN_TERMS_OF_SERVICE_FILE_PATH, // log level - trace/debug/info/warn/error logLevel: process.env.DWN_SERVER_LOG_LEVEL || 'INFO', diff --git a/src/dwn-error.ts b/src/dwn-error.ts new file mode 100644 index 0000000..fa19159 --- /dev/null +++ b/src/dwn-error.ts @@ -0,0 +1,36 @@ +/** + * A class that represents a DWN Server error. + */ +export class DwnServerError extends Error { + constructor( + public code: string, + message: string, + ) { + super(`${code}: ${message}`); + + this.name = 'DwnServerError'; + } + + /** + * Called by `JSON.stringify(...)` automatically. + */ + public toJSON(): { code: string, message: string } { + return { + code: this.code, + message: this.message, + }; + } +} + +/** + * DWN Server error codes. + */ +export enum DwnServerErrorCode { + ProofOfWorkInsufficientSolutionNonce = 'ProofOfWorkInsufficientSolutionNonce', + ProofOfWorkInvalidOrExpiredChallenge = 'ProofOfWorkInvalidOrExpiredChallenge', + ProofOfWorkManagerInvalidChallengeNonce = 'ProofOfWorkManagerInvalidChallengeNonce', + ProofOfWorkManagerInvalidResponseNonceFormat = 'ProofOfWorkManagerInvalidResponseNonceFormat', + ProofOfWorkManagerResponseNonceReused = 'ProofOfWorkManagerResponseNonceReused', + RegistrationManagerInvalidOrOutdatedTermsOfServiceHash = 'RegistrationManagerInvalidOrOutdatedTermsOfServiceHash', + TenantRegistrationOutdatedTermsOfService = 'TenantRegistrationOutdatedTermsOfService', +} diff --git a/src/dwn-server.ts b/src/dwn-server.ts index c54c2a3..8d55674 100644 --- a/src/dwn-server.ts +++ b/src/dwn-server.ts @@ -12,6 +12,7 @@ import { HttpApi } from './http-api.js'; import { setProcessHandlers } from './process-handlers.js'; import { getDWNConfig } from './storage.js'; import { WsApi } from './ws-api.js'; +import { RegistrationManager } from './registration/registration-manager.js'; export type DwnServerOptions = { dwn?: Dwn; @@ -25,6 +26,9 @@ export class DwnServer { #httpApi: HttpApi; #wsApi: WsApi; + /** + * @param options.dwn - Dwn instance to use as an override. Registration endpoint will not be enabled if this is provided. + */ constructor(options: DwnServerOptions = {}) { this.config = options.config ?? defaultConfig; this.dwn = options.dwn; @@ -46,12 +50,21 @@ export class DwnServer { * The DWN creation is secondary and only happens if it hasn't already been done. */ async #setupServer(): Promise { + + let registrationManager: RegistrationManager; if (!this.dwn) { - this.dwn = await Dwn.create(getDWNConfig(this.config)); + registrationManager = await RegistrationManager.create({ + registrationStoreUrl: this.config.registrationStoreUrl, + termsOfServiceFilePath: this.config.termsOfServiceFilePath, + initialMaximumAllowedHashValue: this.config.registrationProofOfWorkInitialMaxHash, + }); + + this.dwn = await Dwn.create(getDWNConfig(this.config, registrationManager)); } - this.#httpApi = new HttpApi(this.dwn); - this.#httpApi.start(this.config.port, () => { + this.#httpApi = new HttpApi(this.config, this.dwn, registrationManager); + + await this.#httpApi.start(this.config.port, () => { log.info(`HttpServer listening on port ${this.config.port}`); }); diff --git a/src/http-api.ts b/src/http-api.ts index d40d90e..351e863 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -1,12 +1,9 @@ -import { - type Dwn, - RecordsRead, - type RecordsReadReply, -} from '@tbd54566975/dwn-sdk-js'; +import { type Dwn, RecordsRead, type RecordsReadReply } from '@tbd54566975/dwn-sdk-js'; import cors from 'cors'; import type { Express, Request, Response } from 'express'; import express from 'express'; +import { readFileSync } from 'fs'; import http from 'http'; import log from 'loglevel'; import { register } from 'prom-client'; @@ -15,24 +12,36 @@ import { v4 as uuidv4 } from 'uuid'; import type { RequestContext } from './lib/json-rpc-router.js'; import type { JsonRpcRequest } from './lib/json-rpc.js'; -import { - createJsonRpcErrorResponse, - JsonRpcErrorCodes, -} from './lib/json-rpc.js'; +import { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; +import type { Config } from './config.js'; +import { config } from './config.js'; +import { type DwnServerError } from './dwn-error.js'; import { jsonRpcApi } from './json-rpc-api.js'; import { requestCounter, responseHistogram } from './metrics.js'; +import type { RegistrationManager } from './registration/registration-manager.js'; + +const packageJson = process.env.npm_package_json ? JSON.parse(readFileSync(process.env.npm_package_json).toString()) : {}; export class HttpApi { + #config: Config; #api: Express; #server: http.Server; + registrationManager: RegistrationManager; dwn: Dwn; - constructor(dwn: Dwn) { + constructor(config: Config, dwn: Dwn, registrationManager: RegistrationManager) { + console.log(config); + + this.#config = config; this.#api = express(); this.#server = http.createServer(this.#api); this.dwn = dwn; + if (registrationManager !== undefined) { + this.registrationManager = registrationManager; + } + this.#setupMiddleware(); this.#setupRoutes(); } @@ -47,6 +56,7 @@ export class HttpApi { #setupMiddleware(): void { this.#api.use(cors({ exposedHeaders: 'dwn-response' })); + this.#api.use(express.json()); this.#api.use( responseTime((req: Request, res: Response, time) => { @@ -84,10 +94,7 @@ export class HttpApi { const record = await RecordsRead.create({ filter: { recordId: req.params.id }, }); - const reply = (await this.dwn.processMessage( - req.params.did, - record.toJSON(), - )) as RecordsReadReply; + const reply = (await this.dwn.processMessage(req.params.did, record.toJSON())) as RecordsReadReply; if (reply.status.code === 200) { if (reply?.record?.data) { @@ -111,20 +118,14 @@ export class HttpApi { this.#api.get('/', (_req, res) => { // return a plain text string res.setHeader('content-type', 'text/plain'); - return res.send( - 'please use a web5 client, for example: https://github.com/TBD54566975/web5-js ', - ); + return res.send('please use a web5 client, for example: https://github.com/TBD54566975/web5-js '); }); this.#api.post('/', async (req: Request, res) => { const dwnRequest = req.headers['dwn-request'] as any; if (!dwnRequest) { - const reply = createJsonRpcErrorResponse( - uuidv4(), - JsonRpcErrorCodes.BadRequest, - 'request payload required.', - ); + const reply = createJsonRpcErrorResponse(uuidv4(), JsonRpcErrorCodes.BadRequest, 'request payload required.'); return res.status(400).json(reply); } @@ -133,11 +134,7 @@ export class HttpApi { try { dwnRpcRequest = JSON.parse(dwnRequest); } catch (e) { - const reply = createJsonRpcErrorResponse( - uuidv4(), - JsonRpcErrorCodes.BadRequest, - e.message, - ); + const reply = createJsonRpcErrorResponse(uuidv4(), JsonRpcErrorCodes.BadRequest, e.message); return res.status(400).json(reply); } @@ -145,21 +142,14 @@ export class HttpApi { // Check whether data was provided in the request body const contentLength = req.headers['content-length']; const transferEncoding = req.headers['transfer-encoding']; - const requestDataStream = - parseInt(contentLength) > 0 || transferEncoding !== undefined - ? req - : undefined; + const requestDataStream = parseInt(contentLength) > 0 || transferEncoding !== undefined ? req : undefined; const requestContext: RequestContext = { - dwn: this.dwn, - transport: 'http', - dataStream: requestDataStream, + dwn : this.dwn, + transport : 'http', + dataStream : requestDataStream, }; - const { jsonRpcResponse, dataStream: responseDataStream } = - await jsonRpcApi.handle( - dwnRpcRequest, - requestContext as RequestContext, - ); + const { jsonRpcResponse, dataStream: responseDataStream } = await jsonRpcApi.handle(dwnRpcRequest, requestContext as RequestContext); // If the handler catches a thrown exception and returns a JSON RPC InternalError, return the equivalent // HTTP 500 Internal Server Error with the response. @@ -169,8 +159,8 @@ export class HttpApi { } requestCounter.inc({ - method: dwnRpcRequest.method, - status: jsonRpcResponse?.result?.reply?.status?.code || 0, + method : dwnRpcRequest.method, + status : jsonRpcResponse?.result?.reply?.status?.code || 0, }); if (responseDataStream) { res.setHeader('content-type', 'application/octet-stream'); @@ -181,13 +171,68 @@ export class HttpApi { return res.json(jsonRpcResponse); } }); + + this.#setupRegistrationRoutes(); + + this.#api.get('/info', (req, res) => { + res.setHeader('content-type', 'application/json'); + const registrationRequirements: string[] = []; + if (config.registrationProofOfWorkEnabled) { + registrationRequirements.push('proof-of-work-sha256-v0'); + } + if (config.termsOfServiceFilePath !== undefined) { + registrationRequirements.push('terms-of-service'); + } + + res.json({ + server : process.env.npm_package_name, + maxFileSize : config.maxRecordDataSize, + registrationRequirements : registrationRequirements, + version : packageJson.version, + sdkVersion : packageJson.dependencies['@tbd54566975/dwn-sdk-js'], + }); + }); } #listen(port: number, callback?: () => void): void { this.#server.listen(port, callback); } - start(port: number, callback?: () => void): http.Server { + #setupRegistrationRoutes(): void { + if (this.#config.registrationProofOfWorkEnabled) { + this.#api.get('/registration/proof-of-work', async (_req: Request, res: Response) => { + const proofOfWorkChallenge = this.registrationManager.getProofOfWorkChallenge(); + res.json(proofOfWorkChallenge); + }); + } + + if (this.#config.termsOfServiceFilePath !== undefined) { + this.#api.get('/registration/terms-of-service', (_req: Request, res: Response) => res.send(this.registrationManager.getTermsOfService())); + } + + if (this.#config.registrationProofOfWorkEnabled || this.#config.termsOfServiceFilePath !== undefined) { + this.#api.post('/registration', async (req: Request, res: Response) => { + const requestBody = req.body; + console.log('Registration request:', requestBody); + + try { + await this.registrationManager.handleRegistrationRequest(requestBody); + res.status(200).json({ success: true }); + } catch (error) { + const dwnServerError = error as DwnServerError; + + if (dwnServerError.code !== undefined) { + res.status(400).json(dwnServerError); + } else { + console.log('Error handling registration request:', error); + res.status(500).json({ success: false }); + } + } + }); + } + } + + async start(port: number, callback?: () => void): Promise { this.#listen(port, callback); return this.#server; } diff --git a/src/json-rpc-handlers/dwn/process-message.ts b/src/json-rpc-handlers/dwn/process-message.ts index 30597e3..09be116 100644 --- a/src/json-rpc-handlers/dwn/process-message.ts +++ b/src/json-rpc-handlers/dwn/process-message.ts @@ -25,7 +25,7 @@ export const handleDwnProcessMessage: JsonRpcHandler = async ( const reply = (await dwn.processMessage( target, message, - dataStream as IsomorphicReadable, + { dataStream: dataStream as IsomorphicReadable }, )) as RecordsReadReply; // RecordsRead messages return record data as a stream to for accommodate large amounts of data diff --git a/src/registration/proof-of-work-manager.ts b/src/registration/proof-of-work-manager.ts new file mode 100644 index 0000000..14554b5 --- /dev/null +++ b/src/registration/proof-of-work-manager.ts @@ -0,0 +1,291 @@ +import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; +import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; +import { ProofOfWork } from "./proof-of-work.js"; + +/** + * Manages proof-of-work challenge difficulty and lifecycle based on solve rate. + * Can have multiple instances each having their own desired solve rate and difficulty. + */ +export class ProofOfWorkManager { + // Takes from seconds to ~1 minute to solve on an M1 MacBook. + private static readonly defaultMaximumAllowedHashValue = '000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; + + // Challenge nonces that can be used for proof-of-work. + private challengeNonces: { currentChallengeNonce: string, previousChallengeNonce?: string }; + + // There is opportunity to improve implementation here. + // TODO: https://github.com/TBD54566975/dwn-server/issues/101 + private proofOfWorkOfLastMinute: Map = new Map(); // proofOfWorkId -> timestamp of proof-of-work + + private difficultyIncreaseMultiplier: number; + private currentMaximumAllowedHashValueAsBigInt: bigint; + private initialMaximumAllowedHashValueAsBigInt: bigint; + private desiredSolveCountPerMinute: number; + + /** + * How often the challenge nonce is refreshed. + */ + public challengeRefreshFrequencyInSeconds: number; + + /** + * How often the difficulty is reevaluated. + */ + public difficultyReevaluationFrequencyInSeconds: number; + + /** + * The current maximum allowed hash value. + */ + public get currentMaximumAllowedHashValue(): bigint { + return this.currentMaximumAllowedHashValueAsBigInt; + } + + /** + * The current proof-of-work solve rate. + */ + public get currentSolveCountPerMinute(): number { + return this.proofOfWorkOfLastMinute.size; + } + + private constructor (input: { + desiredSolveCountPerMinute: number, + initialMaximumAllowedHashValue: string, + difficultyIncreaseMultiplier: number, + challengeRefreshFrequencyInSeconds: number, + difficultyReevaluationFrequencyInSeconds: number + }) { + const { desiredSolveCountPerMinute, initialMaximumAllowedHashValue } = input; + + this.challengeNonces = { currentChallengeNonce: ProofOfWork.generateNonce() }; + this.currentMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); + this.initialMaximumAllowedHashValueAsBigInt = BigInt(`0x${initialMaximumAllowedHashValue}`); + this.desiredSolveCountPerMinute = desiredSolveCountPerMinute; + this.difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier; + this.challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds; + this.difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds; + } + + /** + * Creates a new ProofOfWorkManager instance. + * @param input.difficultyIncreaseMultiplier How fast to increase difficulty when solve rate is higher than desired. Must be >= 1. + * Defaults to 1 which means if the solve rate is 2x the desired solve rate, the difficulty will increase by 2x. + * If set to 2, it means if the solve rate is 2x the desired solve rate, the difficulty will increase by 4x. + * @param input.challengeRefreshFrequencyInSeconds How often the challenge nonce is refreshed. Defaults to 10 minutes. + * @param input.difficultyReevaluationFrequencyInSeconds How often the difficulty is reevaluated. Defaults to 10 seconds. + */ + public static async create(input: { + desiredSolveCountPerMinute: number, + autoStart: boolean, + initialMaximumAllowedHashValue?: string, + difficultyIncreaseMultiplier?: number, + challengeRefreshFrequencyInSeconds?: number, + difficultyReevaluationFrequencyInSeconds?: number + }): Promise { + const { desiredSolveCountPerMinute } = input; + + const initialMaximumAllowedHashValue = input.initialMaximumAllowedHashValue ?? ProofOfWorkManager.defaultMaximumAllowedHashValue; + const difficultyIncreaseMultiplier = input.difficultyIncreaseMultiplier ?? 1; // 1x default + const challengeRefreshFrequencyInSeconds = input.challengeRefreshFrequencyInSeconds ?? 10 * 60; // 10 minutes default + const difficultyReevaluationFrequencyInSeconds = input.difficultyReevaluationFrequencyInSeconds ?? 10; // 10 seconds default + + const proofOfWorkManager = new ProofOfWorkManager({ + desiredSolveCountPerMinute, + initialMaximumAllowedHashValue, + difficultyIncreaseMultiplier, + challengeRefreshFrequencyInSeconds, + difficultyReevaluationFrequencyInSeconds + }); + + if (input.autoStart) { + proofOfWorkManager.start(); + } + + return proofOfWorkManager; + } + + /** + * Starts the proof-of-work manager by starting the challenge nonce and difficulty refresh timers. + */ + public start(): void { + this.periodicallyRefreshChallengeNonce(); + this.periodicallyRefreshProofOfWorkDifficulty(); + } + + public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { + return { + challengeNonce: this.challengeNonces.currentChallengeNonce, + maximumAllowedHashValue: ProofOfWorkManager.bigIntToHexString(this.currentMaximumAllowedHashValue), + }; + } + + /** + * Verifies the proof-of-work meets the difficulty requirement. + */ + public async verifyProofOfWork(proofOfWork: { + challengeNonce: string; + responseNonce: string; + requestData: string; + }): Promise { + const { challengeNonce, responseNonce, requestData } = proofOfWork; + + if (this.proofOfWorkOfLastMinute.has(responseNonce)) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerResponseNonceReused, + `Not allowed to reused response nonce: ${responseNonce}.` + ); + } + + // Verify response nonce is a HEX string that represents a 256 bit value. + if (!ProofOfWorkManager.isHexString(responseNonce) || responseNonce.length !== 64) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat, + `Response nonce not a HEX string representing a 256 bit value: ${responseNonce}.` + ); + } + + // Verify challenge nonce is valid. + if (challengeNonce !== this.challengeNonces.currentChallengeNonce && + challengeNonce !== this.challengeNonces.previousChallengeNonce) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce, + `Unknown or expired challenge nonce: ${challengeNonce}.` + ); + } + + const maximumAllowedHashValue = this.currentMaximumAllowedHashValue; + ProofOfWork.verifyResponseNonce({ challengeNonce, responseNonce, requestData, maximumAllowedHashValue }); + + this.recordProofOfWork(responseNonce); + } + + /** + * Records a successful proof-of-work. + * Exposed for testing purposes. + */ + public async recordProofOfWork(proofOfWorkId: string): Promise { + this.proofOfWorkOfLastMinute.set(proofOfWorkId, Date.now()); + } + + private periodicallyRefreshChallengeNonce (): void { + try { + this.refreshChallengeNonce(); + } catch (error) { + console.error(`Encountered error while refreshing challenge nonce: ${error}`); + } finally { + setTimeout(async () => this.periodicallyRefreshChallengeNonce(), this.challengeRefreshFrequencyInSeconds * 1000); + } + } + + private periodicallyRefreshProofOfWorkDifficulty (): void { + try { + this.refreshMaximumAllowedHashValue(); + } catch (error) { + console.error(`Encountered error while updating proof of work difficulty: ${error}`); + } finally { + setTimeout(async () => this.periodicallyRefreshProofOfWorkDifficulty(), this.difficultyReevaluationFrequencyInSeconds * 1000); + } + } + + private removeProofOfWorkOlderThanOneMinute (): void { + const oneMinuteAgo = Date.now() - 60 * 1000; + for (const proofOfWorkId of this.proofOfWorkOfLastMinute.keys()) { + if (this.proofOfWorkOfLastMinute.get(proofOfWorkId) < oneMinuteAgo) { + this.proofOfWorkOfLastMinute.delete(proofOfWorkId); + } + } + } + + private refreshChallengeNonce(): void { + this.challengeNonces.previousChallengeNonce = this.challengeNonces.currentChallengeNonce; + this.challengeNonces.currentChallengeNonce = ProofOfWork.generateNonce(); + } + + /** + * Refreshes the difficulty by changing the max hash value. + * The higher the number, the easier. Scale 1 (hardest) to 2^256 (easiest), represented in HEX. + * + * If solve rate rate is higher than expected, the difficulty will increase rapidly. + * If solve rate is lower than expected, the difficulty will decrease gradually. + * The difficulty will never be lower than the initial difficulty. + */ + private async refreshMaximumAllowedHashValue (): Promise { + // Cleanup proof-of-work cache and update solve rate. + this.removeProofOfWorkOlderThanOneMinute(); + + const latestSolveCountPerMinute = this.proofOfWorkOfLastMinute.size; + + // NOTE: bigint arithmetic does NOT work with decimals, so we work with "full numbers" by multiplying by a scale factor. + const scaleFactor = 1_000_000; + const difficultyEvaluationsPerMinute = 60000 / (this.difficultyReevaluationFrequencyInSeconds * 1000); // assumed to be >= 1; + + // NOTE: easier difficulty is represented by a larger max allowed hash value + // and harder difficulty is represented by a smaller max allowed hash value. + if (latestSolveCountPerMinute > this.desiredSolveCountPerMinute) { + // if solve rate is higher than desired, make difficulty harder by making the max allowed hash value smaller + + const currentSolveRateInFractionOfDesiredSolveRate = latestSolveCountPerMinute / this.desiredSolveCountPerMinute; + const newMaximumAllowedHashValueAsBigIntPriorToMultiplierAdjustment + = (this.currentMaximumAllowedHashValueAsBigInt * BigInt(scaleFactor)) / + (BigInt(Math.floor(currentSolveRateInFractionOfDesiredSolveRate * this.difficultyIncreaseMultiplier * scaleFactor))); + + const hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment + = (this.currentMaximumAllowedHashValueAsBigInt - newMaximumAllowedHashValueAsBigIntPriorToMultiplierAdjustment) * + (BigInt(Math.floor(this.difficultyIncreaseMultiplier * scaleFactor)) / BigInt(scaleFactor)); + + // Adjustment based on the reevaluation frequency to provide more-or-less consistent behavior regardless of the reevaluation frequency. + const hashValueDecreaseAmount = hashValueDecreaseAmountPriorToEvaluationFrequencyAdjustment / BigInt(difficultyEvaluationsPerMinute); + + this.currentMaximumAllowedHashValueAsBigInt -= hashValueDecreaseAmount; + + // Resetting to allow hash increment to be recalculated when difficulty needs to be reduced (in `else` block below) + this.hashValueIncrementPerEvaluation = undefined; + } else { + // if solve rate is lower than desired, make difficulty easier by making the max allowed hash value larger + + if (this.currentMaximumAllowedHashValueAsBigInt === this.initialMaximumAllowedHashValueAsBigInt) { + // if current difficulty is already at initial difficulty, nothing to do + return; + } + + if (this.hashValueIncrementPerEvaluation === undefined) { + const backToInitialDifficultyInMinutes = 10; + const differenceBetweenInitialAndCurrentDifficulty + = this.initialMaximumAllowedHashValueAsBigInt - this.currentMaximumAllowedHashValueAsBigInt; + this.hashValueIncrementPerEvaluation + = differenceBetweenInitialAndCurrentDifficulty / BigInt(backToInitialDifficultyInMinutes * difficultyEvaluationsPerMinute); + } + + // if newly calculated difficulty is lower than initial difficulty, just use the initial difficulty + const newMaximumAllowedHashValueAsBigInt = this.currentMaximumAllowedHashValueAsBigInt + this.hashValueIncrementPerEvaluation; + if (newMaximumAllowedHashValueAsBigInt >= this.initialMaximumAllowedHashValueAsBigInt) { + this.currentMaximumAllowedHashValueAsBigInt = this.initialMaximumAllowedHashValueAsBigInt; + } else { + this.currentMaximumAllowedHashValueAsBigInt = newMaximumAllowedHashValueAsBigInt; + } + } + } + + /** + * Only used by refreshMaximumAllowedHashValue() to reduce the challenge difficulty gradually. + */ + private hashValueIncrementPerEvaluation = BigInt(1); + + /** + * Verifies that the supplied string is a HEX string. + */ + public static isHexString(str: string): boolean { + const regexp = /^[0-9a-fA-F]+$/; + return regexp.test(str); + } + + /** + * Converts a BigInt to a 256 bit HEX string with padded preceding zeros (64 characters). + */ + private static bigIntToHexString (int: BigInt): string { + let hex = int.toString(16).toUpperCase(); + const stringLength = hex.length; + for (let pad = stringLength; pad < 64; pad++) { + hex = '0' + hex; + } + return hex; + } +} diff --git a/src/registration/proof-of-work-types.ts b/src/registration/proof-of-work-types.ts new file mode 100644 index 0000000..ae75721 --- /dev/null +++ b/src/registration/proof-of-work-types.ts @@ -0,0 +1,7 @@ +/** + * Proof-of-work challenge model returned by the /registration/proof-of-work API. + */ +export type ProofOfWorkChallengeModel = { + challengeNonce: string; + maximumAllowedHashValue: string; +}; \ No newline at end of file diff --git a/src/registration/proof-of-work.ts b/src/registration/proof-of-work.ts new file mode 100644 index 0000000..5d7d691 --- /dev/null +++ b/src/registration/proof-of-work.ts @@ -0,0 +1,100 @@ +import { createHash, randomBytes } from 'crypto'; + +import { DwnServerError, DwnServerErrorCode } from '../dwn-error.js'; + +/** + * Utility methods related to proof-of-work. + */ +export class ProofOfWork { + /** + * Computes the resulting hash of the given proof-of-work input. + */ + public static computeHash(input: { + challengeNonce: string; + responseNonce: string; + requestData: string; + }): string { + const hashInput = [input.challengeNonce, input.responseNonce, input.requestData]; + return this.hashAsHexString(hashInput); + } + + /** + * Computes the hash of the given array of strings. + */ + public static hashAsHexString(input: string[]): string { + const hash = createHash('sha256'); + for (const item of input) { + hash.update(item); + } + + return hash.digest('hex'); + } + + /** + * Verifies that the response nonce meets the proof-of-work difficulty requirement. + */ + public static verifyResponseNonce(input: { + maximumAllowedHashValue: bigint; + challengeNonce: string; + responseNonce: string; + requestData: string; + }): void { + const computedHash = this.computeHash(input); + const computedHashAsBigInt = BigInt(`0x${computedHash}`); + + if (computedHashAsBigInt > input.maximumAllowedHashValue) { + throw new DwnServerError( + DwnServerErrorCode.ProofOfWorkInsufficientSolutionNonce, + `Insufficient computed hash ${computedHashAsBigInt}, needs to be <= ${input.maximumAllowedHashValue}.`, + ); + } + } + + /** + * Finds a response nonce that qualifies the difficulty requirement for the given proof-of-work challenge and request data. + * NOTE: mainly for demonstrating the procedure to find a qualified response nonce. + * Will need to artificially introduce asynchrony to allow other tasks to run if this method is to be used in a real-world client. + */ + public static findQualifiedResponseNonce(input: { + maximumAllowedHashValue: string; + challengeNonce: string; + requestData: string; + }): string { + const startTime = Date.now(); + + const { maximumAllowedHashValue, challengeNonce, requestData } = input; + const maximumAllowedHashValueAsBigInt = BigInt(`0x${maximumAllowedHashValue}`); + + let iterations = 1; + let randomNonce; + let qualifiedSolutionNonceFound = false; + do { + randomNonce = this.generateNonce(); + const computedHash = this.computeHash({ + challengeNonce, + responseNonce: randomNonce, + requestData, + }); + const computedHashAsBigInt = BigInt(`0x${computedHash}`); + + qualifiedSolutionNonceFound = computedHashAsBigInt <= maximumAllowedHashValueAsBigInt; + + iterations++; + } while (!qualifiedSolutionNonceFound); + + // Log final/successful iteration. + console.log( + `iterations: ${iterations}, time lapsed: ${Date.now() - startTime} ms`, + ); + + return randomNonce; + } + + /** + * Generates 32 random bytes expressed as a HEX string. + */ + public static generateNonce(): string { + const hexString = randomBytes(32).toString('hex').toUpperCase(); + return hexString; + } +} diff --git a/src/registration/registration-manager.ts b/src/registration/registration-manager.ts new file mode 100644 index 0000000..220af58 --- /dev/null +++ b/src/registration/registration-manager.ts @@ -0,0 +1,139 @@ +import { ProofOfWorkManager } from "./proof-of-work-manager.js"; +import { ProofOfWork } from "./proof-of-work.js"; +import { RegistrationStore } from "./registration-store.js"; +import type { RegistrationData, RegistrationRequest } from "./registration-types.js"; +import type { ProofOfWorkChallengeModel } from "./proof-of-work-types.js"; +import { DwnServerError, DwnServerErrorCode } from "../dwn-error.js"; +import type { ActiveTenantCheckResult, TenantGate } from "@tbd54566975/dwn-sdk-js"; +import { getDialectFromURI } from "../storage.js"; +import { readFileSync } from "fs"; + +/** + * The RegistrationManager is responsible for managing the registration of tenants. + * It handles tenant registration requests and provides the corresponding `TenantGate` implementation. + */ +export class RegistrationManager implements TenantGate { + private proofOfWorkManager: ProofOfWorkManager; + private registrationStore: RegistrationStore; + + private termsOfServiceHash?: string; + private termsOfService?: string; + + /** + * The terms-of-service. + */ + public getTermsOfService(): string | undefined { + return this.termsOfService; + } + + /** + * The terms-of-service hash. + */ + public getTermsOfServiceHash(): string | undefined { + return this.termsOfServiceHash; + } + + /** + * Updates the terms-of-service. Exposed for testing purposes. + */ + public updateTermsOfService(termsOfService: string): void { + this.termsOfServiceHash = ProofOfWork.hashAsHexString([termsOfService]); + this.termsOfService = termsOfService; + } + + private constructor (termsOfServiceFilePath?: string) { + if (termsOfServiceFilePath !== undefined) { + const termsOfService = readFileSync(termsOfServiceFilePath).toString(); + this.updateTermsOfService(termsOfService); + } + } + + /** + * Creates a new RegistrationManager instance. + */ + public static async create(input: { + registrationStoreUrl: string, + termsOfServiceFilePath?: string + initialMaximumAllowedHashValue?: string, + }): Promise { + const { termsOfServiceFilePath, registrationStoreUrl, initialMaximumAllowedHashValue } = input; + + const registrationManager = new RegistrationManager(termsOfServiceFilePath); + + // Initialize and start ProofOfWorkManager. + registrationManager.proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute: 10, + initialMaximumAllowedHashValue, + }); + + // Initialize RegistrationStore. + const sqlDialect = getDialectFromURI(new URL(registrationStoreUrl)); + const registrationStore = await RegistrationStore.create(sqlDialect); + registrationManager.registrationStore = registrationStore; + + return registrationManager; + } + + /** + * Gets the proof-of-work challenge. + */ + public getProofOfWorkChallenge(): ProofOfWorkChallengeModel { + const proofOfWorkChallenge = this.proofOfWorkManager.getProofOfWorkChallenge(); + return proofOfWorkChallenge; + } + + /** + * Handles a registration request. + */ + public async handleRegistrationRequest(registrationRequest: RegistrationRequest): Promise { + // Ensure the supplied terms of service hash matches the one we require. + if (registrationRequest.registrationData.termsOfServiceHash !== this.termsOfServiceHash) { + throw new DwnServerError(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash, + `Expecting terms-of-service hash ${this.termsOfServiceHash}, but got ${registrationRequest.registrationData.termsOfServiceHash}.` + ); + } + + const { challengeNonce, responseNonce } = registrationRequest.proofOfWork; + + await this.proofOfWorkManager.verifyProofOfWork({ + challengeNonce, + responseNonce, + requestData: JSON.stringify(registrationRequest.registrationData), + }); + + // Store tenant registration data in database. + await this.recordTenantRegistration(registrationRequest.registrationData); + } + + /** + * Records the given registration data in the database. + * Exposed as a public method for testing purposes. + */ + public async recordTenantRegistration(registrationData: RegistrationData): Promise { + await this.registrationStore.insertOrUpdateTenantRegistration(registrationData); + } + + /** + * The TenantGate implementation. + */ + public async isActiveTenant(tenant: string): Promise { + const tenantRegistration = await this.registrationStore.getTenantRegistration(tenant); + + if (tenantRegistration === undefined) { + return { + isActiveTenant: false, + detail: 'Not a registered tenant.' + }; + } + + if (tenantRegistration.termsOfServiceHash !== this.termsOfServiceHash) { + return { + isActiveTenant: false, + detail: 'Agreed terms-of-service is outdated.' + }; + } + + return { isActiveTenant: true } + } +} diff --git a/src/registration/registration-store.ts b/src/registration/registration-store.ts new file mode 100644 index 0000000..ea3770a --- /dev/null +++ b/src/registration/registration-store.ts @@ -0,0 +1,79 @@ +import { Kysely } from 'kysely'; +import type { RegistrationData } from './registration-types.js'; +import type { Dialect } from '@tbd54566975/dwn-sql-store'; + +/** + * The RegistrationStore is responsible for storing and retrieving tenant registration information. + */ +export class RegistrationStore { + private static readonly registeredTenantTableName = 'registeredTenants'; + + private db: Kysely; + + private constructor (sqlDialect: Dialect) { + this.db = new Kysely({ dialect: sqlDialect }); + } + + /** + * Creates a new RegistrationStore instance. + */ + public static async create(sqlDialect: Dialect): Promise { + const proofOfWorkManager = new RegistrationStore(sqlDialect); + + await proofOfWorkManager.initialize(); + + return proofOfWorkManager; + } + + private async initialize(): Promise { + await this.db.schema + .createTable(RegistrationStore.registeredTenantTableName) + .ifNotExists() + .addColumn('did', 'text', (column) => column.primaryKey()) + .addColumn('termsOfServiceHash', 'boolean') + .execute(); + } + + /** + * Inserts or updates the tenant registration information. + */ + public async insertOrUpdateTenantRegistration(registrationData: RegistrationData): Promise { + await this.db + .insertInto(RegistrationStore.registeredTenantTableName) + .values(registrationData) + .onConflict((oc) => + oc.column('did').doUpdateSet((eb) => ({ + termsOfServiceHash: eb.ref('excluded.termsOfServiceHash'), + })), + ) + // Executes the query. No error is thrown if the query doesn’t affect any rows (ie. if the insert or update didn’t change anything). + .executeTakeFirst(); + } + + /** + * Retrieves the tenant registration information. + */ + public async getTenantRegistration(tenantDid: string): Promise { + const result = await this.db + .selectFrom(RegistrationStore.registeredTenantTableName) + .select('did') + .select('termsOfServiceHash') + .where('did', '=', tenantDid) + .execute(); + + if (result.length === 0) { + return undefined; + } + + return result[0]; + } +} + +interface RegisteredTenants { + did: string; + termsOfServiceHash: string; +} + +interface RegistrationDatabase { + registeredTenants: RegisteredTenants; +} diff --git a/src/registration/registration-types.ts b/src/registration/registration-types.ts new file mode 100644 index 0000000..1714946 --- /dev/null +++ b/src/registration/registration-types.ts @@ -0,0 +1,18 @@ +/** + * Registration data model to be included as a parameter in the /registration POST request. + */ +export type RegistrationData = { + did: string; + termsOfServiceHash: string; +}; + +/** + * Registration request model of the /registration POST API. + */ +export type RegistrationRequest = { + proofOfWork: { + challengeNonce: string; + responseNonce: string; + }, + registrationData: RegistrationData +}; \ No newline at end of file diff --git a/src/storage.ts b/src/storage.ts index 6719bd6..1b051f9 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; + import { DataStoreLevel, EventLogLevel, @@ -8,6 +10,7 @@ import type { DwnConfig, EventLog, MessageStore, + TenantGate, } from '@tbd54566975/dwn-sdk-js'; import type { Dialect } from '@tbd54566975/dwn-sql-store'; import { @@ -41,7 +44,10 @@ export enum BackendTypes { export type StoreType = DataStore | EventLog | MessageStore; -export function getDWNConfig(config: Config): DwnConfig { +export function getDWNConfig( + config: Config, + tenantGate: TenantGate, +): DwnConfig { const dataStore: DataStore = getStore(config.dataStore, EStoreType.DataStore); const eventLog: EventLog = getStore(config.eventLog, EStoreType.EventLog); const messageStore: MessageStore = getStore( @@ -49,7 +55,7 @@ export function getDWNConfig(config: Config): DwnConfig { EStoreType.MessageStore, ); - return { eventLog, dataStore, messageStore }; + return { eventLog, dataStore, messageStore, tenantGate }; } function getLevelStore( @@ -113,18 +119,26 @@ function getStore(storeString: string, storeType: EStoreType): StoreType { case BackendTypes.SQLITE: case BackendTypes.MYSQL: case BackendTypes.POSTGRES: - return getDBStore(getDBFromURI(storeURI), storeType); + return getDBStore(getDialectFromURI(storeURI), storeType); default: throw invalidStorageSchemeMessage(storeURI.protocol); } } -function getDBFromURI(u: URL): Dialect { +export function getDialectFromURI(u: URL): Dialect { switch (u.protocol.slice(0, -1)) { case BackendTypes.SQLITE: + const path = u.host + u.pathname; + console.log('SQL-lite relative path:', path ? path : undefined); // NOTE, using ? for lose equality comparison + + if (u.host && !fs.existsSync(u.host)) { + console.log('SQL-lite directory does not exist, creating:', u.host); + fs.mkdirSync(u.host, { recursive: true }); + } + return new SqliteDialect({ - database: async () => new Database(u.host + u.pathname), + database: async () => new Database(path), }); case BackendTypes.MYSQL: return new MysqlDialect({ diff --git a/tests/cors.spec.ts b/tests/cors.spec.ts index d55d571..9b9649c 100644 --- a/tests/cors.spec.ts +++ b/tests/cors.spec.ts @@ -7,7 +7,7 @@ import { executablePath } from 'puppeteer'; import { config as defaultConfig } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; let noBrowser; try { @@ -24,19 +24,18 @@ class CorsProxySetup { proxyPort = 9875; public async start(): Promise { + const testDwn = await getTestDwn(); const dwnServer = new DwnServer({ - dwn: dwn, + dwn: testDwn, config: { ...defaultConfig, port: 0, // UNSPEC to obtain test specific free port + registrationProofOfWorkEnabled: false, }, }); - const dwnPort = await new Promise((resolve) => { - dwnServer.start(() => { - const port = (dwnServer.httpServer.address() as AddressInfo).port; - resolve(port); - }); - }); + await dwnServer.start(); + const dwnPort = (dwnServer.httpServer.address() as AddressInfo).port; + // setup proxy server const proxy = httpProxy.createProxyServer({}); const server = http.createServer((req, res) => { @@ -77,7 +76,6 @@ class CorsProxySetup { await new Promise((resolve) => { dwnServer.stop(resolve); }); - await clearDwn(); } } @@ -173,6 +171,12 @@ describe('CORS setup', function () { // dwn-server runs on dwn.localhost const proxy = new CorsProxySetup(); before(async () => { + + // Mute all server console logs during tests. + console.log = (): void => {}; + console.error = (): void => {}; + console.info = (): void => {}; + await proxy.start(); }); after(async () => { diff --git a/tests/dwn-process-message.spec.ts b/tests/dwn-process-message.spec.ts index d85f53f..450e43e 100644 --- a/tests/dwn-process-message.spec.ts +++ b/tests/dwn-process-message.spec.ts @@ -6,14 +6,10 @@ import { v4 as uuidv4 } from 'uuid'; import { handleDwnProcessMessage } from '../src/json-rpc-handlers/dwn/process-message.js'; import type { RequestContext } from '../src/lib/json-rpc-router.js'; import { createJsonRpcRequest } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createRecordsWriteMessage } from './utils.js'; describe('handleDwnProcessMessage', function () { - afterEach(async function () { - await clearDwn(); - }); - it('returns a JSON RPC Success Response when DWN returns a 2XX status code', async function () { const alice = await DidKeyResolver.generate(); @@ -25,6 +21,7 @@ describe('handleDwnProcessMessage', function () { target: alice.did, }); + const dwn = await getTestDwn(); const context: RequestContext = { dwn, transport: 'http', dataStream }; const { jsonRpcResponse } = await handleDwnProcessMessage( @@ -49,6 +46,7 @@ describe('handleDwnProcessMessage', function () { target: 'did:key:abc1234', }); + const dwn = await getTestDwn(); const context: RequestContext = { dwn, transport: 'http' }; const { jsonRpcResponse } = await handleDwnProcessMessage( diff --git a/tests/dwn-server.spec.ts b/tests/dwn-server.spec.ts index da17745..ab00f4e 100644 --- a/tests/dwn-server.spec.ts +++ b/tests/dwn-server.spec.ts @@ -2,21 +2,17 @@ import { expect } from 'chai'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; describe('DwnServer', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; - before(function () { - dwnServer = new DwnServer(options); + before(async function () { + dwnServer = new DwnServer({ config: config }); }); + after(async function () { dwnServer.stop(() => console.log('server stop')); - await clear(); }); + it('should create an instance of DwnServer', function () { expect(dwnServer).to.be.an.instanceOf(DwnServer); }); @@ -28,6 +24,7 @@ describe('DwnServer', function () { }); expect(response.status).to.equal(200); }); + it('should stop the server', async function () { dwnServer.stop(() => console.log('server Stop')); // Add an assertion to check that the server has been stopped diff --git a/tests/fixtures/terms-of-service.txt b/tests/fixtures/terms-of-service.txt new file mode 100644 index 0000000..bfbe414 --- /dev/null +++ b/tests/fixtures/terms-of-service.txt @@ -0,0 +1,39 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla eget efficitur lorem. Duis vel viverra urna. In eget lobortis arcu. Interdum et malesuada fames ac ante ipsum primis in faucibus. Morbi aliquet purus non lacus scelerisque pellentesque. Nullam tempus arcu auctor nisi placerat cursus. Nunc sed odio sit amet dui eleifend iaculis. Sed enim augue, suscipit non metus eu, vehicula maximus enim. Sed auctor rhoncus tortor ac commodo. Nulla suscipit justo vel purus faucibus varius. Vestibulum mollis, libero vel scelerisque maximus, justo diam laoreet est, elementum suscipit nulla massa quis odio. Nam at consequat ipsum, in rhoncus diam. Suspendisse mattis augue id luctus tincidunt. Vivamus fringilla nisl imperdiet ligula tincidunt eleifend. + +Nam et convallis ipsum. Aenean cursus porta rutrum. Nam efficitur a risus ut gravida. Nulla viverra molestie porta. Suspendisse et risus vitae ante hendrerit tempus. Mauris iaculis magna eros, ac lacinia nisl elementum non. Suspendisse ultrices, libero quis faucibus facilisis, purus dolor aliquet nibh, vel scelerisque dolor ante maximus nulla. Aenean nec porta nisi. Nulla suscipit augue sit amet enim eleifend gravida. Quisque tristique finibus mattis. Quisque faucibus eros id nisl lobortis, at rhoncus dui ullamcorper. Phasellus vel risus malesuada, molestie elit eu, condimentum erat. Ut vel elit eu elit pellentesque luctus. Duis venenatis vehicula nisi, in iaculis mi eleifend at. Quisque arcu velit, suscipit in urna sit amet, ullamcorper malesuada ipsum. Donec maximus orci eget tellus blandit, sed tincidunt ante scelerisque. + +Curabitur rhoncus egestas consequat. Nunc sed ex turpis. Aliquam rhoncus fringilla arcu id dictum. Nullam dignissim lorem non lectus tempor porttitor. Donec nec nisl nec enim pulvinar tristique. Pellentesque tincidunt sem quis ex varius, quis viverra eros semper. Donec tristique tortor et odio placerat, a auctor nunc interdum. Fusce ultricies ut orci nec viverra. Sed a ex purus. Nunc id dolor eu ante posuere commodo. + +Nullam neque magna, maximus sit amet luctus nec, laoreet ac arcu. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam semper posuere laoreet. Curabitur orci velit, venenatis non metus eu, semper varius mi. Quisque non leo non quam molestie commodo ac lobortis turpis. Vestibulum rhoncus iaculis leo, eu tincidunt purus pretium ut. Cras eleifend metus sit amet mi suscipit consequat vel vitae diam. Ut iaculis ullamcorper leo in tincidunt. + +Morbi sem ex, vehicula ut augue et, interdum placerat quam. Sed ac ligula nulla. Ut rhoncus dapibus ipsum, sit amet condimentum turpis fermentum ut. Sed in nulla at ipsum vulputate tincidunt vel in ante. Donec nec suscipit nunc. Ut ultricies sem quis metus finibus pharetra. Vestibulum vestibulum nibh augue, ut pellentesque nisi congue at. Etiam pretium dolor ac fringilla cursus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum posuere placerat pharetra. Vestibulum tempus massa ac nulla pretium, id gravida felis luctus. Sed venenatis sollicitudin odio. Phasellus pellentesque ornare semper. Aenean libero turpis, varius et sapien sed, cursus laoreet lacus. Integer diam tortor, placerat interdum nunc quis, hendrerit tincidunt massa. + +Suspendisse et lacus elit. Nunc finibus dolor eget mattis lacinia. Fusce ac libero orci. Vestibulum gravida ligula eget sem venenatis fermentum. Fusce auctor volutpat est a dignissim. Nam eu mollis quam, in imperdiet mi. Mauris nec purus turpis. Nam volutpat metus ac eros eleifend malesuada. Aenean ut erat non lectus suscipit fringilla ac vel ipsum. Donec non mauris quis sem iaculis facilisis. Mauris convallis orci rutrum elit maximus imperdiet. + +Integer pellentesque non diam aliquet semper. Mauris ornare, quam vel condimentum pulvinar, purus quam rhoncus ipsum, sed congue dolor lorem in lectus. Pellentesque sed congue sapien. Aenean vitae lectus mollis, molestie purus vitae, tristique diam. Cras tristique consequat orci sit amet laoreet. Donec porta, risus sed fringilla tincidunt, dui magna mattis mauris, sed dignissim libero magna semper velit. Nullam enim tortor, interdum ac lacus eget, pulvinar volutpat libero. Mauris auctor lacinia tortor. Fusce at dolor sit amet dui pellentesque facilisis at ut nisi. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Praesent dictum risus eget enim bibendum, sed cursus orci faucibus. + +Pellentesque pulvinar, massa ac tincidunt consequat, enim lacus varius turpis, ut pellentesque nulla nulla ac leo. Pellentesque ac tincidunt neque, sit amet tempor mi. Phasellus imperdiet ornare lacus. Nam viverra vel ligula sed gravida. Aliquam in orci scelerisque, malesuada lorem tristique, hendrerit urna. In vel lacus tortor. Quisque ornare sem a orci convallis interdum. Aenean maximus laoreet velit sed laoreet. Aenean pellentesque a quam imperdiet sagittis. Sed et ultrices arcu, eget molestie lacus. Nullam cursus eros id metus porttitor ullamcorper. Nulla mattis dui ac nibh varius dapibus. Vestibulum quis pharetra ipsum. Morbi cursus sagittis nunc vitae luctus. Suspendisse gravida lacinia diam, ac venenatis justo varius eget. Sed sodales erat justo, ac tincidunt ante commodo eu. + +Quisque elit massa, commodo eget rhoncus sit amet, porta vel nunc. Vestibulum id leo leo. Aenean ac est ut justo tincidunt mattis quis sed leo. Nunc libero turpis, congue ut ligula sed, laoreet cursus mi. Pellentesque blandit eget est vel porta. Pellentesque malesuada, magna eu vulputate pharetra, nulla odio mollis nisl, vel rhoncus dui dolor volutpat elit. Aliquam tincidunt ultricies massa, vel finibus turpis aliquet ut. Nunc id nulla et risus pretium blandit. Pellentesque blandit rutrum ornare. Ut id venenatis urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. In maximus, tellus mollis dictum malesuada, lacus lectus blandit orci, id varius tellus libero non nisi. Sed maximus pellentesque vulputate. Praesent placerat, ligula vitae semper feugiat, mi quam finibus lacus, quis maximus tortor ex id lectus. Morbi dapibus eget orci tristique efficitur. Etiam dui magna, efficitur quis venenatis ac, suscipit eu orci. + +Aliquam turpis mi, luctus ut auctor vel, finibus sed neque. Integer eleifend sit amet justo vehicula sollicitudin. Aenean nec rutrum magna. In bibendum turpis ullamcorper, commodo metus nec, eleifend neque. Vivamus a interdum massa, ac tincidunt nibh. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque imperdiet, magna ac volutpat mattis, velit lacus rhoncus nisl, quis vehicula justo purus vel eros. Morbi ut dapibus justo, in cursus mauris. Curabitur imperdiet iaculis convallis. Ut mauris diam, venenatis non convallis et, euismod sed ex. Mauris tellus sapien, fermentum a mollis eu, sagittis non mi. Nunc ac vulputate purus. Phasellus orci tellus, interdum sit amet nunc in, vehicula convallis lacus. Curabitur nisl orci, gravida ut turpis a, bibendum molestie eros. + +Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Ut tempor vitae sapien ac sagittis. Vivamus imperdiet, est non venenatis blandit, justo tortor porttitor purus, nec pulvinar lectus ligula ut enim. Donec non erat id velit maximus imperdiet vel vitae quam. Duis eu efficitur massa. Fusce nec sem scelerisque, egestas lectus id, lacinia nunc. Vestibulum sollicitudin consectetur lectus. Donec tempor interdum faucibus. Morbi eget eleifend arcu. + +Phasellus ut auctor elit. Vivamus pretium nulla a bibendum rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed justo odio, egestas quis felis vel, mollis pretium mauris. Integer lacinia odio porta justo auctor lacinia. Quisque faucibus eu nisi vel imperdiet. Curabitur et urna orci. Quisque vestibulum interdum ligula, ut rutrum mauris gravida id. Pellentesque iaculis mi in laoreet egestas. Donec lobortis facilisis eros, vel ultricies odio luctus vel. Curabitur in leo nunc. Donec hendrerit risus vitae augue hendrerit lobortis. In scelerisque tempus nunc, eu pellentesque lacus. + +Nunc nunc purus, suscipit non dignissim ac, cursus vitae metus. Nulla fringilla leo in libero mollis posuere. Phasellus ornare dignissim risus, at efficitur nisl porta et. Donec mollis fringilla massa, sed venenatis libero vehicula id. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed condimentum turpis ipsum. Ut consectetur turpis orci, sed tincidunt quam maximus nec. Curabitur fermentum nisi in dolor elementum aliquam. Integer dictum pharetra lectus at malesuada. Nunc tincidunt nunc ac bibendum semper. In fringilla purus ex, vel egestas justo vehicula nec. Cras non justo leo. Fusce posuere sapien eget felis dictum semper. Donec hendrerit condimentum magna vel faucibus. + +Maecenas tempor auctor augue, vitae lobortis lacus gravida sit amet. Nam ac mi nec sem ultricies luctus in et odio. Cras nibh nulla, finibus non ex et, accumsan euismod libero. Cras pretium ex sit amet sem luctus, sed ultrices enim posuere. Donec faucibus, mauris eu lacinia congue, lectus velit cursus est, ac vestibulum metus dolor eget nisl. Morbi varius quam a sem ullamcorper malesuada. Maecenas velit nibh, accumsan a eleifend ut, iaculis quis magna. Nam ante tortor, venenatis et dignissim sit amet, ornare eget mi. Morbi nec dui nisi. + +Integer scelerisque dictum lectus, sit amet feugiat eros pharetra a. Donec scelerisque nibh eget mi venenatis, a bibendum tortor rhoncus. Sed eleifend sit amet quam vitae ullamcorper. Integer libero mi, imperdiet eu ornare vitae, eleifend et orci. Nullam a rutrum dui. Donec eu finibus purus, eu lacinia nunc. Donec nec velit a massa ullamcorper ultrices a a augue. In hac habitasse platea dictumst. Morbi euismod purus at ipsum sollicitudin ullamcorper. Sed interdum dolor vel feugiat consequat. Praesent ac urna malesuada, bibendum turpis suscipit, blandit lacus. Maecenas placerat nisl facilisis pretium dignissim. + +Nunc nec sapien malesuada, malesuada nibh vitae, fringilla libero. Aliquam lobortis hendrerit leo, ut dignissim orci. Nunc ut dui nec tellus imperdiet bibendum. In vitae diam et urna luctus viverra in sit amet dui. Nulla ligula dui, laoreet non tempus nec, feugiat sit amet dolor. In condimentum posuere urna, at iaculis enim mollis eu. Ut consectetur odio at congue vulputate. Maecenas iaculis, ex sed fermentum mollis, neque urna pulvinar tellus, ac porttitor lacus dui ut est. Aliquam erat volutpat. Morbi vel tellus eu purus blandit blandit vitae vel elit. Sed est erat, sodales a sollicitudin at, semper vel dui. Pellentesque posuere nibh erat, ac finibus purus mattis id. Aliquam sagittis varius enim, id dignissim mauris hendrerit ut. + +Nam in posuere ligula. Pellentesque vehicula vulputate libero, ac porta risus mollis in. Aenean euismod nunc ut nisl interdum elementum. Fusce interdum imperdiet imperdiet. Donec sed massa vitae nunc iaculis rutrum. Quisque scelerisque commodo congue. Aenean porttitor elementum dolor, in accumsan elit pretium sit amet. + +Maecenas vitae est eget ex venenatis posuere. Fusce elementum turpis nec maximus semper. Nunc ultricies pulvinar mauris, vel rhoncus lorem molestie vitae. Sed sit amet facilisis urna. Cras ultrices lacus nec porttitor euismod. Ut elementum scelerisque tempor. Phasellus id elit viverra, tincidunt ex eget, vestibulum ligula. Integer non purus purus. Vestibulum dignissim posuere ligula ut vulputate. + +Fusce pretium felis in ullamcorper egestas. Sed sit amet gravida metus. Donec hendrerit tellus nec elit hendrerit, a tempor urna mattis. Donec mattis lobortis nulla, sit amet facilisis arcu euismod in. Nunc eget porttitor orci, a varius augue. Aliquam et aliquet eros, ac euismod mauris. Ut ac lacus est. Integer neque odio, efficitur vel tempus id, placerat quis magna. Donec a quam turpis. Ut eget mi maximus, fermentum mauris a, auctor massa. + +Quisque a tempus eros. Suspendisse mi felis, feugiat vestibulum bibendum vel, pellentesque sit amet risus. Nam commodo, elit molestie suscipit tristique, diam magna blandit nulla, ut tincidunt dui enim efficitur est. Curabitur rhoncus justo at lorem scelerisque porttitor. Aliquam vitae egestas nibh. Morbi at magna porttitor, cursus leo quis, hendrerit dui. Nulla accumsan libero ac lobortis bibendum. Pellentesque vel velit nunc. Duis volutpat posuere tellus ut tincidunt. \ No newline at end of file diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index ba18731..0656704 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -5,15 +5,19 @@ import { DidKeyResolver, RecordsQuery, RecordsRead, + Time, } from '@tbd54566975/dwn-sdk-js'; +import type { Dwn, Persona } from '@tbd54566975/dwn-sdk-js'; import { expect } from 'chai'; import type { Server } from 'http'; import fetch from 'node-fetch'; import { webcrypto } from 'node:crypto'; +import { useFakeTimers } from 'sinon'; import request from 'supertest'; import { v4 as uuidv4 } from 'uuid'; +import { config } from '../src/config.js'; import { HttpApi } from '../src/http-api.js'; import type { JsonRpcErrorResponse, @@ -23,12 +27,13 @@ import { createJsonRpcRequest, JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createRecordsWriteMessage, getFileAsReadStream, streamHttpRequest, } from './utils.js'; +import { RegistrationManager } from '../src/registration/registration-manager.js'; if (!globalThis.crypto) { // @ts-ignore @@ -38,126 +43,154 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; + let alice: Persona; + let registrationManager: RegistrationManager; + let dwn: Dwn; + let clock; before(async function () { - httpApi = new HttpApi(dwn); + clock = useFakeTimers({ shouldAdvanceTime: true }); + + config.registrationStoreUrl = 'sqlite://'; + config.registrationProofOfWorkEnabled = true; + config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + config.registrationProofOfWorkInitialMaxHash = '0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // 1 in 16 chance of solving + + // RegistrationManager creation + const registrationStoreUrl = config.registrationStoreUrl; + const termsOfServiceFilePath = config.termsOfServiceFilePath; + const initialMaximumAllowedHashValue = config.registrationProofOfWorkInitialMaxHash; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, initialMaximumAllowedHashValue }); + + dwn = await getTestDwn(registrationManager); + + httpApi = new HttpApi(config, dwn, registrationManager); + + alice = await DidKeyResolver.generate(); + await registrationManager.recordTenantRegistration({ did: alice.did, termsOfServiceHash: registrationManager.getTermsOfServiceHash()}); }); beforeEach(async function () { - server = httpApi.start(3000); + server = await httpApi.start(3000); }); afterEach(async function () { server.close(); server.closeAllConnections(); - await clearDwn(); }); - it('responds with a 400 if no dwn-request header is provided', async function () { - const response = await request(httpApi.api).post('/').send(); - - expect(response.statusCode).to.equal(400); - - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.equal('request payload required.'); + after(function () { + clock.restore(); }); - it('responds with a 400 if parsing dwn request fails', async function () { - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', ';;;;@!#@!$$#!@%') - .send(); + describe('/ (rpc)', function () { + it('responds with a 400 if no dwn-request header is provided', async function () { + const response = await request(httpApi.api).post('/').send(); - expect(response.statusCode).to.equal(400); + expect(response.statusCode).to.equal(400); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.include('JSON'); - }); + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.equal('request payload required.'); + }); - it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { - const alice = await DidKeyResolver.generate(); - const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); + it('responds with a 400 if parsing dwn request fails', async function () { + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', ';;;;@!#@!$$#!@%') + .send(); - // Intentionally delete a required property to produce an invalid RecordsWrite message. - const message = recordsWrite.toJSON(); - delete message['descriptor']['interface']; + expect(response.statusCode).to.equal(400); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: message, - target: alice.did, + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.include('JSON'); }); - const dataBytes = await DataStream.toBytes(dataStream); + it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { + const { recordsWrite, dataStream } = + await createRecordsWriteMessage(alice); - // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. - const responseInitialWrite = await fetch('http://localhost:3000', { - method: 'POST', - headers: { - 'dwn-request': JSON.stringify(dwnRequest), - }, - body: new Blob([dataBytes]), - }); + // Intentionally delete a required property to produce an invalid RecordsWrite message. + const message = recordsWrite.toJSON(); + delete message['descriptor']['interface']; - expect(responseInitialWrite.status).to.equal(200); + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: message, + target: alice.did, + }); - const body = (await responseInitialWrite.json()) as JsonRpcResponse; - expect(body.id).to.equal(requestId); - expect(body.error).to.not.exist; + const dataBytes = await DataStream.toBytes(dataStream); - const { reply } = body.result; - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.include( - 'Both interface and method must be present', - ); - }); + // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. + const responseInitialWrite = await fetch('http://localhost:3000', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(dwnRequest), + }, + body: new Blob([dataBytes]), + }); - it('exposes dwn-response header', async function () { - // This test verifies that the Express web server includes `dwn-response` in the list of - // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications - // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly - // in the case of RecordsRead messages. + expect(responseInitialWrite.status).to.equal(200); - // TODO: github.com/TBD54566975/dwn-server/issues/50 - // Consider replacing this test with a more robust method of testing, such as writing Playwright tests - // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response - // when CORS mode is enabled. - const response = await request(httpApi.api).post('/').send(); + const body = (await responseInitialWrite.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; - // Check if the 'access-control-expose-headers' header is present - expect(response.headers).to.have.property('access-control-expose-headers'); + const { reply } = body.result; + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.include( + 'Both interface and method must be present', + ); + }); - // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' - const exposedHeaders = response.headers['access-control-expose-headers']; - expect(exposedHeaders).to.include('dwn-response'); - }); + it('exposes dwn-response header', async function () { + // This test verifies that the Express web server includes `dwn-response` in the list of + // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications + // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly + // in the case of RecordsRead messages. + + // TODO: github.com/TBD54566975/dwn-server/issues/50 + // Consider replacing this test with a more robust method of testing, such as writing Playwright tests + // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response + // when CORS mode is enabled. + const response = await request(httpApi.api).post('/').send(); + + // Check if the 'access-control-expose-headers' header is present + expect(response.headers).to.have.property( + 'access-control-expose-headers', + ); - it('works fine when no request body is provided', async function () { - const alice = await DidKeyResolver.generate(); - const recordsQuery = await RecordsQuery.create({ - filter: { - schema: 'woosa', - }, - signer: alice.signer, + // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' + const exposedHeaders = response.headers['access-control-expose-headers']; + expect(exposedHeaders).to.include('dwn-response'); }); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: alice.did, - }); + it('works fine when no request body is provided', async function () { + const recordsQuery = await RecordsQuery.create({ + filter: { + schema: 'woosa', + }, + signer: alice.signer, + }); - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: alice.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.error).to.not.exist; - expect(response.body.result.reply.status.code).to.equal(200); + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.error).to.not.exist; + expect(response.body.result.reply.status.code).to.equal(200); + }); }); describe('RecordsWrite', function () { @@ -165,7 +198,6 @@ describe('http api', function () { const filePath = './fixtures/test.jpeg'; const { cid, size, stream } = await getFileAsReadStream(filePath); - const alice = await DidKeyResolver.generate(); const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: cid, dataSize: size, @@ -200,8 +232,6 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const alice = await DidKeyResolver.generate(); - // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = await createRecordsWriteMessage(alice); @@ -222,17 +252,17 @@ describe('http api', function () { expect(responseInitialWrite.status).to.equal(200); + // Waiting for minimal time to make sure subsequent RecordsWrite has a later timestamp. + await Time.minimalSleep(); + // Subsequent RecordsWrite that mutates the published property of the record. - const { recordsWrite: overWrite } = await createRecordsWriteMessage( - alice, - { - recordId: initialWrite.message.recordId, - dataCid: initialWrite.message.descriptor.dataCid, - dataSize: initialWrite.message.descriptor.dataSize, - dateCreated: initialWrite.message.descriptor.dateCreated, - published: true, - }, - ); + const { recordsWrite: overWrite } = await createRecordsWriteMessage(alice, { + recordId: initialWrite.message.recordId, + dataCid: initialWrite.message.descriptor.dataCid, + dataSize: initialWrite.message.descriptor.dataSize, + dateCreated: initialWrite.message.descriptor.dateCreated, + published: true, + }); requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { @@ -258,7 +288,6 @@ describe('http api', function () { }); it('handles a RecordsWrite tombstone', async function () { - const alice = await DidKeyResolver.generate(); const { recordsWrite: tombstone } = await createRecordsWriteMessage(alice); @@ -306,7 +335,6 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await DidKeyResolver.generate(); const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, @@ -390,7 +418,6 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await DidKeyResolver.generate(); const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, @@ -436,7 +463,6 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await DidKeyResolver.generate(); const { recordsWrite } = await createRecordsWriteMessage(alice, { dataCid: expectedCid, dataSize: size, @@ -472,8 +498,7 @@ describe('http api', function () { expect(response.status).to.equal(404); }); - it('returns a 404 if record doesnt exist', async function () { - const alice = await DidKeyResolver.generate(); + it('returns a 404 if record does not exist', async function () { const { recordsWrite } = await createRecordsWriteMessage(alice); const response = await fetch( @@ -482,22 +507,35 @@ describe('http api', function () { expect(response.status).to.equal(404); }); - it('returns a 404 for invalid did', async function () { - const alice = await DidKeyResolver.generate(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + it('returns a 404 for invalid or unauthorized did', async function () { + const unauthorized = await DidKeyResolver.generate(); + const { recordsWrite } = await createRecordsWriteMessage(unauthorized); const response = await fetch( - `http://localhost:3000/1234567892345678/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${unauthorized.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 for invalid record id', async function () { - const alice = await DidKeyResolver.generate(); const response = await fetch( `http://localhost:3000/${alice.did}/records/kaka`, ); expect(response.status).to.equal(404); }); }); + + describe('/info', function () { + it('verify /info has some of the fields it is supposed to have', async function () { + const resp = await fetch(`http://localhost:3000/info`); + expect(resp.status).to.equal(200); + + const info = await resp.json(); + expect(info['server']).to.equal('@web5/dwn-server'); + expect(info['registrationRequirements']).to.include('terms-of-service'); + expect(info['registrationRequirements']).to.include( + 'proof-of-work-sha256-v0', + ); + }); + }); }); diff --git a/tests/process-handler.spec.ts b/tests/process-handler.spec.ts index caa13c6..97dfe4d 100644 --- a/tests/process-handler.spec.ts +++ b/tests/process-handler.spec.ts @@ -3,28 +3,24 @@ import sinon from 'sinon'; import { config } from '../src/config.js'; import { DwnServer } from '../src/dwn-server.js'; -import { clear, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; describe('Process Handlers', function () { let dwnServer: DwnServer; - const options = { - dwn: dwn, - config: config, - }; let processExitStub: sinon.SinonStub; - before(async function () { - dwnServer = new DwnServer(options); - }); beforeEach(async function () { + const testDwn = await getTestDwn(); + dwnServer = new DwnServer({ dwn: testDwn, config: config }); await dwnServer.start(); processExitStub = sinon.stub(process, 'exit'); }); afterEach(async function () { + dwnServer.stop(() => console.log('server stop in Process Handlers tests')); + process.removeAllListeners('SIGINT'); process.removeAllListeners('SIGTERM'); process.removeAllListeners('uncaughtException'); - await clear(); processExitStub.restore(); }); it('should stop when SIGINT is emitted', async function () { diff --git a/tests/registration/proof-of-work-manager.spec.ts b/tests/registration/proof-of-work-manager.spec.ts new file mode 100644 index 0000000..70e87e9 --- /dev/null +++ b/tests/registration/proof-of-work-manager.spec.ts @@ -0,0 +1,137 @@ + + +import sinon from 'sinon'; + +import { expect } from 'chai'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; +import { ProofOfWorkManager } from '../..//src/registration/proof-of-work-manager.js'; + +describe('ProofOfWorkManager', function () { + let clock; + + before(async function () { + clock = useFakeTimers({ shouldAdvanceTime: true }); + }); + + beforeEach(async function () { + }); + + afterEach(async function () { + }); + + after(function () { + clock.restore(); + }); + + it('should continue to periodically refresh the challenge nonce and proof-of-work difficulty even if the refresh logic throws error.', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumAllowedHashValue = 'FFFFFFFF'; + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumAllowedHashValue, + }); + + // stub that throws half the time + const stub = (): void => { + // Generate a random number between 0 and 1 + const random = Math.random(); + + // If the random number is less than 0.5, throw an error + if (random < 0.5) { + throw new Error('Random error'); + } + }; + + const challengeNonceRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshChallengeNonce').callsFake(stub); + const maximumAllowedHashValueRefreshSpy = sinon.stub(proofOfWorkManager, 'refreshMaximumAllowedHashValue').callsFake(stub); + + clock.tick(60 * 60 * 1000); + + // 1 hour divided by the challenge refresh frequency + const expectedChallengeNonceRefreshCount = 60 * 60 / proofOfWorkManager.challengeRefreshFrequencyInSeconds; + + // 1 hour divided by the challenge refresh frequency + const expectedDifficultyReevaluationCount = 60 * 60 / proofOfWorkManager.difficultyReevaluationFrequencyInSeconds; + + expect(challengeNonceRefreshSpy.callCount).to.greaterThanOrEqual(expectedChallengeNonceRefreshCount); + expect(maximumAllowedHashValueRefreshSpy.callCount).to.greaterThanOrEqual(expectedDifficultyReevaluationCount); + }); + + it('should increase difficulty if proof-of-work rate goes above desired rate and reduce difficulty as proof-of-work rate falls below desired rate.', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumAllowedHashValue = 'FFFFFFFF'; + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumAllowedHashValue, + }); + + // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. + for (let i = 0; i < desiredSolveCountPerMinute; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + } + + let baselineMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + let lastMaximumAllowedHashValue = BigInt('0x' + initialMaximumAllowedHashValue); + const lastSolveCountPerMinute = 0; + for (let i = 0; i < 100; i++) { + // Simulating 1 proof-of-work per second for 100 seconds. + await proofOfWorkManager.recordProofOfWork(uuidv4()); + expect(proofOfWorkManager.currentSolveCountPerMinute).to.be.greaterThanOrEqual(lastSolveCountPerMinute); + clock.tick(1000); + + // The maximum allowed hash value should be monotonically decreasing as more proof-of-work is submitted. + expect(proofOfWorkManager.currentMaximumAllowedHashValue <= lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue < baselineMaximumAllowedHashValue).to.be.true; + + // Simulated 100 seconds has passed, so all proof-of-work entries should be removed. + clock.tick(100_000); + clock.runToLast(); + + expect(proofOfWorkManager.currentSolveCountPerMinute).to.equal(0); + + baselineMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + for (let i = 0; i < 100; i++) { + // Simulating no proof-of-work load for 100 seconds. + clock.tick(1000); + + // The maximum allowed hash value should be monotonically increasing again. + expect(proofOfWorkManager.currentMaximumAllowedHashValue >= lastMaximumAllowedHashValue).to.be.true; + lastMaximumAllowedHashValue = proofOfWorkManager.currentMaximumAllowedHashValue; + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue > baselineMaximumAllowedHashValue).to.be.true; + }); + + it('should reduce difficulty back to initial difficulty when proof-of-work rate is lower than desired rate for long enough', async function () { + const desiredSolveCountPerMinute = 10; + const initialMaximumAllowedHashValue = 'FFFFFFFF'; + const initialMaximumAllowedHashValueAsBigInt = BigInt('0x' + initialMaximumAllowedHashValue); + const proofOfWorkManager = await ProofOfWorkManager.create({ + autoStart: true, + desiredSolveCountPerMinute, + initialMaximumAllowedHashValue, + }); + + // Load up desiredSolveRatePerMinute number of proof-of-work entries, so all future new entries will increase the complexity. + for (let i = 0; i < desiredSolveCountPerMinute; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + } + + // Simulating 1 proof-of-work per second for 100 seconds to increase proof-of-work difficulty. + for (let i = 0; i < 100; i++) { + await proofOfWorkManager.recordProofOfWork(uuidv4()); + clock.tick(1000); + } + expect(proofOfWorkManager.currentMaximumAllowedHashValue < initialMaximumAllowedHashValueAsBigInt).to.be.true; + + // Simulated 1 hour has passed. + clock.tick(60 * 60 * 1000); + clock.runToLast(); + + expect(proofOfWorkManager.currentMaximumAllowedHashValue === initialMaximumAllowedHashValueAsBigInt).to.be.true; + }); +}); diff --git a/tests/scenarios/registration.spec.ts b/tests/scenarios/registration.spec.ts new file mode 100644 index 0000000..39d44db --- /dev/null +++ b/tests/scenarios/registration.spec.ts @@ -0,0 +1,514 @@ +// node.js 18 and earlier, needs globalThis.crypto polyfill +import { + DataStream, + DidKeyResolver, +} from '@tbd54566975/dwn-sdk-js'; +import type { Dwn, Persona } from '@tbd54566975/dwn-sdk-js'; + +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import type { Server } from 'http'; +import fetch from 'node-fetch'; +import { webcrypto } from 'node:crypto'; +import { useFakeTimers } from 'sinon'; +import { v4 as uuidv4 } from 'uuid'; + +import { config } from '../../src/config.js'; +import { HttpApi } from '../../src/http-api.js'; +import type { + JsonRpcRequest, + JsonRpcResponse, +} from '../../src/lib/json-rpc.js'; +import { + createJsonRpcRequest, +} from '../../src/lib/json-rpc.js'; +import { ProofOfWork } from '../../src/registration/proof-of-work.js'; +import { getTestDwn } from '../test-dwn.js'; +import { + createRecordsWriteMessage, +} from '../utils.js'; +import type { ProofOfWorkChallengeModel } from '../../src/registration/proof-of-work-types.js'; +import type { RegistrationData, RegistrationRequest } from '../../src/registration/registration-types.js'; +import { RegistrationManager } from '../../src/registration/registration-manager.js'; +import { DwnServerErrorCode } from '../../src/dwn-error.js'; +import { ProofOfWorkManager } from '../../src/registration/proof-of-work-manager.js'; + +if (!globalThis.crypto) { + // @ts-ignore + globalThis.crypto = webcrypto; +} + +describe('Registration scenarios', function () { + const dwnMessageEndpoint = 'http://localhost:3000'; + const termsOfUseEndpoint = 'http://localhost:3000/registration/terms-of-service'; + const proofOfWorkEndpoint = 'http://localhost:3000/registration/proof-of-work'; + const registrationEndpoint = 'http://localhost:3000/registration'; + + let httpApi: HttpApi; + let server: Server; + let alice: Persona; + let registrationManager: RegistrationManager; + let dwn: Dwn; + let clock; + + before(async function () { + clock = useFakeTimers({ shouldAdvanceTime: true }); + + config.registrationStoreUrl = 'sqlite://'; + config.registrationProofOfWorkEnabled = true; + config.termsOfServiceFilePath = './tests/fixtures/terms-of-service.txt'; + config.registrationProofOfWorkInitialMaxHash = '0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; // 1 in 16 chance of solving + + // RegistrationManager creation + const registrationStoreUrl = config.registrationStoreUrl; + const termsOfServiceFilePath = config.termsOfServiceFilePath; + const initialMaximumAllowedHashValue = config.registrationProofOfWorkInitialMaxHash; + registrationManager = await RegistrationManager.create({ registrationStoreUrl, termsOfServiceFilePath, initialMaximumAllowedHashValue }); + + dwn = await getTestDwn(registrationManager); + + httpApi = new HttpApi(config, dwn, registrationManager); + + alice = await DidKeyResolver.generate(); + }); + + beforeEach(async function () { + server = await httpApi.start(3000); + }); + + afterEach(async function () { + server.close(); + server.closeAllConnections(); + }); + + after(function () { + clock.restore(); + }); + + it('should facilitate tenant registration with terms-of-service and proof-or-work turned on', async () => { + // Scenario: + // 1. Alice fetches the terms-of-service. + // 2. Alice fetches the proof-of-work challenge. + // 3. Alice creates registration data based on the hash of the terms-of-service and her DID. + // 4. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + // 5. Alice sends the registration request to the server and is now registered. + // 6. Alice can now write to the DWN. + // 7. Sanity test that another non-tenant is NOT authorized to write. + + // 1. Alice fetches the terms-of-service. + const termsOfServiceGetResponse = await fetch(termsOfUseEndpoint, { + method: 'GET', + }); + const termsOfServiceFetched = await termsOfServiceGetResponse.text(); + expect(termsOfServiceGetResponse.status).to.equal(200); + expect(termsOfServiceFetched).to.equal(readFileSync(config.termsOfServiceFilePath).toString()); + + // 2. Alice fetches the proof-of-work challenge. + const proofOfWorkChallengeGetResponse = await fetch(proofOfWorkEndpoint, { + method: 'GET', + }); + const { challengeNonce, maximumAllowedHashValue} = await proofOfWorkChallengeGetResponse.json() as ProofOfWorkChallengeModel; + expect(proofOfWorkChallengeGetResponse.status).to.equal(200); + expect(challengeNonce.length).to.equal(64); + expect(ProofOfWorkManager.isHexString(challengeNonce)).to.be.true; + expect(ProofOfWorkManager.isHexString(maximumAllowedHashValue)).to.be.true; + + // 3. Alice creates registration data based on the hash of the terms-of-service and her DID. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfServiceFetched]), + }; + + // 4. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 5. Alice sends the registration request to the server and is now registered. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 6. Alice can now write to the DWN. + const { jsonRpcRequest, dataBytes } = await generateRecordsWriteJsonRpcRequest(alice); + const writeResponse = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(jsonRpcRequest), + }, + body: new Blob([dataBytes]), + }); + const writeResponseBody = await writeResponse.json() as JsonRpcResponse; + expect(writeResponse.status).to.equal(200); + expect(writeResponseBody.result.reply.status.code).to.equal(202); + + // 7. Sanity test that another non-tenant is NOT authorized to write. + const nonTenant = await DidKeyResolver.generate(); + const nonTenantJsonRpcRequest = await generateRecordsWriteJsonRpcRequest(nonTenant); + const nonTenantJsonRpcResponse = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(nonTenantJsonRpcRequest.jsonRpcRequest), + }, + body: new Blob([nonTenantJsonRpcRequest.dataBytes]), + }); + const nonTenantJsonRpcResponseBody = await nonTenantJsonRpcResponse.json() as JsonRpcResponse; + expect(nonTenantJsonRpcResponse.status).to.equal(200); + expect(nonTenantJsonRpcResponseBody.result.reply.status.code).to.equal(401); + expect(nonTenantJsonRpcResponseBody.result.reply.status.detail).to.equal('Not a registered tenant.'); + }); + + it('should reject a registration request that has proof-or-work that does not meet the difficulty requirement.', async function () { + // Scenario: + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + // 1. Alice computes the proof-of-work response nonce that is insufficient to meet the difficulty requirement. + // 2. Alice sends the registration request to the server and is rejected. + + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + const termsOfService = registrationManager.getTermsOfService(); + const { challengeNonce } = registrationManager.getProofOfWorkChallenge(); + + // Force the difficulty to be practically impossible. + const originalMaximumAllowedHashValueAsBigInt = registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt']; // for restoring later below + registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt'] = BigInt('0x0000000000000000000000000000000000000000000000000000000000000001'); + + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + // 1. Alice computes the proof-of-work response nonce that is insufficient to meet the difficulty requirement. + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', // any hash value will always be less or equal to this value + requestData: JSON.stringify(registrationData), + }); + + // 2. Alice sends the registration request to the server and is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkInsufficientSolutionNonce); + + // Restoring original difficulty for subsequent tests. + registrationManager['proofOfWorkManager']['currentMaximumAllowedHashValueAsBigInt'] = originalMaximumAllowedHashValueAsBigInt; + }); + + it('should reject a registration request that uses an invalid/outdated terms-of-service hash', async () => { + // Scenario: + // 0. Assume Alice fetched the proof-of-work challenge. + // 1. Alice constructs the registration data with an invalid/outdated terms-of-service hash. + // 2. Alice sends the registration request to the server and it is rejected. + + // 0. Assume Alice fetched the proof-of-work challenge. + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + + // 1. Alice constructs the registration data with an invalid/outdated terms-of-service hash. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString(['invalid-or-outdated-terms-of-service']), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 2. Alice sends the registration request to the server and it is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.RegistrationManagerInvalidOrOutdatedTermsOfServiceHash); + }); + + it('should reject registration request that reuses a response nonce that is already used a short-time earlier', async () => { + // Scenario: + // 0. Assume Alice fetched the proof-of-work challenge and the terms-of-service. + // 1. Alice sends the registration request to the server and it is accepted. + // 2. Alice sends the same registration request which uses the same response nonce to the server again and it is rejected. + + // 0. Assume Alice fetched the proof-of-work challenge and the terms-of-service. + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + const termsOfService = registrationManager.getTermsOfService(); + + // 1. Alice sends the registration request to the server and it is accepted. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 2. Alice sends the same registration request which uses the same response nonce to the server again and it is rejected. + const registration2Response = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registration2ResponseBody = await registration2Response.json() as any; + expect(registration2Response.status).to.equal(400); + expect(registration2ResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerResponseNonceReused); + }); + + it('should reject an invalid nonce that is not a HEX string representing a 256 bit value.', async function () { + + // Assume Alice fetched the terms-of-service. + const termsOfService = registrationManager.getTermsOfService(); + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const registrationRequest1: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce: 'unused', + responseNonce: 'not-a-hex-string', + }, + }; + + const registrationResponse1 = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest1), + }); + const registrationResponseBody1 = await registrationResponse1.json() as any; + expect(registrationResponse1.status).to.equal(400); + expect(registrationResponseBody1.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat); + + const registrationRequest2: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce: 'unused', + responseNonce: 'FFFF', // HEX string too short + }, + }; + + const registrationResponse2 = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest2), + }); + const registrationResponseBody2 = await registrationResponse2.json() as any; + expect(registrationResponse2.status).to.equal(400); + expect(registrationResponseBody2.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidResponseNonceFormat); + }); + + it('should reject a registration request that uses an expired challenge nonce', async () => { + // Scenario: + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + // 1. A long time has passed since Alice fetched the proof-of-work challenge and the challenge nonce has expired. + // 2. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + // 3. Alice sends the registration request to the server and it is rejected. + + // 0. Assume Alice fetched the terms-of-service and proof-of-work challenge. + const termsOfService = registrationManager.getTermsOfService(); + const { challengeNonce, maximumAllowedHashValue } = registrationManager.getProofOfWorkChallenge(); + + // 1. A long time has passed since Alice fetched the proof-of-work challenge and the challenge nonce has expired. + clock.tick(10 * 60 * 1000); // 10 minutes has passed + clock.runToLast(); // triggers all scheduled timers + + // 2. Alice computes the proof-of-work response nonce based on the the proof-of-work challenge and the registration data. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([termsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + // 3. Alice sends the registration request to the server and it is rejected. + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + const registrationResponseBody = await registrationResponse.json() as any; + expect(registrationResponse.status).to.equal(400); + expect(registrationResponseBody.code).to.equal(DwnServerErrorCode.ProofOfWorkManagerInvalidChallengeNonce); + }); + + it('should reject a DWN message for an existing tenant who agreed to an outdated terms-of-service.', async () => { + // Scenario: + // 1. Alice is a registered tenant and is able to write to the DWN. + // 2. DWN server administrator updates the terms-of-service. + // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. + // 4. Alice fetches the new terms-of-service and proof-of-work challenge + // 5. Alice agrees to the new terms-of-service. + // 6. Alice can now write to the DWN again. + + // 1. Alice is a registered tenant and is able to write to the DWN. + // Shortcut to register Alice. + registrationManager.recordTenantRegistration({ + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([registrationManager.getTermsOfService()]) + }); + + // Sanity test that Alice can write to the DWN after registration. + const write1 = await generateRecordsWriteJsonRpcRequest(alice); + const write1Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(write1.jsonRpcRequest), + }, + body: new Blob([write1.dataBytes]), + }); + const write1ResponseBody = await write1Response.json() as JsonRpcResponse; + expect(write1Response.status).to.equal(200); + expect(write1ResponseBody.result.reply.status.code).to.equal(202); + + // 2. DWN server administrator updates the terms-of-service. + const newTermsOfService = 'new terms of service'; + registrationManager.updateTermsOfService(newTermsOfService); + + // 3. Alice no longer can write to the DWN because she has not agreed to the new terms-of-service. + const write2 = await generateRecordsWriteJsonRpcRequest(alice); + const write2Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(write2.jsonRpcRequest), + }, + body: new Blob([write2.dataBytes]), + }); + const write2ResponseBody = await write2Response.json() as JsonRpcResponse; + expect(write2Response.status).to.equal(200); + expect(write2ResponseBody.result.reply.status.code).to.equal(401); + expect(write2ResponseBody.result.reply.status.detail).to.equal('Agreed terms-of-service is outdated.'); + + // 4. Alice fetches the new terms-of-service and proof-of-work challenge + const termsOfServiceGetResponse = await fetch(termsOfUseEndpoint, { + method: 'GET', + }); + const termsOfServiceFetched = await termsOfServiceGetResponse.text(); + expect(termsOfServiceGetResponse.status).to.equal(200); + expect(termsOfServiceFetched).to.equal(newTermsOfService); + + const proofOfWorkChallengeGetResponse = await fetch(proofOfWorkEndpoint, { + method: 'GET', + }); + const { challengeNonce, maximumAllowedHashValue} = await proofOfWorkChallengeGetResponse.json() as ProofOfWorkChallengeModel; + + // 5. Alice agrees to the new terms-of-service. + const registrationData: RegistrationData = { + did: alice.did, + termsOfServiceHash: ProofOfWork.hashAsHexString([newTermsOfService]), + }; + + const responseNonce = ProofOfWork.findQualifiedResponseNonce({ + challengeNonce, + maximumAllowedHashValue, + requestData: JSON.stringify(registrationData), + }); + + const registrationRequest: RegistrationRequest = { + registrationData, + proofOfWork: { + challengeNonce, + responseNonce, + }, + }; + + const registrationResponse = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registrationRequest), + }); + expect(registrationResponse.status).to.equal(200); + + // 6. Alice can now write to the DWN again. + const { jsonRpcRequest, dataBytes } = await generateRecordsWriteJsonRpcRequest(alice); + const write3Response = await fetch(dwnMessageEndpoint, { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(jsonRpcRequest), + }, + body: new Blob([dataBytes]), + }); + const write3ResponseBody = await write3Response.json() as JsonRpcResponse; + expect(write3Response.status).to.equal(200); + expect(write3ResponseBody.result.reply.status.code).to.equal(202); + + }); +}); + +async function generateRecordsWriteJsonRpcRequest(persona: Persona): Promise<{ jsonRpcRequest: JsonRpcRequest, dataBytes: Uint8Array }> { + const { recordsWrite, dataStream } = await createRecordsWriteMessage(persona); + + const requestId = uuidv4(); + const jsonRpcRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsWrite.toJSON(), + target: persona.did, + }); + + const dataBytes = await DataStream.toBytes(dataStream); + return { jsonRpcRequest, dataBytes }; +} diff --git a/tests/test-dwn.ts b/tests/test-dwn.ts index 19ffd41..27c7a10 100644 --- a/tests/test-dwn.ts +++ b/tests/test-dwn.ts @@ -1,27 +1,32 @@ +import type { TenantGate } from '@tbd54566975/dwn-sdk-js'; +import { Dwn } from '@tbd54566975/dwn-sdk-js'; import { - Dwn, - DataStoreLevel, - EventLogLevel, - MessageStoreLevel, -} from '@tbd54566975/dwn-sdk-js'; + DataStoreSql, + EventLogSql, + MessageStoreSql, +} from '@tbd54566975/dwn-sql-store'; -const testDwnDataDirectory = 'data-test'; +import { getDialectFromURI } from '../src/storage.js'; -const dataStore = new DataStoreLevel({ - blockstoreLocation: `${testDwnDataDirectory}/DATASTORE`, -}); -const eventLog = new EventLogLevel({ - location: `${testDwnDataDirectory}/EVENTLOG`, -}); -const messageStore = new MessageStoreLevel({ - blockstoreLocation: `${testDwnDataDirectory}/MESSAGESTORE`, - indexLocation: `${testDwnDataDirectory}/INDEX`, -}); +export async function getTestDwn( + tenantGate?: TenantGate +): Promise { + const db = getDialectFromURI(new URL('sqlite://')); + const dataStore = new DataStoreSql(db); + const eventLog = new EventLogSql(db); + const messageStore = new MessageStoreSql(db); -export const dwn = await Dwn.create({ eventLog, dataStore, messageStore }); + let dwn: Dwn; + try { + dwn = await Dwn.create({ + eventLog, + dataStore, + messageStore, + tenantGate + }); + } catch (e) { + throw e; + } -export async function clear(): Promise { - await dataStore.clear(); - await eventLog.clear(); - await messageStore.clear(); + return dwn; } diff --git a/tests/utils.ts b/tests/utils.ts index 5cb1d9b..3ded3c4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,4 +1,4 @@ -import type { PrivateJwk, PublicJwk, Signer } from '@tbd54566975/dwn-sdk-js'; +import type { Persona } from '@tbd54566975/dwn-sdk-js'; import { Cid, DataStream, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; import type { ReadStream } from 'node:fs'; @@ -13,15 +13,6 @@ import { WebSocket } from 'ws'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export type Profile = { - did: string; - keyPair: { - publicJwk: PublicJwk; - privateJwk: PrivateJwk; - }; - signer: Signer; -}; - export type CreateRecordsWriteOverrides = | ({ dataCid?: string; @@ -44,7 +35,7 @@ export type GenerateProtocolsConfigureOutput = { }; export async function createRecordsWriteMessage( - signer: Profile, + signer: Persona, overrides: CreateRecordsWriteOverrides = {}, ): Promise { if (!overrides.dataCid && !overrides.data) { diff --git a/tests/ws-api.spec.ts b/tests/ws-api.spec.ts index f478194..c205a34 100644 --- a/tests/ws-api.spec.ts +++ b/tests/ws-api.spec.ts @@ -11,7 +11,7 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import { WsApi } from '../src/ws-api.js'; -import { clear as clearDwn, dwn } from './test-dwn.js'; +import { getTestDwn } from './test-dwn.js'; import { createRecordsWriteMessage, sendWsMessage } from './utils.js'; let server: http.Server; @@ -22,14 +22,11 @@ describe('websocket api', function () { server = http.createServer(); server.listen(9002, '127.0.0.1'); - const wsApi = new WsApi(server, dwn); + const testDwn = await getTestDwn(); + const wsApi = new WsApi(server, testDwn); wsServer = wsApi.start(); }); - afterEach(async function () { - await clearDwn(); - }); - after(function () { wsServer.close(); server.close(); @@ -57,6 +54,7 @@ describe('websocket api', function () { it('handles RecordsWrite messages', async function () { const alice = await DidKeyResolver.generate(); + const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); const dataBytes = await DataStream.toBytes(dataStream); const encodedData = base64url.baseEncode(dataBytes); @@ -74,6 +72,7 @@ describe('websocket api', function () { ); const resp = JSON.parse(data.toString()); expect(resp.id).to.equal(requestId); + console.log(resp.error); expect(resp.error).to.not.exist; const { reply } = resp.result;