diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..c5e11a9 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + env: { + node: true + }, + extends: [ + 'eslint-config-digitalbazaar', + 'eslint-config-digitalbazaar/jsdoc', + 'eslint-config-digitalbazaar/module', + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + } +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 2297f74..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - root: true, - env: { - node: true - }, - extends: 'digitalbazaar' -}; diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 28e9f44..d497a2c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -24,12 +24,12 @@ jobs: timeout-minutes: 10 services: mongodb: - image: mongo:4.2 + image: mongo:4.4 ports: - 27017:27017 strategy: matrix: - node-version: [12.x, 14.x] + node-version: [16.x, 18.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -37,6 +37,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: | + npm install cd test npm install - name: Run test with Node.js ${{ matrix.node-version }} @@ -49,12 +50,12 @@ jobs: timeout-minutes: 10 services: mongodb: - image: mongo:4.2 + image: mongo:4.4 ports: - 27017:27017 strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -62,6 +63,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: | + npm install cd test npm install - name: Generate coverage report diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/lib/DidResolutionResult.js b/lib/DidResolutionResult.js new file mode 100644 index 0000000..8c2f2b4 --- /dev/null +++ b/lib/DidResolutionResult.js @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + */ +import {isDidUrl} from '@digitalbazaar/did-io'; + +/** + * Creates the JSON-LD for the response. + * + * @param {object} options - Options to use. + * @param {string} [options.did=''] - The did being resolved. + * @param {object} [options.didDocument={}] - The didDocument for the + * response. + * @param {object|null} [options.didDocumentMetadata=null] - Meta data + * for the didDocument. + * @param {Error} options.error - An error if any was raised. + */ +export class DidResolutionResult { + constructor({ + did = '', + didDocument = {}, + didDocumentMetadata = null, + error + }) { + this['@context'] = 'https://w3id.org/did-resolution/v1'; + this.didDocument = didDocument; + this.didDocumentMetadata = didDocumentMetadata; + const hasDereferencingMetadata = isDidUrl({did}); + const metadataProperty = hasDereferencingMetadata ? + 'didDereferencingMetadata' : 'didResolutionMetadata'; + this[metadataProperty] = {}; + // only define error if it was passed in + if(error) { + this[metadataProperty].error = error.code || 'internalError'; + } + } +} diff --git a/lib/config.js b/lib/config.js index 21a7294..0a78e03 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,14 +1,10 @@ /*! * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. */ -import bedrock from 'bedrock'; -const {config} = bedrock; +import {config} from '@bedrock/core'; const namespace = 'bedrock-did-resolver-http'; const cfg = config[namespace] = {}; -// support did:key and veres one keys by default -cfg.supportedMethods = ['key', 'v1']; - -const basePath = '/1.0/resolve/identifiers/:did'; +const basePath = '/1.0/identifiers/resolve/:did'; cfg.routes = {basePath}; diff --git a/lib/didResolver.js b/lib/didResolver.js new file mode 100644 index 0000000..f85b419 --- /dev/null +++ b/lib/didResolver.js @@ -0,0 +1,152 @@ +/*! + * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + */ +import {didIo} from '@bedrock/did-io'; +import {DidResolutionResult} from './DidResolutionResult.js'; +import logger from './logger.js'; + +/** + * Resolves a did after validating it. + * + * @param {object} options - Options to use. + * @param {string} options.did - The did or didUrl being resolved. + * @param {object} options.didOptions - Options to be passed to the did + * method driver. + * @param {object} options.resolverOptions - Options for the resolver such as + * the format of the response data. + * + * @returns {object} - Returns a status code and the resolution data. + */ +export const resolveDid = async ({ + did, + didOptions: rawOptions, + resolverOptions +}) => { + let error; + let didDocument; + try { + const didOptions = _convertDidOptions({didOptions: rawOptions}); + didDocument = await didIo.get({did, options: didOptions}); + } catch(e) { + logger.error(`DID resolution error: ${did}`, {error: e}); + error = e; + } + return _formatResponse({resolverOptions, didDocument, error, did}); +}; + +/** + * Did Resolvers can return the didDocument itself or wrapped with metadata. + * + * @private + * + * @param {object} options - Options to use. + * @param {object} options.resolverOptions - Options for the did resolver + * usually found in headers. + * @param {object} options.didDocument - A didDocument. + * @param {Error} [options.error] - An error. + * @param {string} options.did - The did being resolved or derefernced. + * + * @returns {{statusCode: number, resolutionResult: object}} - What to return + * as the response. + */ +function _formatResponse({resolverOptions, didDocument, error, did}) { + const {accept = ''} = resolverOptions; + const didResolutionContext = 'https://w3id.org/did-resolution'; + const addMetadata = accept.includes(didResolutionContext); + if(!addMetadata) { + if(error) { + throw error; + } + return { + // FIXME services are not handled here yet + statusCode: 200, + resolutionResult: didDocument + }; + } + return { + //FIXME services should return 303 + statusCode: error ? _errorCodeToStatusCode({code: error.code}) : 200, + resolutionResult: new DidResolutionResult({did, didDocument, error}) + }; +} + +/** + * Matches an error.code to a status code or 500. + * + * @see https://w3c-ccg.github.io/did-resolution/ + * @private + * + * @param {object} options - Options to use. + * @param {string} options.code - An error. + * + * @returns {number} - An http status code. + */ +function _errorCodeToStatusCode({code}) { + const statuses = [ + // if the did resolves to a service return 303 + {statusCode: 303, codes: ['service']}, + { + statusCode: 400, + // FIXME these codes should have more specific statusCodes soon + codes: [ + 'invalidDid', + 'invalidDidUrl', + 'unsupportedPublicKeyType', + 'invalidPublicKeyType', + 'invalidPublicKey', + 'invalidPublicKeyLength' + ] + }, + {statusCode: 404, codes: ['notFound']}, + // if the did has been deactivated return 410 + {statusCode: 410, codes: ['deactivated']}, + {statusCode: 406, codes: ['representationNotSupported']}, + {statusCode: 500, codes: ['internalError']}, + {statusCode: 501, codes: ['methodNotSupported']}, + ]; + const {statusCode = 500} = statuses.find( + ({codes}) => codes.includes(code)) || {}; + return statusCode; +} + +// takes in search params / url queries +// and then converts properties that should be booleans +// to booleans +function _convertDidOptions({didOptions}) { + if(!didOptions) { + return; + } + if(didOptions.enableExperimentalPublicKeyTypes) { + didOptions.enableExperimentalPublicKeyTypes = _convertSearchParamToBoolean({ + param: didOptions.enableExperimentalPublicKeyTypes + }); + } + return didOptions; +} + +/** + * Search Params are encoded as strings so this turns + * strings of value '0' or 'false' into false. + * + * @param {object} options - Options to use. + * @param {string} options.param - The param value. + * + * @returns {boolean} Returns a boolean. + */ +function _convertSearchParamToBoolean({param}) { + if(typeof param !== 'string') { + return Boolean(param); + } + // the param must start & end with one of these values ignoring case + const booleanParam = /^((?0|false)|(?1|true))$/i; + const {groups} = booleanParam.exec(param.trim()) || {}; + if(!groups) { + throw new Error( + `Expected search param to be 0, "false", 1, or "true" received ${param}`); + } + // if we captured a false value return false + if(groups.false !== undefined) { + return false; + } + return true; +} diff --git a/lib/http.js b/lib/http.js index 19f593d..a3acb96 100644 --- a/lib/http.js +++ b/lib/http.js @@ -1,61 +1,36 @@ /*! * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ -import {asyncHandler} from 'bedrock-express'; -import bedrock from 'bedrock'; -import {didIo} from 'bedrock-did-io'; -import logger from './logger'; +import * as bedrock from '@bedrock/core'; +import {asyncHandler} from '@bedrock/express'; +import logger from './logger.js'; +import {resolveDid} from './didResolver.js'; const {config} = bedrock; bedrock.events.on('bedrock-express.configure.routes', app => { - const {routes, supportedMethods} = config['bedrock-did-resolver-http']; + const { + routes, + supportedMethods, + } = config['bedrock-did-resolver-http']; app.get( routes.basePath, asyncHandler(async (req, res) => { - // this can be resolution or dereferencing meta data - const metaData = {}; - const resolutionResult = { - '@context': 'https://w3id.org/did-resolution/v1', - didDocument: {}, - didDocumentMetadata: null, - didResolutionMetadata: metaData - }; const {did} = req.params; - const parsedDid = new URL(did); - // if there is a fragment `#` or a service `?service` - // then we are dereferencing a did url - const didUrl = (parsedDid.hash || parsedDid.search); - if(didUrl) { - // add a did dereferencing meta - resolutionResult.didDereferencingMetadata = metaData; - // delete the resolution metadata - delete resolutionResult.didResolutionMetadata; - } - // the second value should always be the method - const [prefix, method, id] = did.split(':'); - // a did must have a did prefix, method, and id - if(!((prefix === 'did') && method && id)) { - metaData.error = didUrl ? 'invalidDidUrl' : 'invalidDid'; - return res.status(400).json(resolutionResult); - } - if(!supportedMethods.includes(method)) { - //FIXME this might not be the right error code - metaData.error = 'representationNotSupported'; - return res.status(406).json(resolutionResult); - } + // did options are passed to the resolver via url queries + const didOptions = {...req.query}; + // the accept header tells the resolver how to format the response data + const resolverOptions = {...req.headers}; try { - resolutionResult.didDocument = await didIo.get({did}); - } catch(e) { - //FIXME the did resolver error could contain invalidDid, notFound, - //or representationNotSupported we need to check for those errors - //and newer errors and add that information in the future - logger.error('DID Resolution error', {error: e}); - // the spec doesn't seem to handle what occurs if the - // did resolver fails for reasons unrelated to the did such - // as database timeouts. - metaData.error = 'InternalError'; - return res.status(500).json(resolutionResult); + const {statusCode, resolutionResult} = await resolveDid({ + did, + supportedMethods, + didOptions, + resolverOptions + }); + res.status(statusCode).json(resolutionResult); + } catch(error) { + logger.error(`DID resolution error ${did}`, {error}); + throw error; } - res.json(resolutionResult); })); }); diff --git a/lib/index.js b/lib/index.js index e731f70..12ce93d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,8 +1,7 @@ /*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; +import '@bedrock/express'; -// translate `main.js` to CommonJS -require = require('esm')(module); -module.exports = require('./main.js'); +import './config.js'; +import './http.js'; diff --git a/lib/logger.js b/lib/logger.js index 73c0f40..eed81e0 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -2,9 +2,7 @@ /*! * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; - -import bedrock from 'bedrock'; +import * as bedrock from '@bedrock/core'; const logger = bedrock.loggers.get('app').child('bedrock-did-resolver-http'); diff --git a/lib/main.js b/lib/main.js deleted file mode 100644 index 7e65f16..0000000 --- a/lib/main.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. - */ -import 'bedrock-express'; - -import './config.js'; -import './http.js'; diff --git a/package.json b/package.json index 257276e..2a6ee40 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "bedrock-did-resolver-http", "version": "0.0.1-0", "description": "Bedrock HTTP API", - "main": "./lib", + "type": "module", + "main": "./lib/index.js", "scripts": { "lint": "eslint ." }, @@ -23,21 +24,23 @@ }, "homepage": "https://github.com/digitalbazaar/bedrock-did-resolver-http", "dependencies": { - "bedrock-did-io": "^5.0.0", - "esm": "^3.2.25" + "@bedrock/did-io": "digitalbazaar/bedrock-did-io#use-next-release-did-io" }, "peerDependencies": { - "bedrock": "^4.4.3", - "bedrock-express": "^6.3.0" + "@digitalbazaar/did-io": "^2.0.0", + "@bedrock/core": "^6.0.0", + "@bedrock/express": "^8.0.0" }, "directories": { "lib": "./lib" }, "devDependencies": { - "eslint": "^7.14.0", - "eslint-config-digitalbazaar": "^2.6.1" + "eslint": "^8.20.0", + "eslint-config-digitalbazaar": "^4.1.0", + "eslint-plugin-jsdoc": "^39.3.4", + "eslint-plugin-unicorn": "^43.0.2" }, "engines": { - "node": ">=12" + "node": ">=16" } } diff --git a/test/.npmrc b/test/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/test/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/test/mocha/.eslintrc b/test/mocha/.eslintrc index f9f5022..6f4ff4e 100644 --- a/test/mocha/.eslintrc +++ b/test/mocha/.eslintrc @@ -3,7 +3,6 @@ "mocha": true }, "globals": { - "assertNoError": true, "should": true } } diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 6157d46..ff0100b 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -1,7 +1,29 @@ /* - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. */ +import {makeRequest} from './helpers.js'; +import {shouldBeDidResolverResponse} from './assertions.js'; -describe('api', () => { - it('should work'); +// default valid bs58 ed25519 did +const did = 'did:key:z6MktKwz7Ge1Yxzr4JHavN33wiwa8y81QdcMRLXQsrH9T53b'; +// The id of the keyAgreementKey used for encryption verification +const encryptionId = 'did:key:z6MktKwz7Ge1Yxzr4JHavN33wiwa8y81QdcMRLXQsrH9T5' + + '3b#z6LSfHfAMAopsuBxaBzvp51GXrPf38Az13j2fmwqadbwwrzJ'; +// The id of the verificationMethod used for signature verification +const signatureId = 'did:key:z6MktKwz7Ge1Yxzr4JHavN33wiwa8y81QdcMRLXQsrH9T53' + + 'b#z6MktKwz7Ge1Yxzr4JHavN33wiwa8y81QdcMRLXQsrH9T53b'; + +describe('api', function() { + it('should resolve did', async function() { + const result = await makeRequest({did}); + shouldBeDidResolverResponse(result); + }); + it('should resolve signature verification method', async function() { + const result = await makeRequest({did: signatureId}); + shouldBeDidResolverResponse(result); + }); + it('should resolve encryption verification method', async function() { + const result = await makeRequest({did: encryptionId}); + shouldBeDidResolverResponse(result); + }); }); diff --git a/test/mocha/assertions.js b/test/mocha/assertions.js new file mode 100644 index 0000000..7e05972 --- /dev/null +++ b/test/mocha/assertions.js @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. + */ + +/** + * Tests the properties of a did resolver return value. + * + * @param {object} data - The data from a did resolver. + * + * @returns {undefined} Just returns on success. + */ +export const shouldBeDidResolverResponse = data => { + const resolverResponse = 'DID Resolver response'; + should.exist(data, `Expected ${resolverResponse} to have data.`); + data.should.be.an( + 'object', + `Expected ${resolverResponse} data to be an object` + ); + data.should.have.property('didDocument'); + data.should.have.property('@context'); + data.should.have.property('didDocumentMetadata'); + const didMetaData = data.didDereferencingMetadata || + data.didResolutionMetadata; + should.exist(didMetaData, 'Expected didResolver data to have either' + + ' "didDereferencingMetadata" or "didResolutionMetadata"'); +}; + diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js new file mode 100644 index 0000000..2036641 --- /dev/null +++ b/test/mocha/helpers.js @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2021-2022 Digital Bazaar, Inc. All rights reserved. + */ +import {agent} from '@bedrock/https-agent'; +import {httpClient} from '@digitalbazaar/http-client'; + +const headers = { + Accept: 'application/ld+json;profile="https://w3id.org/did-resolution"' +}; + +export const makeRequest = async ({did}) => { + const basePath = '/1.0/identifiers/resolve/'; + const url = `https://localhost:52443${basePath}${encodeURIComponent(did)}`; + const {data} = await httpClient.get(url, {headers, agent}); + return data; +}; + diff --git a/test/package.json b/test/package.json index 735c2f9..0e6ac72 100644 --- a/test/package.json +++ b/test/package.json @@ -2,24 +2,26 @@ "name": "bedrock-did-resolver-http-test", "version": "0.0.1-0", "private": true, + "type": "module", "scripts": { "test": "node --preserve-symlinks test.js test", - "coverage": "cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text-summary npm test", - "coverage-ci": "cross-env NODE_ENV=test nyc --reporter=lcovonly npm test", - "coverage-report": "nyc report" + "coverage": "cross-env NODE_ENV=test c8 --reporter=lcov --reporter=text-summary npm test", + "coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly npm test", + "coverage-report": "c8 report" }, "dependencies": { - "bedrock": "^4.1.1", - "bedrock-express": "^3.2.0", - "bedrock-https-agent": "^2.0.0", + "@bedrock/core": "^6.0.0", + "@bedrock/express": "^8.0.0", + "@bedrock/https-agent": "^4.0.0", + "@bedrock/mongodb": "^10.0.0", + "@bedrock/server": "^5.0.0", + "@bedrock/test": "^8.0.0", + "@digitalbazaar/http-client": "^3.2.0", "bedrock-did-resolver-http": "file:..", - "bedrock-mongodb": "^8.2.0", - "bedrock-server": "^2.7.0", - "bedrock-test": "^5.3.2", - "cross-env": "^7.0.3", - "nyc": "^15.1.0" + "c8": "^7.12.0", + "cross-env": "^7.0.3" }, - "nyc": { + "c8": { "excludeNodeModules": false, "include": [ "node_modules/bedrock-did-resolver-http/**" diff --git a/test/test.config.js b/test/test.config.js index b5bb5cc..f535e8f 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -1,10 +1,11 @@ /* * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; +import {config} from '@bedrock/core'; +import {fileURLToPath} from 'node:url'; +import path from 'path'; -const {config} = require('bedrock'); -const path = require('path'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // MongoDB config.mongodb.name = 'bedrock_did_resolver_http_test'; @@ -15,3 +16,8 @@ config.mocha.tests.push(path.join(__dirname, 'mocha')); // allow self-signed certs in test framework config['https-agent'].rejectUnauthorized = false; + +// server info +config.server.port = 52443; +config.server.httpPort = 52080; +config.server.domain = 'localhost'; diff --git a/test/test.js b/test/test.js index 83a89e1..ca0310e 100644 --- a/test/test.js +++ b/test/test.js @@ -1,12 +1,10 @@ /* * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; +import * as bedrock from '@bedrock/core'; +import '@bedrock/https-agent'; +import '@bedrock/mongodb'; +import 'bedrock-did-resolver-http'; +import '@bedrock/test'; -const bedrock = require('bedrock'); -require('bedrock-https-agent'); -require('bedrock-mongodb'); -require('bedrock-did-resolver-http'); - -require('bedrock-test'); -bedrock.start(); +bedrock.start().catch(err => console.error(err));