diff --git a/package-lock.json b/package-lock.json index f2847f0..245996f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@web5/dwn-server", - "version": "0.4.10", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@web5/dwn-server", - "version": "0.4.10", + "version": "0.5.0", "dependencies": { "@tbd54566975/dwn-sdk-js": "0.4.7", "@tbd54566975/dwn-sql-store": "0.6.7", + "@web5/common": "^1.0.2", "@web5/crypto": "^1.0.3", + "@web5/dids": "^1.1.3", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", "bytes": "3.1.2", @@ -46,7 +48,6 @@ "@types/ws": "8.5.10", "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", - "@web5/dids": "^1.1.3", "c8": "8.0.1", "chai": "4.3.6", "chai-as-promised": "7.1.1", @@ -1209,19 +1210,73 @@ "dev": true }, "node_modules/@web5/common": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@web5/common/-/common-1.0.0.tgz", - "integrity": "sha512-3JHF6X5o0h+3oAVQeBC4XpMoZeEYZYdEmQdgpOfKv/rnSru2yHQSAM+0wbIvEFcSCmelBT3u7rUAcpJjelLB0w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@web5/common/-/common-1.0.2.tgz", + "integrity": "sha512-SerGdrxZF47yidvhrRa8sGLEOunIlDHppxrtWYCuKMVgtQKgheEmaS4+xchGAc/mZggJX4LlwJbRuniIiSaXrw==", "dependencies": { "@isaacs/ttlcache": "1.4.1", - "level": "8.0.0", - "multiformats": "11.0.2", - "readable-stream": "4.4.2" + "level": "8.0.1", + "multiformats": "13.1.0", + "readable-stream": "4.5.2" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@web5/common/node_modules/abstract-level": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz", + "integrity": "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==", + "dependencies": { + "buffer": "^6.0.3", + "catering": "^2.1.0", + "is-buffer": "^2.0.5", + "level-supports": "^4.0.0", + "level-transcoder": "^1.0.1", + "module-error": "^1.0.1", + "queue-microtask": "^1.2.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@web5/common/node_modules/level": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz", + "integrity": "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ==", + "dependencies": { + "abstract-level": "^1.0.4", + "browser-level": "^1.0.1", + "classic-level": "^1.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/@web5/common/node_modules/multiformats": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.1.0.tgz", + "integrity": "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ==" + }, + "node_modules/@web5/common/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@web5/crypto": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@web5/crypto/-/crypto-1.0.3.tgz", @@ -1327,6 +1382,36 @@ "node": ">=18.0.0" } }, + "node_modules/@web5/dids/node_modules/@web5/common": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web5/common/-/common-1.0.0.tgz", + "integrity": "sha512-3JHF6X5o0h+3oAVQeBC4XpMoZeEYZYdEmQdgpOfKv/rnSru2yHQSAM+0wbIvEFcSCmelBT3u7rUAcpJjelLB0w==", + "dependencies": { + "@isaacs/ttlcache": "1.4.1", + "level": "8.0.0", + "multiformats": "11.0.2", + "readable-stream": "4.4.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/dids/node_modules/@web5/common/node_modules/level": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/level/-/level-8.0.0.tgz", + "integrity": "sha512-ypf0jjAk2BWI33yzEaaotpq7fkOPALKAgDBxggO6Q9HGX2MRXn0wbP1Jn/tJv1gtL867+YOjOB49WaUF3UoJNQ==", + "dependencies": { + "browser-level": "^1.0.1", + "classic-level": "^1.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, "node_modules/@web5/dids/node_modules/abstract-level": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz", diff --git a/package.json b/package.json index b817fe1..e129d92 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@web5/dwn-server", "type": "module", - "version": "0.4.10", + "version": "0.5.0", "files": [ "dist", "src" @@ -28,7 +28,9 @@ "dependencies": { "@tbd54566975/dwn-sdk-js": "0.4.7", "@tbd54566975/dwn-sql-store": "0.6.7", + "@web5/common": "^1.0.2", "@web5/crypto": "^1.0.3", + "@web5/dids": "^1.1.3", "better-sqlite3": "^8.5.0", "body-parser": "^1.20.2", "bytes": "3.1.2", @@ -61,7 +63,6 @@ "@types/ws": "8.5.10", "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", - "@web5/dids": "^1.1.3", "c8": "8.0.1", "chai": "4.3.6", "chai-as-promised": "7.1.1", @@ -108,7 +109,7 @@ }, "overrides": { "express": { - "serve-static": "^1.16.2" + "serve-static": "^1.16.2" } } } diff --git a/src/http-api.ts b/src/http-api.ts index bd26fcb..4ee110c 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -21,6 +21,7 @@ import { jsonRpcRouter } from './json-rpc-api.js'; import { Web5ConnectServer } from './web5-connect/web5-connect-server.js'; import { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; import { requestCounter, responseHistogram } from './metrics.js'; +import { Convert } from '@web5/common'; export class HttpApi { @@ -49,7 +50,7 @@ export class HttpApi { httpApi.#packageInfo.version = packageJson.version; httpApi.#packageInfo.sdkVersion = packageJson.dependencies ? packageJson.dependencies['@tbd54566975/dwn-sdk-js'] : undefined; } catch (error: any) { - log.error('could not read `package.json` for version info', error); + log.info('could not read `package.json` for version info', error); } httpApi.#config = config; @@ -150,58 +151,77 @@ export class HttpApi { return res.status(400).send('protocol path is required'); } - const queryOptions = { filter: {} } as any; - for (const param in req.query) { - const keys = param.split('.'); - const lastKey = keys.pop(); - const lastLevelObject = keys.reduce((obj, key) => obj[key] = obj[key] || {}, queryOptions) - lastLevelObject[lastKey] = req.query[param]; - } + // wrap request in a try-catch block to handle any unexpected errors + try { + const queryOptions = { filter: {} } as any; + for (const param in req.query) { + const keys = param.split('.'); + const lastKey = keys.pop(); + const lastLevelObject = keys.reduce((obj, key) => obj[key] = obj[key] || {}, queryOptions) + lastLevelObject[lastKey] = req.query[param]; + } - queryOptions.filter.protocol = req.params.protocol; - queryOptions.filter.protocolPath = req.params[0].replace(leadTailSlashRegex, ''); + // the protocol path segment is base64url encoded, as the actual protocol is a URL + // we decode it here in order to filter for the correct protocol + const protocol = Convert.base64Url(req.params.protocol).toString() + queryOptions.filter.protocol = protocol; + queryOptions.filter.protocolPath = req.params[0].replace(leadTailSlashRegex, ''); - const query = await RecordsQuery.create({ - filter: queryOptions.filter, - pagination: { limit: 1 }, - dateSort: DateSort.PublishedDescending - }); + const query = await RecordsQuery.create({ + filter: queryOptions.filter, + pagination: { limit: 1 }, + dateSort: DateSort.PublishedDescending + }); - const { entries, status } = await this.dwn.processMessage(req.params.did, query.message); + const { entries, status } = await this.dwn.processMessage(req.params.did, query.message); - if (status.code === 200) { - if (entries[0]) { - const record = await RecordsRead.create({ - filter: { recordId: entries[0].recordId }, - }); - const reply = await this.dwn.processMessage(req.params.did, record.toJSON()); - return readReplyHandler(res, reply); - } else { + if (status.code === 200) { + if (entries[0]) { + const record = await RecordsRead.create({ + filter: { recordId: entries[0].recordId }, + }); + const reply = await this.dwn.processMessage(req.params.did, record.toJSON()); + return readReplyHandler(res, reply); + } else { + return res.sendStatus(404); + } + } else if (status.code === 401) { return res.sendStatus(404); + } else { + return res.sendStatus(status.code); } - } else if (status.code === 401) { - return res.sendStatus(404); - } else { - return res.sendStatus(status.code); + } catch(error) { + log.error(`Error processing request: ${decodeURI(req.url)}`, error); + return res.sendStatus(400); } }) this.#api.get('/:did/read/protocols/:protocol', async (req, res) => { - const query = await ProtocolsQuery.create({ - filter: { protocol: req.params.protocol } - }); - const { entries, status } = await this.dwn.processMessage(req.params.did, query.message); - if (status.code === 200) { - if (entries.length) { - res.status(status.code); - res.json(entries[0]); - } else { + // wrap request in a try-catch block to handle any unexpected errors + try { + + // the protocol segment is base64url encoded, as the actual protocol is a URL + // we decode it here in order to filter for the correct protocol + const protocol = Convert.base64Url(req.params.protocol).toString() + const query = await ProtocolsQuery.create({ + filter: { protocol } + }); + const { entries, status } = await this.dwn.processMessage(req.params.did, query.message); + if (status.code === 200) { + if (entries.length) { + res.status(status.code); + res.json(entries[0]); + } else { + return res.sendStatus(404); + } + } else if (status.code === 401) { return res.sendStatus(404); + } else { + return res.sendStatus(status.code); } - } else if (status.code === 401) { - return res.sendStatus(404); - } else { - return res.sendStatus(status.code); + } catch(error) { + log.error(`Error processing request: ${decodeURI(req.url)}`, error); + return res.sendStatus(400); } }) diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 6e07c9d..6a6b6c0 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -36,6 +36,7 @@ import { } from './utils.js'; import { RegistrationManager } from '../src/registration/registration-manager.js'; import CommonScenarioValidator from './common-scenario-validator.js'; +import { Convert } from '@web5/common'; if (!globalThis.crypto) { // @ts-ignore @@ -564,8 +565,8 @@ describe('http api', function () { // Fetch the protocol definition using the HTTP API - const urlEncodedProtocol = encodeURIComponent(protocolConfigure.message.descriptor.definition.protocol); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}`; + const base64urlEncodedProtocol = Convert.string(protocolConfigure.message.descriptor.definition.protocol).toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}`; const protocolQueryResponse = await fetch(protocolUrl); expect(protocolQueryResponse.status).to.equal(200); @@ -606,11 +607,18 @@ describe('http api', function () { // Fetch the protocol definition using the HTTP API - const urlEncodedProtocol = encodeURIComponent(protocolConfigure.message.descriptor.definition.protocol); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}`; + const base64urlEncodedProtocol = Convert.string(protocolConfigure.message.descriptor.definition.protocol).toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}`; const protocolQueryResponse = await fetch(protocolUrl); expect(protocolQueryResponse.status).to.equal(404); }); + + it('returns a 400 if protocol is not base64url encoded', async function () { + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/invalid-protocol`; + const protocolQueryResponse = await fetch(protocolUrl); + expect(protocolQueryResponse.status).to.equal(400); + expect(await protocolQueryResponse.text()).to.equal('Bad Request'); + }); }); describe('/:did/query/protocols', function () { @@ -752,8 +760,8 @@ describe('http api', function () { expect(responseJson.result.reply.status.code).to.equal(202); // Fetch the record using the HTTP API - const urlEncodedProtocol = encodeURIComponent(protocolConfigure.message.descriptor.definition.protocol); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}/foo`; + const base64urlEncodedProtocol = Convert.string(protocolConfigure.message.descriptor.definition.protocol).toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}/foo`; const recordReadResponse = await fetch(protocolUrl); expect(recordReadResponse.status).to.equal(200); @@ -771,8 +779,8 @@ describe('http api', function () { it('removes the trailing slash from the protocol path', async function () { const recordsQueryCreateSpy = sinon.spy(RecordsQuery, 'create'); - const urlEncodedProtocol = encodeURIComponent('http://example.com/protocol'); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}/foo/`; // trailing slash + const base64urlEncodedProtocol = Convert.string('http://example.com/protocol').toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}/foo/`; // trailing slash const recordReadResponse = await fetch(protocolUrl); expect(recordReadResponse.status).to.equal(404); @@ -845,20 +853,27 @@ describe('http api', function () { expect(responseJson.result.reply.status.code).to.equal(202); // Fetch the record using the HTTP API - const urlEncodedProtocol = encodeURIComponent(protocolConfigure.message.descriptor.definition.protocol); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}/foo`; + const base64urlEncodedProtocol = Convert.string(protocolConfigure.message.descriptor.definition.protocol).toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}/foo`; const recordReadResponse = await fetch(protocolUrl); expect(recordReadResponse.status).to.equal(404); }); it('returns a 400 if protocol path is not provided', async function () { // Fetch a protocol record without providing a protocol path - const urlEncodedProtocol = encodeURIComponent('http://example.com/protocol'); - const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${urlEncodedProtocol}/`; // missing protocol path + const base64urlEncodedProtocol = Convert.string('http://example.com/protocol').toBase64Url(); + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/${base64urlEncodedProtocol}/`; // missing protocol path const recordReadResponse = await fetch(protocolUrl); expect(recordReadResponse.status).to.equal(400); expect(await recordReadResponse.text()).to.equal('protocol path is required'); }); + + it('returns a 400 error if protocol cannot be base64url encoded', async function () { + const protocolUrl = `http://localhost:3000/${alice.did}/read/protocols/invalid-protocol/foo`; + const recordReadResponse = await fetch(protocolUrl); + expect(recordReadResponse.status).to.equal(400); + expect(await recordReadResponse.text()).to.equal('Bad Request'); + }) }); describe('/:did/query', function () { @@ -961,8 +976,8 @@ describe('http api', function () { it('verify /info still returns when package.json file does not exist', async function () { await httpApi.close(); - // set up spy to check for an error log by the server - const logSpy = sinon.spy(log, 'error'); + // set up spy to check for an info log by the server + const logSpy = sinon.spy(log, 'info'); // set the config to an invalid file path const packageJsonConfig = config.packageJsonPath; @@ -982,8 +997,8 @@ describe('http api', function () { expect(info['version']).to.be.undefined; // check the logSpy was called - expect(logSpy.callCount).to.equal(1); - expect(logSpy.args[0][0]).to.contain('could not read `package.json` for version info'); + expect(logSpy.callCount).to.be.gt(0); + expect(logSpy.calledWith(sinon.match('could not read `package.json` for version info'))).to.be.true; // restore old config path config.packageJsonPath = packageJsonConfig;