diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e54a80..9f003c6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,4 +22,23 @@ jobs: uses: coverallsapp/github-action@v2 with: github-token: ${{ github.token }} - + + docker-build-push: + runs-on: ubuntu-latest + needs: test-node + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: skybridgeskills + password: ${{ secrets.DOCKERHUB_PAT }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: skybridgeskills/dcc-transaction-service:${{ github.sha }} diff --git a/.gitignore b/.gitignore index fa2c3b5..a2c7b51 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ lerna-debug.log* package-lock.json yarn.lock +pnpm-lock.yaml # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -75,6 +76,7 @@ types/ # dotenv environment variables file .env .env.test +test-fixtures/.env.testing # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/.husky/pre-commit b/.husky/pre-commit index b56da6c..72c4429 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx lint-staged -npm test \ No newline at end of file +npm test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..9de2256 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/iron diff --git a/Dockerfile b/Dockerfile index 24dde4f..8b7b324 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM node:18-alpine +FROM node:20-alpine WORKDIR /app COPY . . RUN npm install -CMD ["node", "server.js"] -EXPOSE 4004 \ No newline at end of file +RUN npm run build +CMD ["node", "dist/server.js"] +EXPOSE 4004 diff --git a/README.md b/README.md index a85fcf6..5925f47 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # Transaction Manager Service _(@digitalcredentials/transaction-manager-service)_ -[![Build status](https://img.shields.io/github/actions/workflow/status/digitalcredentials/transaction-service/main.yml?branch=main)](https://github.com/digitalcredentials/transaction-service/actions?query=workflow%3A%22Node.js+CI%22) -[![Coverage Status](https://coveralls.io/repos/github/digitalcredentials/transaction-service/badge.svg?branch=main)](https://coveralls.io/github/digitalcredentials/transaction-service?branch=main) +[![Build +status](https://img.shields.io/github/actions/workflow/status/digitalcredentials/transaction-service/main.yml?branch=main)](https://github.com/digitalcredentials/transaction-service/actions?query=workflow%3A%22Node.js+CI%22) +[![Coverage +Status](https://coveralls.io/repos/github/digitalcredentials/transaction-service/badge.svg?branch=main)](https://coveralls.io/github/digitalcredentials/transaction-service?branch=main) -> Express app for managing the transactions used in [VC-API exchanges](https://w3c-ccg.github.io/vc-api/#initiate-exchange). +> Express app for managing the transactions used in [VC-API +> exchanges](https://w3c-ccg.github.io/vc-api/#initiate-exchange). -#### IMPORTANT NOTE ABOUT VERSIONING: If you are using a Docker Hub image of this repository, make sure you are reading the version of this README that corresponds to your Docker Hub version. If, for example, you are using the image `digitalcredentials/transaction-service:0.1.0` then you'll want to use the corresponding tagged repo: [https://github.com/digitalcredentials/transaction-service/tree/v0.1.0](https://github.com/digitalcredentials/transaction-service/tree/v0.1.0). If you are new here, then just read on... +#### IMPORTANT NOTE ABOUT VERSIONING: If you are using a Docker Hub image of this repository, make sure you are reading the version of this README that corresponds to your Docker Hub version. If, for example, you are using the image `digitalcredentials/transaction-service:0.1.0` then you'll want to use the corresponding tagged repo: [https://github.com/digitalcredentials/transaction-service/tree/v0.1.0](https://github.com/digitalcredentials/transaction-service/tree/v0.1.0). If you are new here, then just read on... ## Table of Contents @@ -19,137 +22,207 @@ ## Overview -This is an express app that: +This is a web app with an HTTP API served using the Hono framework that: + +- stores data associated with a [VC-API + exchange](https://w3c-ccg.github.io/vc-api/#initiate-exchange) and generates an exchangeID and + transactionID +- generates and verifies UUID challenges used in [DIDAuthentication Verifiable Presentation + Requests](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) +- verifies [DID Authentication](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) + signatures +- generates a multi-protocol query including DCC deep link and [CHAPI](https://chapi.io) wallet + queries +- processes VC-API requests for basic credential issuance workflow. + +One way of using this is behind the [DCC Workflow +Coordinator](https://github.com/digitalcredentials/workflow-coordinator), which serves as a proxy +and passes the DID authentication portion of the exchanges to this service to handle Wallet/Issuer +DIDAuth exchange prior to issuing a credential. + +This package can also be used directly as a VC-API server, offering exchanges that result in the +issuance of credentials. It will support additional protocols in the future, such as OpenID for +Verifiable Credential Issuance (OIDC4VCI). + +Especially meant to be used as a service within a Docker compose network, initialized by the +coordinator from within the Docker compose network, and then called externally by a wallet like the +[Leaner Credential Wallet](https://lcw.app). To that end, a Docker image for this app is published +to DockerHub to make it easier to wire this into a Docker compose network. -- stores data associated with a [VC-API exchange](https://w3c-ccg.github.io/vc-api/#initiate-exchange) and generates an exchangeID and transactionID -- generates and verifies UUID challenges used in [DIDAuthentication Verifiable Presentation Requests](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) -- verifies [DID Authentication](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) signatures -- generates both DCC deeplink and [CHAPI}(https://chapi.io) wallet queries - -Use as you like, but this is primarily intended to be used to with the [DCC Exchange Coordinator](https://github.com/digitalcredentials/exchange-coordinator) to manage the challenges in the Wallet/Issuer DIDAuth exchange. +## API -Especially meant to be used as a service within a Docker compose network, initialized by the coordinator from within the Docker compose network, and then called externally by a wallet like the [Leaner Credential Wallet](https://lcw.app). To that end, a Docker image for this app is published to DockerHub to make it easier to wire this into a Docker compose network. +Implements endpoints: -## API +### `POST /exchange` - Create Exchange Batch (Basic) -Implements four endpoints: +Initializes a batch of exchanges of [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/). +Expects an object containing the data that will later be used to issue the credentials, like so: -* POST /exchange +``` +{ + exchangeHost: "hostname to use when constructing the exchange endpoints", + tenantName: "the tenant with which to later sign the credentials", + data: [ + { + vc: "an unsigned populated Verifiable Credential", + retrievalId: "an ID to later use to select the generated VPR/deeplink for this credential" + }, + { + vc: "another unsigned populated Verifiable Credential", + retrievalId: "another ID to later use to select the generated VPR/deeplink for this credential" + }, + ... however many other credentials to setup an exchange for + ] +} +``` -Initializes the exchange for an array of [Verifiable Credentials](https://www.w3.org/TR/vc-data-model/). Expects an object containing the data that will later be used to issue the credentials, like so: +This endpoint returns a list of interactions to pass to a wallet such as the [Leaner Credential +Wallet](https://lcw.app) to initiate the exchange. - ``` - { - exchangeHost: "hostname to use when constructing the exchange endpoints", - tenantName: "the tenant with which to later sign the credentials", - data: [ - { - vc: "an unsigned populated Verifiable Credential", - retrievalId: "an ID to later use to select the generated VPR/deeplink for this credential" - }, - { - vc: "another unsigned populated Verifiable Credential", - retrievalId: "another ID to later use to select the generated VPR/deeplink for this credential" - }, - ... however many other credentials to setup an exchange for - ] - } - ``` +### `POST /workflows/:workflowId/exchanges` - Create Exchange (VC-API) - The endpoint stores the data in a key/value store along with newly generated UUIDs for the exchangeId, transactionId and a challenge to be used later for a [DIDAuthentication Verifiable Presentation Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication). +The endpoint stores the data in a key/value store along with newly generated UUIDs for the +exchangeId, transactionId and a challenge to be used later for a [DIDAuthentication Verifiable +Presentation Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication). - The endpoint returns an object with two options for opening a wallet: a custom deeplink that will open the Learner Credential Wallet and a [CHAPI}(https://chapi.io) request that can be used to open a [CHAPI}(https://chapi.io) enabled wallet. In both cases the deeplink or chapi request will prompt the wallet to submit a DID Authenticaion to the exchange endpoint, which will return the signed credential. +The endpoint returns an object with two options for opening a wallet: a custom deep link that will +open the Learner Credential Wallet and a [CHAPI](https://chapi.io) request that can be used to open +a CHAPI-enabled wallet. In both cases the deep link or CHAPI request will prompt the wallet to +submit a DID Authentication to the exchange endpoint, which will return the signed credential. - The object will look something like so: +The object will look something like so: (TODO update) - ```json +```json [ - { - "retrievalId": "someId", - "directDeepLink": "https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=27485032-e0bc-4d74-bb5a-bb778cd7f8e3&vc_request_url=http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379/27485032-e0bc-4d74-bb5a-bb778cd7f8e3", - "vprDeepLink": "https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&vc_request_url=http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379", - "chapiVPR": { - "query": { - "type": "DIDAuthentication" - }, - "interact": { - "service": [ - { - "type": "VerifiableCredentialApiExchangeService", - "serviceEndpoint": "http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379/27485032-e0bc-4d74-bb5a-bb778cd7f8e3" - }, - { - "type": "CredentialHandlerService" - } - ] - }, - "challenge": "27485032-e0bc-4d74-bb5a-bb778cd7f8e3", - "domain": "http://localhost:4005" - } + { + "retrievalId": "someId", + "directDeepLink": "https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=27485032-e0bc-4d74-bb5a-bb778cd7f8e3&vc_request_url=http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379/27485032-e0bc-4d74-bb5a-bb778cd7f8e3", + "vprDeepLink": "https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&vc_request_url=http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379", + "chapiVPR": { + "query": { + "type": "DIDAuthentication" + }, + "interact": { + "service": [ + { + "type": "VerifiableCredentialApiExchangeService", + "serviceEndpoint": "http://localhost:4005/exchange/993cce5e-58a8-41ce-a055-bef4a8253379/27485032-e0bc-4d74-bb5a-bb778cd7f8e3" + }, + { + "type": "CredentialHandlerService" + } + ] + }, + "challenge": "27485032-e0bc-4d74-bb5a-bb778cd7f8e3", + "domain": "http://localhost:4005" } + } ] - ``` +``` -* POST /exchange/{exchangeId} +- POST /exchange/{exchangeId} -Called by the wallet to initiate the exchange. Returns a [DIDAuthentication Verifiable Presentation Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) asking the wallet for a DID Authentication +Called by the wallet to initiate the exchange. Returns a [DIDAuthentication Verifiable Presentation +Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) asking the wallet for a DID +Authentication -* POST /exchange/{exchangeId}/{transactionId} +- POST /exchange/{exchangeId}/{transactionId} -Called by the wallet to complete the exchange. Receives the requested DID Authentication and returns the signed Verifiable Credential after verifying the DID Authentication. +Called by the wallet to complete the exchange. Receives the requested DID Authentication and returns +the signed Verifiable Credential after verifying the DID Authentication. NOTE: the object returned from the initial setup call to the exchanger returns two deepLinks: -- `directDeepLink` which prompts the wallet to bypass the `POST /exchange/{exchangeId}` initiation call, and instead simply instead immediately submit the DID Authentication. The signed credential is returned from this call. So this is a one-step process. -- `vprDeepLink` which prompts the wallet to first call the inititaion endpoint, from which the [DIDAuthentication Verifiable Presentation Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) is returned, and after which the wallet then submits its DID Authentication. So this is a two-step process. +- `directDeepLink` which prompts the wallet to bypass the `POST /exchange/{exchangeId}` initiation + call, and instead simply instead immediately submit the DID Authentication. The signed credential + is returned from this call. So this is a one-step process. +- `vprDeepLink` which prompts the wallet to first call the initiation endpoint, from which the + [DIDAuthentication Verifiable Presentation + Request](https://w3c-ccg.github.io/vp-request-spec/#did-authentication) is returned, and after + which the wallet then submits its DID Authentication. So this is a two-step process. At the moment, the [Leaner Credential Wallet](https://lcw.app) only supports the directDeepLink. -* GET /healthz +- GET /healthz -Which is an endpoint typically meant to be called by the Docker [HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck) option for a specific service. Read more below in the [Health Check](#health-check) section. +Which is an endpoint typically meant to be called by the Docker +[HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck) option for a specific +service. Read more below in the [Health Check](#health-check) section. ## Health Check -Docker has a [HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck) option for monitoring the -state (health) of a container. We've included an endpoint `GET healthz` that checks the health of the signing service (by running a test signature). The endpoint can be directly specified in a CURL or WGET call on the HEALTHCHECK, but we also provide a [healthcheck.js](./healthcheck.js) function that can be similarly invoked by the HEALTHCHECK and which itself hits the `healthz` endpoint, but additionally provides options for both email and Slack notifications when the service is unhealthy. +Docker has a [HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck) option for +monitoring the state (health) of a container. We've included an endpoint `GET /healthz` that checks +the health of the data storage backend. The endpoint can be directly +specified in a CURL or WGET call on the HEALTHCHECK, but we also provide a +[healthcheck.js](./healthcheck.js) function that can be similarly invoked by the HEALTHCHECK and +which itself hits the `healthz` endpoint, but additionally provides options for both email and Slack +notifications when the service is unhealthy. -You can see how we've configured the HEALTHCHECK in our [example compose files](https://github.com/digitalcredentials/docs/blob/main/deployment-guide/DCCDeploymentGuide.md#docker-compose-examples). Our compose files also include an example of how to use [autoheal](https://github.com/willfarrell/docker-autoheal) together with HEALTHCHECK to restart an unhealthy container. +You can see how we've configured the HEALTHCHECK in our [example compose +files](https://github.com/digitalcredentials/docs/blob/main/deployment-guide/DCCDeploymentGuide.md#docker-compose-examples). +Our compose files also include an example of how to use +[autoheal](https://github.com/willfarrell/docker-autoheal) together with HEALTHCHECK to restart an +unhealthy container. -If you want notifications sent to a Slack channel, you'll have to set up a Slack [web hook](https://api.slack.com/messaging/webhooks). +If you want notifications sent to a Slack channel, you'll have to set up a Slack [web +hook](https://api.slack.com/messaging/webhooks). -If you want notifications sent to an email address, you'll need an SMTP server to which you can send emails, so something like sendgrid, mailchimp, mailgun, or even your own email account if it allows direct SMTP sends. Gmail can apparently be configured to so so. +If you want notifications sent to an email address, you'll need an SMTP server to which you can send +emails, so something like Sendgrid, Mailchimp, Mailgun, or even your own email account if it allows +direct SMTP sends. Gmail can apparently be configured to so so. ## Environment Variables -There is a sample .env file provided called .env.example to help you get started with your own .env file. The supported fields: - -| Key | Description | Default | Required | -| --- | --- | --- | --- | -| `PORT` | http port on which to run the express app | 4006 | no | -| `HEALTH_CHECK_SMTP_HOST` | SMTP host for unhealthy notification emails - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_SMTP_USER` | SMTP user for unhealthy notification emails - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_SMTP_PASS` | SMTP password for unhealthy notification emails - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_EMAIL_FROM` | name of email sender for unhealthy notifications emails - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_EMAIL_RECIPIENT` | recipient when unhealthy - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_EMAIL_SUBJECT` | email subject when unhealthy - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_WEB_HOOK` | posted to when unhealthy - see [Health Check](#health-check) | no | no | -| `HEALTH_CHECK_SERVICE_URL` | local url for this service - see [Health Check](#health-check) | http://SIGNER:4004/healthz | no | -| `HEALTH_CHECK_SERVICE_NAME` | service name to use in error messages - see [Health Check](#health-check) | SIGNING-SERVICE | no | +There is a sample .env file provided called .env.example to help you get started with your own .env +file. The supported fields: + +| Key | Description | Default | Required | +| ------------------------------ | ------------------------------------------------------------------------------------------- | -------------------------- | -------- | +| `PORT` | http port on which to run the express app | 4006 | no | +| `HEALTH_CHECK_SMTP_HOST` | SMTP host for unhealthy notification emails - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_SMTP_USER` | SMTP user for unhealthy notification emails - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_SMTP_PASS` | SMTP password for unhealthy notification emails - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_EMAIL_FROM` | name of email sender for unhealthy notifications emails - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_EMAIL_RECIPIENT` | recipient when unhealthy - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_EMAIL_SUBJECT` | email subject when unhealthy - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_WEB_HOOK` | posted to when unhealthy - see [Health Check](#health-check) | no | no | +| `HEALTH_CHECK_SERVICE_URL` | local url for this service - see [Health Check](#health-check) | http://SIGNER:4004/healthz | no | +| `HEALTH_CHECK_SERVICE_NAME` | service name to use in error messages - see [Health Check](#health-check) | SIGNING-SERVICE | no | +| `DEFAULT_EXCHANGE_HOST` | default exchange host to use when constructing the exchange endpoints. This or a proxy. | http://localhost:4005 | no | +| `REDIS_URI` | Redis URI for storing exchange data. Use this or `PERSIST_TO_FILE` to a Keyv file. | no | no | +| `PERSIST_TO_FILE` | Full local file path to a Keyv data storage file. Priority over `REDIS_URI`. | false | no | +| `KEYV_WRITE_DELAY` | delay in milliseconds between writing to keyv and checking for expiration | 50 | no | +| `KEYV_EXPIRED_CHECK_DELAY` | delay in milliseconds between checking for expired exchanges | 1000 | no | ## Versioning -The transaction-service is primarily intended to run as a docker image within a docker compose network, typically as part of a flow that is orchestrated by the [DCC Issuer Coordinator](https://github.com/digitalcredentials/issuer-coordinator) and the [DCC Workflow Coordinator](https://github.com/digitalcredentials/workflow-coordinator). +The transaction-service is primarily intended to run as a docker image within a docker compose +network, typically as part of a flow that is orchestrated by the [DCC Issuer +Coordinator](https://github.com/digitalcredentials/issuer-coordinator) and the [DCC Workflow +Coordinator](https://github.com/digitalcredentials/workflow-coordinator). Set the +`DEFAULT_EXCHANGE_HOST` to the url of the outer reverse proxy that will be used to route requests to +the transaction-service. You don't have to expose this service to the public internet if there is a +proxy in front of it inside the docker compose network or VPC. -For convenience we've published the images for the transaction-service and the other services used by the coordinators, as well as for the coordinators themselves, to Docker Hub so that you don't have to build them locally yourself from the github repositories. +For convenience DCC has published the images for the transaction-service and the other services used +by the coordinators, as well as for the coordinators themselves, to Docker Hub so that you don't +have to build them locally yourself from the GitHub repositories. -The images on Docker Hub will of course at times be updated to add new functionality and fix bugs. Rather than overwrite the default (`latest`) version on Docker Hub for each update, we've adopted the [Semantic Versioning Guidelines](https://semver.org) with our docker image tags. +The images on Docker Hub will of course at times be updated to add new functionality and fix bugs. +Rather than overwrite the default (`latest`) version on Docker Hub for each update, we've adopted +the [Semantic Versioning Guidelines](https://semver.org) with our docker image tags. -We DO NOT provide a `latest` tag so you must provide a tag name (i.e, the version number) for the images in your docker compose file. +We DO NOT provide a `latest` tag so you must provide a tag name (i.e, the version number) for the +images in your docker compose file. -To ensure you've got compatible versions of the services and the coordinator, the `major` number for each should match. At the time of writing, the versions for each are at 0.1.0, and the `major` number (the leftmost number) agrees across all three. +To ensure you've got compatible versions of the services and the coordinator, the `major` number for +each should match. At the time of writing, the versions for each are at 0.1.0, and the `major` +number (the leftmost number) agrees across all three. -If you do ever want to work from the source code in the repository and build your own images, we've tagged the commits in Github that were used to build the corresponding Docker image. So a github tag of v0.1.0 coresponds to a docker image tag of 0.1.0 +If you do ever want to work from the source code in the repository and build your own images, we've +tagged the commits in GitHub that were used to build the corresponding Docker image. So a GitHub tag +of v0.1.0 corresponds to a docker image tag of 0.1.0 ## Development @@ -159,7 +232,7 @@ To install locally (for development): git clone https://github.com/digitalcredentials/transaction-manager-service.git cd transaction-manager-service npm install -npm dev +npm run dev ``` ## Contribute diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..5199555 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts", + "exec": "ts-node -r dotenv/config src/server.ts" +} diff --git a/package.json b/package.json index 63fc7b5..b39c819 100644 --- a/package.json +++ b/package.json @@ -1,49 +1,68 @@ { "name": "@digitalcredentials/transaction-service", - "description": "An express app for managing challenges in a DIDAuth exchange.", + "description": "A hono app for managing DID Auth and VC-API exchanges.", "version": "0.3.0", "type": "module", "scripts": { - "start": "node -r dotenv/config server.js", - "dev": "nodemon -r dotenv/config server.js", - "dev-noenv": "nodemon server.js", - "test": "NODE_OPTIONS=--experimental-vm-modules npx c8 mocha --timeout 10000 -r dotenv/config dotenv_config_path=src/test-fixtures/.env.testing src/app.test.js ", - "coveralls": "npm run test; npx c8 report --reporter=text-lcov > ./coverage/lcov.info", - "prepare": "test -d node_modules/husky && husky install || echo \"husky is not installed\"", + "build": "tsc", + "start": "node -r dotenv/config dist/server.js", + "dev": "nodemon -r dotenv/config --exec tsx src/server.ts", + "dev-noenv": "nodemon --exec tsx src/server.ts", + "test": "dotenvx run -f src/test-fixtures/.env.testing -- vitest run", + "coverage": "dotenvx run -f src/test-fixtures/.env.testing -- vitest run --coverage", + "prepare": "husky", "lint": "eslint", "lint-fix": "eslint --fix" }, "dependencies": { + "@digitalbazaar/data-integrity": "^2.5.0", + "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.2.0", + "@digitalbazaar/ed25519-multikey": "^1.3.1", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/ed25519-verification-key-2020": "^4.1.0", + "@digitalbazaar/eddsa-rdfc-2022-cryptosuite": "^1.2.0", "@digitalbazaar/vc": "^7.0.0", "@digitalcredentials/security-document-loader": "^6.0.0", + "@hono/node-server": "^1.14.4", + "@keyv/redis": "^4.4.0", "axios": "^1.7.7", "cookie-parser": "~1.4.4", "cors": "^2.8.5", "debug": "~2.6.9", "dotenv": "^16.0.3", - "express": "~4.16.1", - "keyv": "^4.5.2", - "keyv-file": "^0.2.0", + "express": "^5.1.0", + "handlebars": "^4.7.8", + "hono": "^4.7.11", + "keyv": "^5.3.3", + "keyv-file": "^5.1.2", "morgan": "~1.9.1", - "nodemailer": "^6.9.14" + "nodemailer": "^6.9.14", + "tsx": "^4.20.1", + "zod": "^3.25.56" }, "devDependencies": { + "@dotenvx/dotenvx": "^1.44.1", "@eslint/js": "^9.3.0", + "@types/chai": "^5.2.2", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/morgan": "^1.9.10", + "@types/node": "^22.15.30", + "@types/supertest": "^6.0.3", + "@vitest/coverage-v8": "^3.2.3", "chai": "^4.3.7", "coveralls": "^3.1.1", "eslint": "^9.3.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-mocha": "^10.4.3", "globals": "^15.3.0", "husky": "^9.0.11", "lint-staged": "^15.2.5", - "mocha": "^10.2.0", "nock": "^13.5.4", "nodemon": "^2.0.21", "prettier": "3.2.5", - "supertest": "^6.3.3" + "supertest": "^6.3.3", + "typescript": "^5.8.3", + "vitest": "^3.2.3" }, "keywords": [ "dcc" diff --git a/server.js b/server.js deleted file mode 100644 index 448d6dd..0000000 --- a/server.js +++ /dev/null @@ -1,15 +0,0 @@ -import { build } from './src/app.js' -import http from "http" -const DEFAULT_PORT = 4004 - -const run = async () => { - const port = process.env.PORT | DEFAULT_PORT - const app = await build(); - http.createServer(app).listen(port, () => console.log(`Server running on port ${port}`)) -} - -run(); - - - - diff --git a/src/TransactionException.js b/src/TransactionException.js deleted file mode 100644 index 3a1e014..0000000 --- a/src/TransactionException.js +++ /dev/null @@ -1,6 +0,0 @@ -export default function TransactionException(code, message, stack) { - this.code = code - this.message = message - this.stack = stack - } - \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..c56dabf --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,128 @@ +declare global { + namespace App { + interface Config { + port: number + exchangeHost: string + exchangeTtl: number + statusService: string + signingService: string + defaultWorkflow: string + defaultTenantName: string + keyvFilePath?: string + redisUri?: string + keyvWriteDelayMs: number + keyvExpiredCheckDelayMs: number + } + + interface ErrorResponseBody { + code: number + message: string + details?: Array<{ + code: string + message: string + path: Array + }> + } + + interface Credential extends Record { + credentialSubject: Record & { + id: string + } + } + + interface ExchangeBatch { + data: Array<{ + vc: string // JSON template string + subjectData?: Record + retrievalId?: string // Optional for later retrieval/correlation of this record + metadata?: Record // Additional data to store related to the exchange + redirectUrl?: string + }> + batchId?: string + exchangeHost: string + tenantName: string + workflowId?: string + } + + interface ExchangeCreateInput { + expires?: string + variables: Record & { + vc?: string + redirectUrl?: string + exchangeHost: string + tenantName: string + challenge?: string + } + } + + interface ExchangeDetail { + // Local metadata + tenantName: string + workflowId: string + + // VC-API metadata + exchangeId: string + expires: string + state: 'pending' | 'active' | 'completed' | 'invalid' + variables: { + vc?: string + redirectUrl?: string + retrievalId?: string + exchangeHost: string + metadata?: Record + challenge: string // Used to authenticate presentations + } + } + + interface WorkflowStep { + createChallenge: boolean + verifiablePresentationRequest: { + query: Array<{ type: string } & Record> // Simplistic for now + } + } + + interface Workflow { + id: string + steps: Record + initialStep: string + credentialTemplates?: Array<{ + id: string + type: 'handlebars' // TODO: add 'jsonata' + template: string + }> + } + + interface VPR { + query: { + type: 'DIDAuthentication' | 'QueryByExample' + } & Record + interact: { + service: Array<{ + type: + | 'VerifiableCredentialApiExchangeService' + | 'UnmediatedPresentationService2021' + | 'CredentialHandlerService' + serviceEndpoint?: string + }> + } + challenge: string + domain: string + } + + interface Protocols { + vcapi?: string + verifiablePresentationRequest: VPR + lcw?: string + } + + interface DCCWalletQuery { + retrievalId: string + directDeepLink: string + vprDeepLink: string + chapiVPR?: VPR + metadata?: Record + } + } +} + +export {} diff --git a/src/app.js b/src/app.js deleted file mode 100644 index 71c776c..0000000 --- a/src/app.js +++ /dev/null @@ -1,121 +0,0 @@ -import express from 'express' -import logger from 'morgan' -import cors from 'cors' -import axios from 'axios' -import assert from 'node:assert/strict' - -import { - initializeTransactionManager, - setupExchange, - retrieveStoredData, - getVPR -} from './transactionManager.js' -import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' -import { getSignedDIDAuth } from './didAuth.js' - -export async function build() { - await initializeTransactionManager() - - var app = express() - - app.use(logger('dev')) - app.use(express.json()) - app.use(express.urlencoded({ extended: false })) - app.use(cors()) - - app.get('/', function (req, res) { - res.send({ message: 'transaction-service server status: ok.' }) - }) - - app.get('/healthz', async function (req, res) { - const baseURL = `${req.protocol}://${req.headers.host}` - const testData = getDataForExchangeSetupPost('test', baseURL) - const exchangeURL = `${baseURL}/exchange` - - try { - const response = await axios.post(exchangeURL, testData) - const { data: walletQuerys } = response - const walletQuery = walletQuerys.find((q) => q.retrievalId === 'someId') - const parsedDeepLink = new URL(walletQuery.directDeepLink) - const requestURI = parsedDeepLink.searchParams.get('vc_request_url') - const challenge = parsedDeepLink.searchParams.get('challenge') - const didAuth = await getSignedDIDAuth('did:ex:223234', challenge) - const { data } = await axios.post(requestURI, didAuth) - const { tenantName, vc: unSignedVC } = data - assert.equal(tenantName, 'test') - assert.ok(unSignedVC.issuer) - } catch (e) { - console.log(`exception in healthz: ${JSON.stringify(e)}`) - return res.status(503).json({ - error: `transaction-service healthz check failed with error: ${e}`, - healthy: false - }) - } - res.send({ - message: 'transaction-service server status: ok.', - healthy: true - }) - }) - - /* - This is step 1 in an exchange. - Creates a new exchange and stores the provided data - for later use in the exchange, in particular the subject data - with which to later construct the VC. - Returns a walletQuery object with both deeplinks - with which to trigger wallet selection that in turn - will trigger the exchange when the wallet opens. - */ - app.post('/exchange', async (req, res) => { - try { - const data = req.body - if (!data || !Object.keys(data).length) - return res - .status(400) - .send({ code: 400, message: 'No data was provided in the body.' }) - const walletQuerys = await setupExchange(data) - return res.json(walletQuerys) - } catch (error) { - console.log(error) - return res.status(error.code || 500).json(error) - } - }) - - /* - This is step 2 in an exchange, where the wallet - has asked to initiate the exchange, and we reply - here with a Verifiable Presentation Request, asking - for a DIDAuth. Note that in some scenarios the wallet - may skip this step and directly present the DIDAuth. - */ - app.post('/exchange/:exchangeId', async (req, res) => { - try { - const vpr = await getVPR(req.params.exchangeId) - return res.json(vpr) - } catch (error) { - console.log(error) - return res.status(error.code || 500).json(error) - } - }) - - /* - This is step 3 in an exchange, where we verify the - supplied DIDAuth, and if verified we return the previously - stored data for the exchange. - */ - app.post('/exchange/:exchangeId/:transactionId', async (req, res) => { - try { - const didAuth = req.body - const data = await retrieveStoredData( - req.params.exchangeId, - req.params.transactionId, - didAuth - ) - return res.json(data) - } catch (error) { - return res.status(error.code || 500).json(error) - } - }) - - return app -} diff --git a/src/app.test.js b/src/app.test.js deleted file mode 100644 index bc11057..0000000 --- a/src/app.test.js +++ /dev/null @@ -1,331 +0,0 @@ -import { expect } from 'chai' - -import request from 'supertest' -import crypto from 'crypto' -import { build } from './app.js' -import { getDataForExchangeSetupPost, testVC } from './test-fixtures/testData.js' -import { getSignedDIDAuth } from './didAuth.js' -import { - clearKeyv, - initializeTransactionManager -} from './transactionManager.js' -import TransactionException from './TransactionException.js' -const tempKeyvFile = process.env.PERSIST_TO_FILE - -let app - -describe('api', function () { - beforeEach(async function () { - app = await build() - }) - - describe('GET /', function () { - it('GET / => hello', function (done) { - request(app) - .get('/') - .expect(200) - .expect('Content-Type', /json/) - .expect(/{"message":"transaction-service server status: ok."}/, done) - }) - }) - - describe('GET /unknown', function () { - it('unknown endpoint returns 404', function (done) { - request(app).get('/unknown').expect(404, done) - }, 10000) - }) - - describe('POST /exchange', function () { - it('returns 400 if no body', function (done) { - request(app) - .post('/exchange') - .expect('Content-Type', /json/) - .expect(400, done) - }) - - it('returns array of wallet queries', async function () { - const testData = getDataForExchangeSetupPost('test') - const response = await request(app).post('/exchange').send(testData) - - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(200) - expect(response.body) - expect(response.body.length).to.eql(testData.data.length) - }) - - it('returns array of wallet queries', async () => { - const testData = getDataForExchangeSetupPost('test') - const response = await request(app) - .post("/exchange") - .send(testData) - expect(response.header["content-type"]).to.have.string("json"); - expect(response.status).to.eql(200); - expect(response.body) - expect(response.body.length).to.eql(testData.data.length) - - const walletQuerys = response.body - const walletQuery = walletQuerys.find(q => q.retrievalId === 'someId') - const url = walletQuery.directDeepLink - - const parsedDeepLink = new URL(url) - const requestURI = parsedDeepLink.searchParams.get('vc_request_url'); //should be http://localhost:4004/exchange?challenge=VOclS8ZiMs&auth_type=bearer - // here we need to pull out just the path - // since we are calling the endpoint via - // supertest - const path = (new URL(requestURI)).pathname - const challenge = parsedDeepLink.searchParams.get('challenge'); // the challenge that the exchange service generated - const didAuth = await getSignedDIDAuth('did:ex:223234', challenge) - - const exchangeResponse = await request(app) - .post(path) - .send(didAuth) - - expect(exchangeResponse.header["content-type"]).to.have.string("json"); - expect(exchangeResponse.status).to.eql(200); - expect(exchangeResponse.body) - - const signedVC = exchangeResponse.body.vc - expect(signedVC).to.eql(testVC) - - }) - - - it('returns error if missing exchangeHost', async function () { - const testData = getDataForExchangeSetupPost('test') - delete testData.exchangeHost - const response = await request(app).post('/exchange').send(testData) - const body = response.body - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(400) - expect(body.code).to.eql(400) - expect(body.message).to.eql( - 'Incomplete exchange data - you must provide an exchangeHost' - ) - }) - - it('returns error if missing tenantName', async function () { - const testData = getDataForExchangeSetupPost('test') - delete testData.tenantName - const response = await request(app).post('/exchange').send(testData) - const body = response.body - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(400) - expect(body.code).to.eql(400) - expect(body.message).to.eql( - 'Incomplete exchange data - you must provide a tenant name' - ) - }) - - it('returns error if missing vc or subjectData', async function () { - const testData = getDataForExchangeSetupPost('test') - delete testData.data[0].vc - const response = await request(app).post('/exchange').send(testData) - const body = response.body - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(400) - expect(body.code).to.eql(400) - expect(body.message).to.eql( - 'Incomplete exchange data - you must provide either a vc or subjectData' - ) - }) - - it('returns error if missing batchId with subjectData', async function () { - const testData = getDataForExchangeSetupPost('test') - delete testData.data[0].vc - testData.data[0].subjectData = { hello: 'trouble' } - const response = await request(app).post('/exchange').send(testData) - const body = response.body - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(400) - expect(body.code).to.eql(400) - expect(body.message).to.eql( - 'Incomplete exchange data - if you provide subjectData, you must also provide a batchId' - ) - }) - - it('returns error if missing retrievalId', async function () { - const testData = getDataForExchangeSetupPost('test') - delete testData.data[0].retrievalId - const response = await request(app).post('/exchange').send(testData) - const body = response.body - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(400) - expect(body.code).to.eql(400) - expect(body.message).to.eql( - "Incomplete exchange data - every submitted record must have it's own retrievalId." - ) - }) - }) - - describe('keyv', function () { - before(async function () { - clearKeyv() - delete process.env.PERSIST_TO_FILE - app = await build() - }) - - after(async function () { - process.env.PERSIST_TO_FILE = tempKeyvFile - }) - - it('uses in-memory keyv', function (done) { - request(app) - .post('/exchange') - .expect('Content-Type', /json/) - .expect(400, done) - }) - }) - - describe('POST /exchange/exchangeId/transactionId', function () { - it('returns 404 if invalid', function (done) { - request(app) - .post('/exchange/234/123') - .expect('Content-Type', /json/) - .expect(404, done) - }) - }) - - describe('POST /exchange/exchangeId', function () { - it('returns 404 if invalid', function (done) { - request(app) - .post('/exchange/234') - .expect('Content-Type', /json/) - .expect(404, done) - }) - }) - - describe('GET /healthz', function () { - it('returns 200 if healthy', async function () { - const response = await request(app).get('/healthz') - - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(200) - expect(response.body).to.eql({ - message: 'transaction-service server status: ok.', - healthy: true - }) - }) - - it('returns 503 if internal error', async function () { - // we delete the keyv store to force an error - clearKeyv() - const response = await request(app).get('/healthz') - - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(503) - expect(response.body).to.have.property('healthy', false) - initializeTransactionManager() - }) - }) - - describe('TransactionException', function () { - it('sets props on Exception', function () { - const code = 404 - const message = 'a test message' - const stack = { test: 'test' } - const obj = new TransactionException(code, message, stack) - expect(obj.code).to.eql(code) - expect(obj.message).to.eql(message) - expect(obj.stack).to.eql(stack) - }) - }) - - describe('POST /exchange - direct', function () { - it('does the direct exchange', async function () { - const { path, challenge } = await doSetupWithDirectDeepLink(app) - const didAuth = await getSignedDIDAuth('did:ex:223234', challenge) - const exchangeResponse = await request(app).post(path).send(didAuth) - verifyReturnedData(exchangeResponse) - }) - - it('returns error for bad didAuth', async function () { - const { path } = await doSetupWithDirectDeepLink(app) - // use a different challenge than was issued - const didAuth = await getSignedDIDAuth('did:ex:223234', 'badChallenge') - const exchangeResponse = await request(app).post(path).send(didAuth) - expect(exchangeResponse.header['content-type']).to.have.string('json') - expect(exchangeResponse.status).to.eql(401) - expect(exchangeResponse.body) - - const responseErrorObject = exchangeResponse.body - expect(responseErrorObject.code).to.eql(401) - expect(responseErrorObject.message).to.eql('Invalid DIDAuth.') - }) - - it('does the vpr exchange', async function () { - const walletQuery = await doSetup(app) - const url = walletQuery.vprDeepLink - - // Step 2. mimics what the wallet would do when opened by deeplink - // which is to parse the deeplink and call the exchange initiation endpoint - const parsedDeepLink = new URL(url) - const inititationURI = parsedDeepLink.searchParams.get('vc_request_url') - - // strip out the host because we are using supertest - const initiationURIPath = new URL(inititationURI).pathname - - const initiationResponse = await request(app).post(initiationURIPath) - expect(initiationResponse.header['content-type']).to.have.string('json') - expect(initiationResponse.status).to.eql(200) - expect(initiationResponse.body) - - const vpr = initiationResponse.body - - // Step 3. mimics what the wallet does once it's got the VPR - const challenge = vpr.verifiablePresentationRequest.challenge // the challenge that the exchange service generated - const continuationURI = - vpr.verifiablePresentationRequest.interact.service.find( - (service) => service.type === 'UnmediatedPresentationService2021' - ).serviceEndpoint - // strip out the host because we are using supertest - const continuationURIPath = new URL(continuationURI).pathname - const randomId = `did:ex:${crypto.randomUUID()}` - const didAuth = await getSignedDIDAuth(randomId, challenge) - - const continuationResponse = await request(app) - .post(continuationURIPath) - .send(didAuth) - - verifyReturnedData(continuationResponse) - }) - }) -}) - -const doSetup = async (app) => { - const testData = getDataForExchangeSetupPost('test') - const response = await request(app).post('/exchange').send(testData) - - expect(response.header['content-type']).to.have.string('json') - expect(response.status).to.eql(200) - expect(response.body) - expect(response.body.length).to.eql(testData.data.length) - - const walletQuerys = response.body - const walletQuery = walletQuerys.find((q) => q.retrievalId === 'someId') - return walletQuery -} - -const doSetupWithDirectDeepLink = async (app) => { - const walletQuery = await doSetup(app) - const url = walletQuery.directDeepLink - - const parsedDeepLink = new URL(url) - const requestURI = parsedDeepLink.searchParams.get('vc_request_url') //should be http://localhost:4004/exchange?challenge=VOclS8ZiMs&auth_type=bearer - // here we need to pull out just the path - // since we are calling the endpoint via - // supertest - const path = new URL(requestURI).pathname - const challenge = parsedDeepLink.searchParams.get('challenge') // the challenge that the exchange service generated - return { path, challenge } -} - -const verifyReturnedData = (exchangeResponse) => { - expect(exchangeResponse.header['content-type']).to.have.string('json') - expect(exchangeResponse.status).to.eql(200) - expect(exchangeResponse.body) - - const storedData = exchangeResponse.body - expect(storedData.vc.issuer.id).to.exist - expect(storedData.tenantName).to.eql('test') - expect(storedData.retrievalId).to.eql('someId') -} diff --git a/src/app.test.ts b/src/app.test.ts new file mode 100644 index 0000000..3c47cc8 --- /dev/null +++ b/src/app.test.ts @@ -0,0 +1,500 @@ +import { expect, test, describe, beforeAll, afterAll, vi } from 'vitest' +import { testClient } from 'hono/testing' +import axios from 'axios' +import crypto from 'crypto' +import { app, type AppType } from './hono.js' +import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' +import { getSignedDIDAuth } from './didAuth.js' +import { + saveExchange, + initializeTransactionManager +} from './transactionManager.js' +import * as transactionManager from './transactionManager.js' +import * as config from './config.js' + +import { HTTPException } from 'hono/http-exception' + +describe('api', function () { + const client = testClient(app) + + describe('GET /', function () { + test('GET / => hello', async function () { + const response = await client.index.$get() + expect(response.status).toBe(200) + expect(await response.text()).toContain( + '{"message":"transaction-service server status: ok."}' + ) + }) + }) + + describe('GET /unknown', function () { + test('unknown endpoint returns 404', async function () { + const response = await app.request('/unknown') + expect(response.status).toBe(404) + }) + }) + + describe('POST /exchange', function () { + test('returns 400 if no body', async function () { + const response = await await app.request('/exchange', { + method: 'POST' + }) + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toContain('json') + }) + + test('returns 400 if invalid JSON', async function () { + const response = await await app.request('/exchange', { + method: 'POST', + body: '{"invalid/json$', + headers: { + 'Content-Type': 'application/json' + } + }) + expect(response.status).toBe(400) + const body = (await response.json()) as unknown as App.ErrorResponseBody + expect(response.headers.get('content-type')).toContain('json') + expect(body.code).toBe(400) + expect(body.message).toContain('Malformed JSON') + }) + + test('returns array of wallet queries', async function () { + const testData = getDataForExchangeSetupPost('test') + const response = await client.exchange.$post({ + json: testData + }) + const body = (await response.json()) as any + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(200) + + expect(body).toBeDefined() + expect(body.length).toBe(testData.data.length) + }) + + test('successful DID auth exchange from batch creation', async function () { + const testData = getDataForExchangeSetupPost('test') + const response = await client.exchange.$post({ json: testData }) + expect(response.headers.get('content-type')).toContain('json') + const body = (await response.json()) as any + expect(response.status).toBe(200) + + expect(body).toBeDefined() + expect(body.length).toBe(testData.data.length) + + const walletQuerys = body as App.DCCWalletQuery[] + const walletQuery = walletQuerys.find((q) => q.retrievalId === 'someId') + expect(walletQuery).toBeDefined() + const url = walletQuery?.vprDeepLink ?? '' + + const parsedDeepLink = new URL(url) + //should be http://localhost:4004/exchange?challenge=VOclS8ZiMs&auth_type=bearer + const requestURI = parsedDeepLink.searchParams.get('vc_request_url') ?? '' + // here we need to pull out just the path + // since we are calling the endpoint via + // supertest + const path = new URL(requestURI).pathname + // the challenge that the exchange service generated + const challenge = parsedDeepLink.searchParams.get('challenge') ?? '' + const didAuth = await getSignedDIDAuth(challenge) + + const exchangeResponse = await app.request(path, { + method: 'POST', + body: JSON.stringify(didAuth), + headers: { + 'Content-Type': 'application/json' + } + }) + + expect(exchangeResponse.headers.get('content-type')).toContain('json') + const exchangeBody = await exchangeResponse.json() + expect(exchangeResponse.status).toBe(200) + expect(exchangeBody).toBeDefined() + // the didAuth exchange should not return a VC (and really this endpoint should return a VP, not a VC eh?) + expect(exchangeBody.vc).toEqual(undefined) + }) + + test('returns error if missing exchangeHost', async function () { + const { exchangeHost, ...testData } = getDataForExchangeSetupPost('test') + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await response.json() + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(400) + expect(body.code).toBe(400) + expect(body.message).toContain( + 'Incomplete exchange data - you must provide an exchangeHost' + ) + }) + + test('returns error if missing tenantName', async function () { + const { tenantName, ...testData } = getDataForExchangeSetupPost('test') + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await response.json() + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(400) + expect(body.code).toBe(400) + expect(body.message).toContain( + 'Incomplete exchange data - you must provide a tenant name' + ) + }) + + test('returns error if missing vc or subjectData', async function () { + const testData = getDataForExchangeSetupPost('test') + // @ts-ignore + delete testData.data[0].vc + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await response.json() + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(400) + expect(body.code).toBe(400) + expect(body.message).toContain( + 'Incomplete exchange data - you must provide either a vc or subjectData' + ) + }) + + test('returns error if missing batchId with subjectData', async function () { + const testData = getDataForExchangeSetupPost('test') as App.ExchangeBatch + // @ts-ignore + delete testData.data[0].vc + testData.data[0].subjectData = { hello: 'trouble' } + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await response.json() + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(400) + expect(body.code).toBe(400) + expect(body.message).toContain( + 'Incomplete exchange data - if you provide subjectData, you must also provide a batchId' + ) + }) + + test('returns error if missing retrievalId', async function () { + const testData = getDataForExchangeSetupPost('test') + // @ts-ignore + delete testData.data[0].retrievalId + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await response.json() + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(400) + expect(body.code).toBe(400) + expect(body.message).toContain( + "Incomplete exchange data - every submitted record must have it's own retrievalId." + ) + }) + }) + + describe('keyv', function () { + beforeAll(async function () { + // Mock saveExchange to throw an error + vi.spyOn({ saveExchange }, 'saveExchange').mockImplementation( + async () => { + throw new HTTPException(500, { message: 'Failed to save exchange.' }) + } + ) + }) + + afterAll(async function () { + vi.restoreAllMocks() + await initializeTransactionManager() + }) + + test('uses in-memory keyv', async function () { + const response = await app.request('/exchange', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + expect(response.headers.get('Content-Type')).toContain('json') + expect(response.status).toBe(400) + }) + }) + + describe('participateInExchange: POST /workflows/:workflowId/exchanges/:exchangeId', function () { + test('returns 404 if invalid workflowId', async function () { + const response = await app.request( + '/workflows/NO-SUCH-WORKFLOW/exchanges/123', + { + method: 'POST' + } + ) + expect(response.headers.get('Content-Type')).toContain('json') + const body = await response.json() + expect(response.status).toBe(404) + expect(body.code).toBe(404) + }) + }) + + describe('POST /exchange/exchangeId', function () { + test('returns 404 if invalid exchangeId', async function () { + const response = await app.request( + '/workflows/didAuth/exchanges/NO-SUCH-EXCHANGE', + { + method: 'POST' + } + ) + expect(response.headers.get('Content-Type')).toContain('json') + const body = await response.json() + expect(response.status).toBe(404) + expect(body.code).toBe(404) + expect(body.message).toBe('Unknown exchangeId.') + }) + }) + + describe('GET /healthz', function () { + test('returns 200 if healthy', async function () { + const response = await client.healthz.$get() + + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ + message: 'transaction-service server status: ok.', + healthy: true + }) + }) + + test('returns 503 if internal error', async function () { + // we delete the keyv store to force an error + const spy = vi + .spyOn(transactionManager, 'saveExchange') + .mockImplementation(async () => { + throw new HTTPException(500, { message: 'Failed to save exchange.' }) + }) + const response = await client.healthz.$get() + + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toHaveProperty('healthy', false) + vi.restoreAllMocks() + initializeTransactionManager() + }) + }) + + describe('POST /exchange - direct', function () { + beforeAll(() => { + // mock the signing service api call to return not much. + vi.spyOn(axios, 'post').mockImplementation(() => + Promise.resolve({ data: {} }) + ) // signing + }) + + const currentConfig = config.getConfig() + vi.spyOn(config, 'getConfig').mockImplementation(() => { + return { + ...currentConfig, + statusService: '' + } + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + test('does the direct exchange', async function () { + const { path, challenge } = await doSetupWithDirectDeepLink(app) + const didAuth = await getSignedDIDAuth(challenge) + const exchangeResponse = await app.request(path, { + method: 'POST', + body: JSON.stringify(didAuth), + headers: { + 'Content-Type': 'application/json' + } + }) + const body = await exchangeResponse.json() + expect(body).toBeDefined() + expect(body.redirectUrl).toBe('') + }) + + test('returns error for bad didAuth', async function () { + const { path } = await doSetupWithDirectDeepLink(app) + // use a different challenge than was issued + const didAuth = await getSignedDIDAuth('badChallenge') + const exchangeResponse = await app.request(path, { + method: 'POST', + body: JSON.stringify(didAuth), + headers: { + 'Content-Type': 'application/json' + } + }) + expect(exchangeResponse.headers.get('content-type')).toContain('json') + const body = await exchangeResponse.json() + expect(exchangeResponse.status).toBe(401) + expect(body).toBeDefined() + expect(body.code).toBe(401) + expect(body.message).toBe('Invalid DIDAuth.') + }) + + test('does the VPR exchange for DID Auth', async function () { + const walletQuery = await doSetup(app) + const url = walletQuery?.vprDeepLink ?? '' + + // Step 2. mimics what the wallet would do when opened by deeplink + // which is to parse the deeplink and call the exchange initiation endpoint + const parsedDeepLink = new URL(url) + const inititationURI = + parsedDeepLink.searchParams.get('vc_request_url') ?? '' + + // strip out the host because we are using supertest + const initiationURIPath = new URL(inititationURI).pathname + + const initiationResponse = await app.request(initiationURIPath, { + method: 'POST' // empty body to initiate a VC-API exchange + }) + expect(initiationResponse.headers.get('content-type')).toContain('json') + const vpr = (await initiationResponse.json()) as App.VPR + expect(initiationResponse.status).toBe(200) + + expect(vpr).toBeDefined() + + // Step 3. mimics what the wallet does once it's got the VPR + const challenge = vpr.challenge // the challenge that the exchange service generated + const continuationURI = + vpr.interact.service.find( + (service) => service.type === 'UnmediatedPresentationService2021' + )?.serviceEndpoint ?? '' + // strip out the host because we are using supertest + const continuationURIPath = new URL(continuationURI).pathname + const randomId = `did:ex:${crypto.randomUUID()}` + const didAuth = await getSignedDIDAuth(challenge, randomId) + + const continuationResponse = await app.request(continuationURIPath, { + method: 'POST', + body: JSON.stringify(didAuth), + headers: { + 'Content-Type': 'application/json' + } + }) + + // Verify the response structure + expect(continuationResponse.headers.get('Content-Type')).toContain('json') + expect(continuationResponse.status).toBe(200) + const body = await continuationResponse.json() + expect(body).toBeDefined() + + // First verify the basic structure + expect(body.redirectUrl).toBe('') + }) + + test('does the VPR exchange for credential issuance', async function () { + const walletQuery = await doSetup(app, 'claim') + const url = walletQuery?.vprDeepLink ?? '' + + // Step 2. mimics what the wallet would do when opened by deeplink + // which is to parse the deeplink and call the exchange initiation endpoint + const parsedDeepLink = new URL(url) + const inititationURI = + parsedDeepLink.searchParams.get('vc_request_url') ?? '' + + // strip out the host because we are using supertest + const initiationURIPath = new URL(inititationURI).pathname + + const initiationResponse = await app.request(initiationURIPath, { + method: 'POST' + }) + expect(initiationResponse.headers.get('content-type')).toContain('json') + const vpr = (await initiationResponse.json()) as App.VPR + expect(initiationResponse.status).toBe(200) + expect(vpr).toBeDefined() + + // Step 3. mimics what the wallet does once it's got the VPR + const challenge = vpr.challenge // the challenge that the exchange service generated + const continuationURI = + vpr.interact.service.find( + (service) => service.type === 'UnmediatedPresentationService2021' + )?.serviceEndpoint ?? '' + // strip out the host because we are using supertest + const continuationURIPath = new URL(continuationURI).pathname + const randomId = `did:ex:${crypto.randomUUID()}` + const didAuth = await getSignedDIDAuth(challenge, randomId) + + const continuationResponse = await app.request(continuationURIPath, { + method: 'POST', + body: JSON.stringify(didAuth), + headers: { + 'Content-Type': 'application/json' + } + }) + + // Verify the response structure + expect(continuationResponse.headers.get('Content-Type')).toContain('json') + const body = await continuationResponse.json() + expect(continuationResponse.status).toBe(200) + expect(body).toBeDefined() + + // First verify the basic structure + expect(body.redirectUrl).toBeUndefined() + expect(body.type).toBeDefined() // ["VerifiablePresentation"] + expect(body.verifiableCredential).toBeDefined() + expect(body.verifiableCredential.length).toBe(1) // It will just be the mocked {} + }) + }) +}) + +const doSetup = async (app: AppType, workflowId = 'didAuth') => { + const testData = getDataForExchangeSetupPost( + 'test', + 'http://localhost:4005', + workflowId + ) + const response = await app.request('/exchange', { + method: 'POST', + body: JSON.stringify(testData), + headers: { + 'Content-Type': 'application/json' + } + }) + + expect(response.headers.get('content-type')).toContain('json') + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toBeDefined() + expect(body.length).toBe(testData.data.length) + + const walletQuerys = body as App.DCCWalletQuery[] + const walletQuery = walletQuerys.find((q) => q.retrievalId === 'someId') + return walletQuery +} + +const doSetupWithDirectDeepLink = async (app: AppType) => { + const walletQuery = await doSetup(app) + const url = walletQuery?.directDeepLink ?? '' + + const parsedDeepLink = new URL(url) + const requestURI = parsedDeepLink.searchParams.get('vc_request_url') ?? '' //should be http://localhost:4004/exchange?challenge=VOclS8ZiMs&auth_type=bearer + // here we need to pull out just the path + // since we are calling the endpoint via + // supertest + const path = new URL(requestURI).pathname + const challenge = parsedDeepLink.searchParams.get('challenge') ?? '' // the challenge that the exchange service generated + return { path, challenge } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..391933b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,41 @@ +let CONFIG: App.Config + +const defaultPort = 4004 +const defaultExchangeHost = 'http://localhost:4004' +const defaultStatusService = 'http://localhost:4008' +const defaultSigningService = 'http://localhost:4006' +const defaultWorkflow = 'didAuth' +const defaultTenantName = 'test' +const defaultTtlSeconds = 60 * 10 // exchange expires after ten minutes + +const parseConfig = (): App.Config => { + return Object.freeze({ + port: parseInt(process.env.PORT ?? '0') || defaultPort, + exchangeHost: process.env.EXCHANGE_HOST ?? defaultExchangeHost, + exchangeTtl: parseInt(process.env.EXCHANGE_TTL ?? '0') || defaultTtlSeconds, + // status service is optional, set STATUS_SERVICE="" (empty string) to disable. + statusService: + process.env.STATUS_SERVICE !== undefined + ? process.env.STATUS_SERVICE + : defaultStatusService, + signingService: process.env.SIGNING_SERVICE ?? defaultSigningService, + defaultWorkflow: process.env.DEFAULT_WORKFLOW ?? defaultWorkflow, + defaultTenantName: process.env.DEFAULT_TENANT_NAME ?? defaultTenantName, + + // Keyv backend configuration + keyvFilePath: process.env.PERSIST_TO_FILE, + redisUri: process.env.REDIS_URI ?? undefined, + keyvWriteDelayMs: parseInt(process.env.KEYV_WRITE_DELAY ?? '0') || 100, // 100ms + keyvExpiredCheckDelayMs: + parseInt(process.env.KEYV_EXPIRED_CHECK_DELAY ?? '0') || 4 * 3600 * 1000 // 4 hours + }) +} + +export const getConfig = () => { + if (!CONFIG) { + CONFIG = parseConfig() + } + return CONFIG +} + +export const loadSecrets = async () => {} diff --git a/src/didAuth.js b/src/didAuth.js deleted file mode 100644 index 145d06f..0000000 --- a/src/didAuth.js +++ /dev/null @@ -1,38 +0,0 @@ - -import {verify,signPresentation, createPresentation} from '@digitalbazaar/vc'; - -import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; -import { securityLoader } from '@digitalcredentials/security-document-loader'; -import {Ed25519VerificationKey2020} from '@digitalbazaar/ed25519-verification-key-2020'; - -const documentLoader = securityLoader().build() - -const key = await Ed25519VerificationKey2020.generate( - { - seed: new Uint8Array ([ - 217, 87, 166, 30, 75, 106, 132, 55, - 32, 120, 171, 23, 116, 73, 254, 74, - 230, 16, 127, 91, 2, 252, 224, 96, - 184, 172, 245, 157, 58, 217, 91, 240 - ]), - controller: "did:key:z6MkvL5yVCgPhYvQwSoSRQou6k6ZGfD5mNM57HKxufEXwfnP" - } -) - - -const suite = new Ed25519Signature2020({key}); - -export const getSignedDIDAuth = async (holder = 'did:ex:12345', challenge) => { - const presentation = createPresentation({holder}); - return await signPresentation({ - presentation, suite, challenge, documentLoader - }); -} - -const verificationSuite = new Ed25519Signature2020(); - -export const verifyDIDAuth = async ({presentation, challenge}) => { - const result = await verify({presentation, challenge, suite: verificationSuite, documentLoader}); - return result.verified -} - diff --git a/src/didAuth.ts b/src/didAuth.ts new file mode 100644 index 0000000..2d1add7 --- /dev/null +++ b/src/didAuth.ts @@ -0,0 +1,64 @@ +// @ts-nocheck // There are no type definitions for these digitalbazaar libraries +import { verify, signPresentation, createPresentation } from '@digitalbazaar/vc' +import { Ed25519Signature2020 } from '@digitalbazaar/ed25519-signature-2020' +import { securityLoader } from '@digitalcredentials/security-document-loader' +import { Ed25519VerificationKey2020 } from '@digitalbazaar/ed25519-verification-key-2020' +import { DataIntegrityProof } from '@digitalbazaar/data-integrity' +import { cryptosuite as ecdsaRdfc2019Cryptosuite } from '@digitalbazaar/ecdsa-rdfc-2019-cryptosuite' +import { cryptosuite as eddsaRdfc2022Cryptosuite } from '@digitalbazaar/eddsa-rdfc-2022-cryptosuite' + +const documentLoader = securityLoader().build() + +let key: Ed25519VerificationKey2020 +let suite: Ed25519Signature2020 + +export const initializeKeyAndSuite = async () => { + // Test key and suite for health check and unit tests + key = await Ed25519VerificationKey2020.generate({ + seed: new Uint8Array([ + 217, 87, 166, 30, 75, 106, 132, 55, 32, 120, 171, 23, 116, 73, 254, 74, + 230, 16, 127, 91, 2, 252, 224, 96, 184, 172, 245, 157, 58, 217, 91, 240 + ]), + controller: 'did:key:z6MkvL5yVCgPhYvQwSoSRQou6k6ZGfD5mNM57HKxufEXwfnP' + }) + suite = new Ed25519Signature2020({ key }) +} + +// Helper funtion for health check and unit tests +export const getSignedDIDAuth = async ( + challenge: string, + customHolder: string | undefined = undefined +) => { + await initializeKeyAndSuite() + const holder = customHolder ?? key?.controller + const presentation = createPresentation({ holder }) + return await signPresentation({ + presentation, + suite, + challenge, + documentLoader + }) +} + +// TODO add ecdsa-rdfc-2019 support, and support Ed25519 via multikey +const verificationSuite = [ + new Ed25519Signature2020(), + new DataIntegrityProof({ cryptosuite: ecdsaRdfc2019Cryptosuite }), + new DataIntegrityProof({ cryptosuite: eddsaRdfc2022Cryptosuite }) +] + +export const verifyDIDAuth = async ({ + presentation, + challenge +}: { + presentation: unknown + challenge: string +}) => { + const result = await verify({ + presentation, + challenge, + suite: verificationSuite, + documentLoader + }) + return result.verified +} diff --git a/src/exchanges.ts b/src/exchanges.ts new file mode 100644 index 0000000..f344b9b --- /dev/null +++ b/src/exchanges.ts @@ -0,0 +1,216 @@ +import { + saveExchange, + getExchangeData, + getDIDAuthVPR +} from './transactionManager.js' +import axios from 'axios' +import type { Context } from 'hono' +// @ts-expect-error createPresentation is untyped +import { createPresentation } from '@digitalbazaar/vc' +import crypto from 'crypto' +import Handlebars from 'handlebars' +import { HTTPException } from 'hono/http-exception' +import * as https from 'https' +import * as schema from './schema.js' +import { verifyDIDAuth } from './didAuth.js' + +export const callService = async ( + endpoint: string, + body: Record +) => { + // We're calling VPC-internal services over HTTP only. + const agent = new https.Agent({ + rejectUnauthorized: false + }) + + const { data } = await axios.post(endpoint, body, { httpsAgent: agent }) + return data +} + +/** Allows the creation of one or a batch of exchanges for a particular tenant. */ +export const createExchangeBatch = async ({ + data, + config, + workflow +}: { + data: App.ExchangeBatch + config: App.Config + workflow: App.Workflow +}) => { + const exchangeRequests: App.ExchangeDetail[] = data.data.map((d) => { + return { + exchangeId: crypto.randomUUID(), + tenantName: data.tenantName, + expires: new Date(Date.now() + config.exchangeTtl * 1000).toISOString(), + batchId: data.batchId, + variables: { + challenge: crypto.randomUUID(), + exchangeHost: data.exchangeHost, + ...(d.vc && { vc: d.vc }), + ...(d.redirectUrl && { redirectUrl: d.redirectUrl }), + ...(d.subjectData && { subjectData: d.subjectData }), + ...(d.retrievalId && { retrievalId: d.retrievalId }), + ...(d.metadata && { metadata: d.metadata }) + }, + workflowId: workflow.id, + state: 'pending' + } + }) + + for (const ex of exchangeRequests) { + await saveExchange(ex) + } + const walletQueries = exchangeRequests.map((e) => { + const protocols = getProtocols(e) + return { + iu: protocols.iu, + retrievalId: e.variables.retrievalId, + directDeepLink: protocols.lcw ?? '', + vprDeepLink: protocols.lcw ?? '', + chapiVPR: protocols.verifiablePresentationRequest, + metadata: e.variables.metadata + } + }) + return walletQueries +} + +export const createExchangeVcapi = async ({ + data, + config, + workflow +}: { + data: App.ExchangeCreateInput + config: App.Config + workflow: App.Workflow +}) => { + const inputData = schema.vcApiExchangeCreateSchema.parse(data) + + const exchange: App.ExchangeDetail = { + ...inputData, + workflowId: workflow.id, + tenantName: data.variables.tenantName, + exchangeId: crypto.randomUUID(), + variables: { + ...inputData.variables, + challenge: crypto.randomUUID() + }, + expires: + inputData.expires ?? + new Date(Date.now() + config.exchangeTtl * 1000).toISOString(), + state: 'pending' + } + + await saveExchange(exchange) + return getProtocols(exchange) +} + +export const participateInExchange = async ({ + data, + config, + workflow, + exchange +}: { + data: any + config: App.Config + workflow: App.Workflow + exchange: App.ExchangeDetail +}) => { + if (!data || !Object.keys(data).length) { + // If there is no body, this is the initial step of the exchange. + // We will reply with a VPR to authenticate the wallet. + const vpr = await getDIDAuthVPR(exchange) + return vpr + } else { + // This is the second step of the exchange, we will verify the DIDAuth and return the + // previously stored data for the exchange. + const didAuthVerified = await verifyDIDAuth({ + presentation: data, + challenge: exchange.variables.challenge + }) + + if (!didAuthVerified) { + throw new HTTPException(401, { + message: 'Invalid DIDAuth.' + }) + } + + const credentialTemplate = workflow?.credentialTemplates?.[0] + if (!credentialTemplate || exchange.workflowId == 'didAuth') { + // TODO: this path won't be hit for now, but we eventually should support redirection to a + // url set in exchange variables at exchange creation time. + return { + redirectUrl: exchange.variables.redirectUrl ?? '' + } + } + + // The 'claim' workflow has a template that expects a `vc` variable of the built credential + // as a string. Future more complex workflows may have more complex templates. + let credential: App.Credential + try { + const builtCredential = await Handlebars.compile( + credentialTemplate.template + )(exchange.variables) + credential = JSON.parse(builtCredential) + console.log(credential) + credential.credentialSubject.id = data.holder + } catch (error) { + console.log(error) + throw new HTTPException(400, { + message: 'Failed to build credential from template' + }) + } + + // add credential status if enabled + if (config.statusService) { + credential = await callService( + `${config.statusService}/credentials/status/allocate`, + credential + ) + } + const signedCredential = await callService( + `${config.signingService}/instance/${exchange.tenantName}/credentials/sign`, + credential + ) + // generate VP to return VCs + const verifiablePresentation = createPresentation() + verifiablePresentation.verifiableCredential = [signedCredential] + + // VC-API indicates we would wrap this in a presentation, but wallet probably doesn't expect that yet. + return verifiablePresentation + } +} + +export const getProtocols = (exchange: App.ExchangeDetail) => { + const verifiablePresentationRequest = getDIDAuthVPR(exchange) + const serviceEndpoint = + verifiablePresentationRequest.interact.service[0].serviceEndpoint ?? '' + const protocols = { + iu: `${serviceEndpoint}/protocols?iuv=1`, + vcapi: serviceEndpoint, + // TODO issuer shouldn't be hardcoded. Where can we get the issuer DID value for the tenant? + // Wallet doesn't seem to reject this hardcoded issuer. + lcw: `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=${ + exchange.variables.challenge + }&vc_request_url=${encodeURIComponent(serviceEndpoint)}`, + verifiablePresentationRequest + // TODO: add "oid4vci" support (claim workflow) + // TODO: add "oid4vp" support for forthcoming verification workflows + } + return protocols +} + +export const getInteractionsForExchange = async (c: Context) => { + const exchangeData = await getExchangeData( + c.req.param('exchangeId'), + c.req.param('workflowId') + ) + if (!exchangeData) { + c.status(404) + return c.json({ + code: 404, + message: 'Exchange not found' + }) + } + const protocols = getProtocols(exchangeData) + return c.json({ protocols }) +} diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..a220eff --- /dev/null +++ b/src/health.ts @@ -0,0 +1,47 @@ +import { getConfig } from './config.js' +import { getExchangeData, saveExchange } from './transactionManager.js' +import type { Context } from 'hono' + +export const healthCheck = async (c: Context) => { + const config = getConfig() + try { + const timestamp = Date.now() + const success = await saveExchange({ + exchangeId: `healthz-${timestamp}`, + workflowId: 'healthz', + tenantName: 'healthz', + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now + state: 'pending' as const, + variables: { + exchangeHost: '', + challenge: '' + } + }) + if (!success) { + throw new Error('Failed to save exchange to Keyv') + } + + // Wait double the write delay to ensure the exchange is persisted + await new Promise((resolve) => + setTimeout(resolve, 2 * config.keyvWriteDelayMs) + ) + const result = await getExchangeData(`healthz-${timestamp}`, 'healthz') + if (!result) { + throw new Error('Failed to retrieve exchange from Keyv') + } + + // TODO: consider checking dependency services here + // But mock out in tests + } catch (e) { + console.log(`exception in healthz: ${JSON.stringify(e)}`) + c.status(503) + return c.json({ + error: `transaction-service healthz check failed with error: ${e}`, + healthy: false + }) + } + return c.json({ + message: 'transaction-service server status: ok.', + healthy: true + }) +} diff --git a/src/hono.ts b/src/hono.ts new file mode 100644 index 0000000..586a45d --- /dev/null +++ b/src/hono.ts @@ -0,0 +1,219 @@ +import { Hono, type Context } from 'hono' +import { logger } from 'hono/logger' +import { cors } from 'hono/cors' +import { createMiddleware } from 'hono/factory' +import { + createExchangeBatch, + createExchangeVcapi, + getInteractionsForExchange, + participateInExchange +} from './exchanges.js' +import { healthCheck } from './health.js' +import { HTTPException } from 'hono/http-exception' +import * as schema from './schema.js' +import { validator } from 'hono/validator' +import z from 'zod' +import { JSONObject } from 'hono/utils/types' +import { getWorkflow } from './workflows.js' +import { getConfig } from './config.js' +import { getExchangeData } from './transactionManager.js' + +/** + * Wraps a Hono handler with error handling + * @param {Function} viewHandler - The Hono handler to wrap + * @returns {Function} Hono middleware function + */ +const handleErrors = (err: unknown, c: Context) => { + if (err instanceof HTTPException) { + c.status(err.status) + return c.json({ + code: err.status, + message: err.message + }) + } else if (err instanceof z.ZodError) { + c.status(400) + return c.json({ + code: 400, + message: err.errors.map((e) => e.message).join(', '), + details: err.errors + }) + } else { + console.error('Unexpected error:', err) + c.status(500) + return c.json({ + code: 500, + message: 'An unexpected error occurred' + }) + } +} + +// Validation + +const validateJson = (value: JSONObject, c: Context) => { + // pass-through validator, will get failures if the JSON is invalid + return value +} + +const addWorkflowByParam = createMiddleware<{ + Variables: { + workflow: App.Workflow + } +}>(async (c, next) => { + const param = c.req.param('workflowId') + if (param) { + const workflow = getWorkflow(param) + if (!workflow) { + throw new HTTPException(404, { message: 'Workflow not found' }) + } + c.set('workflow', workflow) + } + await next() +}) + +// Middleware +const setConfigContext = createMiddleware<{ + Variables: { + config: App.Config + workflow?: App.Workflow + } +}>(async (c, next) => { + c.set('config', getConfig()) + await next() +}) + +/** A listing of all application routes */ +const routes = { + index: '/', + healthz: '/healthz', + exchangeBatchCreate: '/exchange', + legacyExchangeDetail: '/exchange/:exchangeId', // This might not be used anymore if it is not referenced by the exchange creation + exchangeCreate: '/workflows/:workflowId/exchanges', + exchangeDetail: '/workflows/:workflowId/exchanges/:exchangeId', + protocols: '/workflows/:workflowId/exchanges/:exchangeId/protocols' +} + +export const app = new Hono() + + .notFound((c) => { + return c.json({ code: 404, message: 'Not found' }, 404) + }) + .onError(handleErrors) + + .use(logger()) + .use(cors()) + .use(setConfigContext) + + // Config Handler adds config to the context + .use(async (c, next) => { + await next() + }) + + // Basic health check + .get(routes.index, async (c) => { + return c.json({ message: 'transaction-service server status: ok.' }) + }) + + // Extended health check + .get(routes.healthz, healthCheck) + + /* + This is step 1 in an exchange. Creates a new exchange and stores the provided data for later use + in the exchange, in particular the subject data with which to later construct the VC. Returns a + walletQuery object with both deeplinks with which to trigger wallet selection that in turn will + trigger the exchange when the wallet opens. + */ + + // DCC draft protocol for a batch of exchanges that returns wallet queries + .post( + routes.exchangeBatchCreate, + validator('json', validateJson), + async (c) => { + const body = c.req.valid('json') + const data = schema.exchangeBatchSchema.parse(body) + c.set('workflow', getWorkflow(data.workflowId ?? 'didAuth')) + return c.json( + await createExchangeBatch({ + data, + config: c.var.config, + workflow: c.var.workflow! + }) + ) + } + ) + + // VC-API 0.7 as of 2025-06-08 for a single exchange. + .post( + routes.exchangeCreate, + validator('json', validateJson), + addWorkflowByParam, + async (c) => { + const inputData = c.req.valid('json') + const data = schema.vcApiExchangeCreateSchema.parse(inputData) + return c.json( + await createExchangeVcapi({ + data, + config: c.var.config, + workflow: c.var.workflow + }) + ) + } + ) + + /* + This is step 2 in an exchange, where the wallet has asked to initiate the exchange, and we + reply here with a Verifiable Presentation Request, asking for a DIDAuth. Note that in some + scenarios the wallet may skip this step and directly present the DIDAuth. + + This also handles step 3 in the exchange, where the user presents their DIDAuth and receives + the result. + */ + // DCC draft protocol + .post( + routes.legacyExchangeDetail, + validator('json', validateJson), + async (c) => { + c.set('workflow', getWorkflow('didAuth')) + const exchange = await getExchangeData( + c.req.param('exchangeId')!, + c.var.workflow!.id + ) + return c.json( + await participateInExchange({ + data: null, + config: c.var.config, + workflow: c.var.workflow!, + exchange + }) + ) + } + ) + + // VC-API 0.7 as of 2025-06-08 + .post( + routes.exchangeDetail, + validator('json', validateJson), + addWorkflowByParam, + async (c) => { + const exchange = await getExchangeData( + c.req.param('exchangeId')!, + c.var.workflow.id + ) + return c.json( + await participateInExchange({ + data: c.req.valid('json'), + config: c.var.config, + workflow: c.var.workflow, + exchange + }) + ) + } + ) + + /* Cross-protocol interactions object. The URL for (the exchangeHost proxy for) this endpoint is + used in QR codes and deep links. It supplies information about the protocols that may be used to + interact with this exchange. Eventually we'll use this URL as QR code contents for wallet to scan. + VC-API 0.7 as of 2025-06-08: https://w3c-ccg.github.io/vc-api/#interaction-url-format + */ + .get(routes.protocols, getInteractionsForExchange) + +export type AppType = typeof app diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..876f185 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' + +export const credentialDataSchema = z + .object( + { + vc: z + .union([z.string(), z.object({})]) + .optional() + .transform((vcData, ctx) => { + if (typeof vcData !== 'string') { + // Sets template to be a string + try { + return JSON.stringify(vcData) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Invalid VC data - must be a string or valid JSON object' + }) + return z.NEVER + } + } + return vcData + }), + subjectData: z.any().optional(), + retrievalId: z.string({ + message: + "Incomplete exchange data - every submitted record must have it's own retrievalId." + }), + redirectUrl: z.string().optional(), + metadata: z.any().optional() + }, + { message: 'Invalid JSON: expected object' } + ) + .refine((data) => [data.vc, data.subjectData].some((d) => d !== undefined), { + message: + 'Incomplete exchange data - you must provide either a vc or subjectData' + }) + +const optionalFutureDate = (d: string | undefined) => { + if (!d) { + return true + } + try { + const date = new Date(d) + return date < new Date() + } catch (error) { + return false + } +} + +export const exchangeBatchSchema = z + .object( + { + exchangeHost: z.string({ + message: 'Incomplete exchange data - you must provide an exchangeHost' + }), + tenantName: z.string({ + message: 'Incomplete exchange data - you must provide a tenant name' + }), + batchId: z.string().optional(), + workflowId: z.enum(['didAuth', 'claim']).optional(), + data: z.array(credentialDataSchema), + expires: z + .string() + .datetime() + .refine(optionalFutureDate, { + message: + 'Invalid expires date. Must be ISO 8601 format datetime in the future.' + }) + .optional() + }, + { message: 'Invalid JSON: expected object' } + ) + .refine( + (d) => d.data.some((dd) => dd.subjectData !== undefined) == !!d.batchId, + { + message: + 'Incomplete exchange data - if you provide subjectData, you must also provide a batchId' + } + ) + +export const vcApiExchangeCreateSchema = z.object( + { + variables: z.object({ + exchangeHost: z + .string() + .optional() + .default(process.env.DEFAULT_EXCHANGE_HOST ?? 'http://localhost:4004'), + tenantName: z.string({ + message: + 'Incomplete exchange data - you must provide a tenant name variable' + }), + batchId: z.string().optional(), + vc: z.any() + }), + expires: z.string().datetime().optional().refine(optionalFutureDate, { + message: + 'Invalid expires date. Must be ISO 8601 format datetime in the future.' + }) + }, + { message: 'Invalid JSON: expected object' } +) + +export const workflowIdSchema = z.enum(['didAuth', 'claim'], { + message: 'Invalid workflowId. Must be either didAuth or claim.' +}) diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..0ae7745 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,18 @@ +import { app } from './hono.js' +import { getConfig } from './config.js' +import { serve } from '@hono/node-server' +import { initializeTransactionManager } from './transactionManager.js' + +const run = async () => { + const config = getConfig() + const port = config.port + await initializeTransactionManager() + + console.log(`Server running on port ${port}`) + serve({ + fetch: app.fetch, + port + }) +} + +run() diff --git a/src/test-fixtures/.env.testing b/src/test-fixtures/.env.testing index 3817e23..269f47c 100644 --- a/src/test-fixtures/.env.testing +++ b/src/test-fixtures/.env.testing @@ -1 +1 @@ -PERSIST_TO_FILE=/Users/jameschartrand/Documents/github/dcc/transaction-service/src/data/transactionsFile.json \ No newline at end of file +PERSIST_TO_FILE=./src/data/transactionsFile.json diff --git a/src/test-fixtures/testData.js b/src/test-fixtures/testData.ts similarity index 50% rename from src/test-fixtures/testData.js rename to src/test-fixtures/testData.ts index b9c67b0..a42ce1b 100644 --- a/src/test-fixtures/testData.js +++ b/src/test-fixtures/testData.ts @@ -1,15 +1,17 @@ import testVC from './testVC.js' const getDataForExchangeSetupPost = ( - tenantName, - exchangeHost = 'http://localhost:4005' + tenantName: string, + exchangeHost = 'http://localhost:4005', + workflowId = 'didAuth' ) => { const fakeData = { tenantName, + workflowId, exchangeHost, data: [ - { vc: testVC, retrievalId: 'someId' }, - { vc: testVC, retrievalId: 'blah' } + { vc: JSON.stringify(testVC), retrievalId: 'someId' }, + { vc: JSON.stringify(testVC), retrievalId: 'blah' } ] } return fakeData diff --git a/src/test-fixtures/testVC.js b/src/test-fixtures/testVC.js deleted file mode 100644 index ed5b09f..0000000 --- a/src/test-fixtures/testVC.js +++ /dev/null @@ -1,37 +0,0 @@ -export default { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://purl.imsglobal.org/spec/ob/v3p0/context.json", - "https://w3id.org/vc/status-list/2021/v1" - ], - "id": "urn:uuid:951b475e-b795-43bc-ba8f-a2d01efd2eb1", - "type": [ - "VerifiableCredential", - "OpenBadgeCredential" - ], - "issuer": { - "id": "did:key:z6MkhVTX9BF3NGYX6cc7jWpbNnR7cAjH8LUffabZP8Qu4ysC", - "type": "Profile", - "name": "University of Wonderful", - "description": "The most wonderful university", - "url": "https://wonderful.edu/", - "image": { - "id": "https://user-images.githubusercontent.com/947005/133544904-29d6139d-2e7b-4fe2-b6e9-7d1022bb6a45.png", - "type": "Image" - } - }, - "issuanceDate": "2020-01-01T00:00:00Z", - "name": "A Simply Wonderful Course", - "credentialSubject": { - "type": "AchievementSubject", - "achievement": { - "id": "http://wonderful.wonderful", - "type": "Achievement", - "criteria": { - "narrative": "Completion of the Wonderful Course - well done you!" - }, - "description": "Wonderful.", - "name": "Introduction to Wonderfullness" - } - } - } \ No newline at end of file diff --git a/src/test-fixtures/testVC.ts b/src/test-fixtures/testVC.ts new file mode 100644 index 0000000..2c24923 --- /dev/null +++ b/src/test-fixtures/testVC.ts @@ -0,0 +1,33 @@ +export default { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json' + ], + id: 'urn:uuid:951b475e-b795-43bc-ba8f-a2d01efd2eb1', + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: { + id: 'did:key:z6MkhVTX9BF3NGYX6cc7jWpbNnR7cAjH8LUffabZP8Qu4ysC', + type: 'Profile', + name: 'University of Wonderful', + description: 'The most wonderful university', + url: 'https://wonderful.edu/', + image: { + id: 'https://user-images.githubusercontent.com/947005/133544904-29d6139d-2e7b-4fe2-b6e9-7d1022bb6a45.png', + type: 'Image' + } + }, + issuanceDate: '2020-01-01T00:00:00Z', + name: 'A Simply Wonderful Course', + credentialSubject: { + type: 'AchievementSubject', + achievement: { + id: 'http://wonderful.wonderful', + type: 'Achievement', + criteria: { + narrative: 'Completion of the Wonderful Course - well done you!' + }, + description: 'Wonderful.', + name: 'Introduction to Wonderfullness' + } + } +} diff --git a/src/transactionManager.js b/src/transactionManager.js deleted file mode 100644 index 16af6c4..0000000 --- a/src/transactionManager.js +++ /dev/null @@ -1,242 +0,0 @@ -/*! - * Copyright (c) 2023 Digital Credentials Consortium. All rights reserved. - */ -import crypto from 'crypto' -import Keyv from 'keyv' -import { KeyvFile } from 'keyv-file' -import { verifyDIDAuth } from './didAuth.js' - -//const persistToFile = process.env.PERSIST_TO_FILE -const defaultTimeToLive = (process.env.DEFAULT_TTL = 1000 * 60 * 10) // keyv entry expires after ten minutes - -let keyv - -/** - * Intializes the keyv store either in-memory or in file system, according to env. - */ -export const initializeTransactionManager = () => { - if (!keyv) { - if (process.env.PERSIST_TO_FILE) { - keyv = new Keyv({ - store: new KeyvFile({ - filename: process.env.PERSIST_TO_FILE, // the file path to store the data - expiredCheckDelay: 4 * 3600 * 1000, // ms (so every 4 hours) how often to check for and remove expired records - writeDelay: 100, // ms, batch write to disk in a specific duration, enhance write performance. - encode: JSON.stringify, // serialize function - decode: JSON.parse // deserialize function - }) - }) - } else { - keyv = new Keyv() - } - } -} - -/** - * @param {Array} exchangeData Array of data items, one per credential, with data needed for the exchange - * @param {Object} [exchangeData.data[].vc] optional - an unsigned populated VC - * @param {Object} [exchangeData.data[].subjectData] optional - data to populate a VC - * @param {string} exchangeData.exchangeHost hostname for the exchange endpoints - * @param {string} exchangeData.tenantName tenant with which to sign - * @param {string} exchangeData.batchId batch to which cred belongs; also determines vc template - * @param {string} exchangeData.data[].retrievalId an identier for ech record, e.g., the recipient's email address - * @param {Object} exchangeData.data[].metadata anything else we want to store in the record for later use - * @returns {Object} deeplink/chapi queries with which to open a wallet for this exchange, as well as whatever is in metadata - */ -export const setupExchange = async (exchangeData) => { - verifyExchangeData(exchangeData) - // sets up an exchange ID in keyv for each record, and returns an array of objects - // where each object contains a choice of wallet queries for the exchange. - // A wallet query is either a deeplink, or a VPR for use with CHAPI. - // Each object also contains whatever 'metadata' had been supplied - const exchangeHost = exchangeData.exchangeHost - const tenantName = exchangeData.tenantName - const processRecord = bindProcessRecordFnToExchangeHostAndTenant( - exchangeHost, - tenantName - ) - return await Promise.all(exchangeData.data.map(processRecord)) -} - -/** - * This returns the vpr as described in the - * "Integrating with the VC-API Exchanges workflow" section of: - * https://chapi.io/developers/playgroundfaq/ - */ -export const getDIDAuthVPR = async (exchangeId) => { - const exchangeData = await getExchangeData(exchangeId) - return { - query: { - type: 'DIDAuthentication' - }, - interact: { - service: [ - { - type: 'VerifiableCredentialApiExchangeService', - serviceEndpoint: `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` - // "serviceEndpoint": "https://playground.chapi.io/exchanges/eyJjcmVkZW50aWFsIjoiaHR0cHM6Ly9wbGF5Z3JvdW5kLmNoYXBpLmlvL2V4YW1wbGVzL2pmZjIvamZmMi5qc29uIiwiaXNzdWVyIjoiZGIvdmMifQ/esOGVHG8d44Q" - }, - { - type: 'CredentialHandlerService' - } - ] - }, - challenge: exchangeData.transactionId, - domain: exchangeData.exchangeHost - } -} - -/** - * @param {string} exchangeHost - * @param {string} tenantName - * @returns a function for processing incoming records, bound to the specific exchangeHost and tenant - */ -const bindProcessRecordFnToExchangeHostAndTenant = ( - exchangeHost, - tenantName -) => { - return async (record) => { - record.tenantName = tenantName - record.exchangeHost = exchangeHost - record.transactionId = crypto.randomUUID() - record.exchangeId = crypto.randomUUID() - - const timeToLive = record.timeToLive || defaultTimeToLive - await keyv.set(record.exchangeId, record, timeToLive) - - // directDeepLink bypasses the VPR step and assumes the wallet knows to send a DIDAuth. - const directDeepLink = `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=${record.transactionId}&vc_request_url=${exchangeHost}/exchange/${record.exchangeId}/${record.transactionId}` - - //vprDeepLink = deeplink that calls /exchanges/${exchangeId} to initiate the exchange - // and get back a VPR to which to then send the DIDAuth. - const vprDeepLink = `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&vc_request_url=${exchangeHost}/exchange/${record.exchangeId}` - // - const chapiVPR = await getDIDAuthVPR(record.exchangeId) - const retrievalId = record.retrievalId - const metadata = record.metadata - - return { retrievalId, directDeepLink, vprDeepLink, chapiVPR, metadata } - } -} -/** - * - * This is the "old" version of the vpr, which might have been superseded by the above vpr, - * at least as described in the "Integrating with the VC-API Exchanges workflow" section of: - * https://chapi.io/developers/playgroundfaq/ - * @param {string} exchangeId - * @returns returns a verifiable presentation request for DIDAuth - */ -export const getVPR = async (exchangeId) => { - const exchangeData = await getExchangeData(exchangeId) - return { - verifiablePresentationRequest: { - query: [{ type: 'DIDAuthentication' }], - challenge: exchangeData.transactionId, - domain: exchangeData.exchangeHost, - interact: { - service: [ - { - type: 'UnmediatedPresentationService2021', - serviceEndpoint: `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` - } - ] - } - } - } -} - -/** - * @param {string} exchangeId - * @param {string} transactionId - * @param {Object} didAuthVP - * @throws {ExchangeError} Invalid DIDAuth - * @returns returns stored data but only if didAuth verifies and transactionId - * from request param matches stored transactionId - */ -export const retrieveStoredData = async ( - exchangeId, - transactionId, - didAuthVP -) => { - const storedData = await getExchangeData(exchangeId) - const didAuthVerified = await verifyDIDAuth({ - presentation: didAuthVP, - challenge: storedData.transactionId - }) - const transactionIdMatches = transactionId === storedData.transactionId - if (didAuthVerified && transactionIdMatches) { - return storedData - } else { - throw new ExchangeError(401, 'Invalid DIDAuth.') - } -} - -/** - * @param {string} exchangeId - * @throws {ExchangeError} Unknown exchangeID - * @returns returns stored data if exchangeId exists - */ -const getExchangeData = async (exchangeId) => { - const storedData = await keyv.get(exchangeId) - if (!storedData) throw new ExchangeError(404, 'Unknown exchangeId.') - return storedData -} - -/** - * This is meant for testing failures. It deletes the keyv store entirely. - */ -export const clearKeyv = () => { - keyv = null -} - -/** - * @param {Array} exchangeData Array of data items, one per credential, with data needed for the exchange - * @param {Object} [exchangeData.data[].vc] optional - an unsigned populated VC - * @param {Object} [exchangeData.data[].subjectData] optional - data to populate a VC - * @param {string} exchangeData.exchangeHost hostname for the exchange endpoints - * @param {string} exchangeData.tenantName tenant with which to sign - * @param {string} exchangeData.batchId batch to which cred belongs; also determines vc template - * @param {string} exchangeData.data[].retrievalId an identifer for ech record, e.g., the recipient's email address - * @param {Object} exchangeData.data[].metadata anything else we want to store in the record for later use - * @throws {ExchangError} Unknown exchangeID - */ -const verifyExchangeData = (exchangeData) => { - const batchId = exchangeData.batchId - if (!exchangeData.exchangeHost) { - throw new ExchangeError( - 400, - 'Incomplete exchange data - you must provide an exchangeHost' - ) - } - if (!exchangeData.tenantName) { - throw new ExchangeError( - 400, - 'Incomplete exchange data - you must provide a tenant name' - ) - } - exchangeData.data.forEach((credData) => { - if (!credData.vc && !credData.subjectData) { - throw new ExchangeError( - 400, - 'Incomplete exchange data - you must provide either a vc or subjectData' - ) - } - if (credData.subjectData && !batchId) { - throw new ExchangeError( - 400, - 'Incomplete exchange data - if you provide subjectData, you must also provide a batchId' - ) - } - if (!credData.retrievalId) { - throw new ExchangeError( - 400, - "Incomplete exchange data - every submitted record must have it's own retrievalId." - ) - } - }) -} - -function ExchangeError(code, message) { - this.code = code - this.message = message -} diff --git a/src/transactionManager.ts b/src/transactionManager.ts new file mode 100644 index 0000000..4c6e977 --- /dev/null +++ b/src/transactionManager.ts @@ -0,0 +1,110 @@ +/*! + * Copyright (c) 2023 Digital Credentials Consortium. All rights reserved. + */ +import { HTTPException } from 'hono/http-exception' +import Keyv from 'keyv' +import KeyvRedis from '@keyv/redis' +import { KeyvFile } from 'keyv-file' +import { getConfig } from './config.js' + +// The key value store used for transaction data. +let keyv: Keyv + +/** + * Intializes the keyv store either in-memory or in file system, according to env. + */ +export const initializeTransactionManager = () => { + const config = getConfig() + if (!keyv) { + if (config.keyvFilePath) { + keyv = new Keyv({ + store: new KeyvFile({ + filename: config.keyvFilePath, + expiredCheckDelay: config.keyvExpiredCheckDelayMs, // How often to check for and remove expired records + writeDelay: config.keyvWriteDelayMs, // ms, batch write to disk in a specific duration, enhance write performance. + serialize: JSON.stringify, // serialize function + deserialize: JSON.parse // deserialize function + }) + }) + } else if (config.redisUri) { + console.log("Using redis backend for Keyv: " + config.redisUri); + const hasPort = config.redisUri.includes("6379"); + keyv = new Keyv( + new KeyvRedis({ + url: hasPort ? config.redisUri : `rediss://${config.redisUri}:6379`, + socket: { tls: hasPort ? false : true } + }, { namespace: 'exchange' }) + ) + } else { + keyv = new Keyv() + } + } +} +initializeTransactionManager() // call immediately to ensure keyv is initialized + +/** + * @throws {} Unknown exchangeID + * @returns returns stored data if exchangeId exists + */ +export const getExchangeData = async ( + exchangeId: string, + workflowId: string +) => { + const storedData = await keyv.get(exchangeId) + if (!storedData || storedData.workflowId !== workflowId) { + throw new HTTPException(404, { message: 'Unknown exchangeId.' }) + } + return storedData +} + +/** + * Sets up one exchange and save it to Keyv. The local exchangeId is used as the key for the + * record. Success/Failure boolean is returned. + */ +export const saveExchange = async (data: App.ExchangeDetail) => { + const ttl = new Date(data.expires).getTime() - Date.now() + 1000 + const success = await keyv.set(data.exchangeId, data, ttl) + if (!success) { + throw new HTTPException(500, { message: 'Failed to save exchange.' }) + } + return success +} + +/** + * This returns the authentication vpr as described in + * https://w3c-ccg.github.io/vp-request-spec/#did-authentication + */ +export const getDIDAuthVPR = (exchange: App.ExchangeDetail) => { + const serviceEndpoint = `${exchange.variables.exchangeHost}/workflows/${exchange.workflowId}/exchanges/${exchange.exchangeId}` + + return { + query: { + type: 'DIDAuthentication' + }, + interact: { + service: [ + { + type: 'VerifiableCredentialApiExchangeService', + serviceEndpoint + }, + { + type: 'UnmediatedPresentationService2021', + serviceEndpoint + }, + { + type: 'CredentialHandlerService' + } + ] + }, + challenge: exchange.variables.challenge, + domain: exchange.variables.exchangeHost + } +} + +/** + * This is meant for testing failures. It deletes the keyv store entirely. + */ +export const clearKeyv = () => { + // @ts-ignore + keyv = undefined +} diff --git a/src/workflows.ts b/src/workflows.ts new file mode 100644 index 0000000..d75516c --- /dev/null +++ b/src/workflows.ts @@ -0,0 +1,49 @@ +const didAuthWorkflow: App.Workflow = { + id: 'didAuth', + steps: { + didAuth: { + createChallenge: true, + verifiablePresentationRequest: { + query: [{ type: 'DIDAuthentication' }] + } + } + }, + initialStep: 'didAuth' +} + +// A workflow that enables the claim of a VC whose template is defined at the exchange +// creation time. DID Auth is required, and status is used if the service is globally enabled. +const claimWorkflow: App.Workflow = { + id: 'claim', + steps: { + claim: { + createChallenge: true, + verifiablePresentationRequest: { + query: [{ type: 'DIDAuthentication' }] + } + } + }, + credentialTemplates: [ + { + id: 'generic', + type: 'handlebars', + // For this workflow, the VC is provide in the exchange creation variables as "vc". + template: '{{{vc}}}' // triple-stache to avoid html escaping quotation marks + } + ], + initialStep: 'claim' +} + +const workflows: Record = { + didAuth: didAuthWorkflow, + claim: claimWorkflow +} + +/** + * Gets a supported workflow by ID. + */ +export const getWorkflow = ( + workflowId: keyof typeof workflows +): App.Workflow => { + return workflows[workflowId] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..845eeca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c0b4124 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // ... + } +})