From b2e6b9aa737dfe52ba365e341c864eea5cbc1760 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 18 Feb 2025 15:10:57 -0800 Subject: [PATCH 01/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a85fcf6..2716be6 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,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 From eb1447b2d39ba67c07c0c6a26e0a652f08e77cd7 Mon Sep 17 00:00:00 2001 From: Nichols Date: Wed, 2 Apr 2025 17:00:12 -0700 Subject: [PATCH 02/20] add redis backend --- package.json | 1 + src/transactionManager.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/package.json b/package.json index 63fc7b5..a5aa90e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@digitalbazaar/ed25519-verification-key-2020": "^4.1.0", "@digitalbazaar/vc": "^7.0.0", "@digitalcredentials/security-document-loader": "^6.0.0", + "@keyv/redis": "^4.3.2", "axios": "^1.7.7", "cookie-parser": "~1.4.4", "cors": "^2.8.5", diff --git a/src/transactionManager.js b/src/transactionManager.js index 16af6c4..cbcc645 100644 --- a/src/transactionManager.js +++ b/src/transactionManager.js @@ -26,6 +26,8 @@ export const initializeTransactionManager = () => { decode: JSON.parse // deserialize function }) }) + } else if (process.env.REDIS_URI) { + keyv = new Keyv(process.env.REDIS_URI) } else { keyv = new Keyv() } From bd36aa777cbd26142ced8f066f5088b31ef19f41 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 11 Jun 2025 16:44:28 -0700 Subject: [PATCH 03/20] Draft: refactor workflows, use typescript, add status and sign natively --- .gitignore | 2 + .husky/pre-commit | 6 +- Dockerfile | 5 +- README.md | 259 +++++++++++------ nodemon.json | 5 + package.json | 44 ++- server.js | 15 - src/TransactionException.js | 6 - src/app.d.ts | 101 +++++++ src/app.js | 121 -------- src/app.test.js | 331 ---------------------- src/app.test.ts | 500 +++++++++++++++++++++++++++++++++ src/config.ts | 37 +++ src/didAuth.js | 38 --- src/didAuth.ts | 57 ++++ src/exchanges.ts | 289 +++++++++++++++++++ src/health.ts | 45 +++ src/hono.ts | 125 +++++++++ src/server.ts | 18 ++ src/test-fixtures/.env.testing | 2 +- src/test-fixtures/testData.js | 18 -- src/test-fixtures/testData.ts | 20 ++ src/test-fixtures/testVC.js | 37 --- src/test-fixtures/testVC.ts | 33 +++ src/transactionManager.js | 244 ---------------- src/transactionManager.ts | 111 ++++++++ src/workflows.ts | 47 ++++ tsconfig.json | 17 ++ vitest.config.ts | 7 + 29 files changed, 1614 insertions(+), 926 deletions(-) create mode 100644 nodemon.json delete mode 100644 server.js delete mode 100644 src/TransactionException.js create mode 100644 src/app.d.ts delete mode 100644 src/app.js delete mode 100644 src/app.test.js create mode 100644 src/app.test.ts create mode 100644 src/config.ts delete mode 100644 src/didAuth.js create mode 100644 src/didAuth.ts create mode 100644 src/exchanges.ts create mode 100644 src/health.ts create mode 100644 src/hono.ts create mode 100644 src/server.ts delete mode 100644 src/test-fixtures/testData.js create mode 100644 src/test-fixtures/testData.ts delete mode 100644 src/test-fixtures/testVC.js create mode 100644 src/test-fixtures/testVC.ts delete mode 100644 src/transactionManager.js create mode 100644 src/transactionManager.ts create mode 100644 src/workflows.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts 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/Dockerfile b/Dockerfile index 24dde4f..632b72d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,5 +2,6 @@ FROM node:18-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..6a66bbd 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 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 a5aa90e..8b492a8 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "@digitalcredentials/transaction-service", - "description": "An express app for managing challenges in a DIDAuth exchange.", + "description": "An express 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 ts-node src/server.ts", + "dev-noenv": "nodemon --exec ts-node src/server.ts", + "test": "dotenvx run -f src/test-fixtures/.env.testing -- vitest", + "coverage": "dotenvx run -f src/test-fixtures/.env.testing -- vitest run --coverage", + "prepare": "husky", "lint": "eslint", "lint-fix": "eslint --fix" }, @@ -18,33 +19,46 @@ "@digitalbazaar/ed25519-verification-key-2020": "^4.1.0", "@digitalbazaar/vc": "^7.0.0", "@digitalcredentials/security-document-loader": "^6.0.0", - "@keyv/redis": "^4.3.2", + "@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", + "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", + "ts-node": "^10.9.2", + "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..f288ee7 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,101 @@ +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 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 Exchange { + workflowId: string + exchangeId: string + challenge: string + tenantName: string + exchangeHost: string + ttl: number // Expressed in seconds + variables: Record & { + vc?: string + redirectUrl?: string + } + } + + 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..9a62d7c --- /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 crypto from 'crypto' +import { app, type AppType } from './hono' +import { + getDataForExchangeSetupPost, + testVC +} from './test-fixtures/testData.js' +import { getSignedDIDAuth } from './didAuth.js' +import { + saveExchange, + initializeTransactionManager, + ExchangeError +} from './transactionManager' +import * as transactionManager from './transactionManager' +import * as config from './config' +import axios from 'axios' + +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 client.exchange.$post({ body: null }) + expect(response.status).toBe(400) + expect(response.headers.get('content-type')).toContain('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') + expect(exchangeResponse.status).toBe(200) + const exchangeBody = await exchangeResponse.json() + 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') + // @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') + 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 ExchangeError(500, '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', + headers: { + 'Content-Type': 'application/json' + } + } + ) + 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', + headers: { + 'Content-Type': 'application/json' + } + } + ) + expect(response.headers.get('Content-Type')).toContain('json') + const body = await response.json() + expect(response.status).toBe(404) // not 400 for invalid body + 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 ExchangeError(500, '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', + headers: { + 'Content-Type': 'application/json' + } + }) + expect(initiationResponse.headers.get('content-type')).toContain('json') + expect(initiationResponse.status).toBe(200) + const vpr = (await initiationResponse.json()) as App.VPR + 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', + headers: { + 'Content-Type': 'application/json' + } + }) + expect(initiationResponse.headers.get('content-type')).toContain('json') + expect(initiationResponse.status).toBe(200) + const vpr = (await initiationResponse.json()) as App.VPR + 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.response.verifiablePresentation).toBeDefined() + expect( + body.response.verifiablePresentation.verifiableCredential + ).toBeDefined() + expect( + body.response.verifiablePresentation.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..82ba447 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,37 @@ +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 { + port: parseInt(process.env.PORT ?? '0') || defaultPort, + exchangeHost: process.env.EXCHANGE_HOST ?? defaultExchangeHost, + exchangeTtl: parseInt(process.env.EXCHANGE_TTL ?? '0') || defaultTtlSeconds, + statusService: 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..0db4a39 --- /dev/null +++ b/src/didAuth.ts @@ -0,0 +1,57 @@ +// @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' + +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() + +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..5c8b9ce --- /dev/null +++ b/src/exchanges.ts @@ -0,0 +1,289 @@ +import { + saveExchange, + getExchangeData, + getDIDAuthVPR, + ExchangeError +} from './transactionManager' +import axios from 'axios' +// @ts-expect-error createPresentation is untyped +import { createPresentation } from '@digitalbazaar/vc' +import crypto from 'crypto' +import { getConfig } from './config' +import { getWorkflow } from './workflows' +import * as Handlebars from 'handlebars' +import * as https from 'https' +import { verifyDIDAuth } from './didAuth' +import { z } from 'zod' +import type { Context } from 'hono' + +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 +} + +const validateWorkflow = (workflowId: string) => { + const workflow = getWorkflow(workflowId) + if (!workflow) { + throw new ExchangeError(404, 'Unknown workflow.') + } + return workflow +} + +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() + }) + .refine((data) => [data.vc, data.subjectData].some((d) => d !== undefined), { + message: + 'Incomplete exchange data - you must provide either a vc or subjectData' + }) + +const ExchangeDataSchema = 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) + }) + .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' + } + ) + +/** Allows the creation of one or a batch of exchanges for a particular tenant. */ +export const createExchangeBatch = async (c: Context) => { + const config = getConfig() + let requestData: App.ExchangeBatch + try { + const body = await c.req.json() + requestData = ExchangeDataSchema.parse(body) as App.ExchangeBatch + } catch (error) { + if (error instanceof z.ZodError) { + const i = error.issues[0] + throw new ExchangeError( + 400, + `${i.code} error at ${JSON.stringify(i.path ?? '')}: ${i.message}` + ) + } else if (error instanceof SyntaxError) { + throw new ExchangeError(400, 'Invalid JSON') + } + throw error + } + + const exchangeRequests: App.Exchange[] = requestData.data.map((d) => { + return { + exchangeId: crypto.randomUUID(), + challenge: crypto.randomUUID(), + exchangeHost: requestData.exchangeHost, + tenantName: requestData.tenantName, + ttl: config.exchangeTtl, + batchId: requestData.batchId, + variables: { + ...(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: requestData.workflowId ?? 'didAuth' + } + }) + + 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 c.json(walletQueries) +} + +const vcApiExchangeDataSchema = z.object({ + variables: z.object({ + exchangeHost: z + .string() + .optional() + .default(process.env.DEFAULT_EXCHANGE_HOST ?? 'http://localhost:4004'), + tenantName: z.string(), + batchId: z.string().optional(), + vc: z.any() + }) +}) + +export const createExchangeVcapi = async (c: Context) => { + // There is a legacy URL path that doesn't include the workflowId. + const workflowId = c.req.param('workflowId') ?? 'claim' + const workflow = validateWorkflow(workflowId) + + const data = await c.req.json() + if (!data || !Object.keys(data).length) { + c.status(400) + return c.json({ code: 400, message: 'No exchange creation data provided.' }) + } + await saveExchange(data) + const protocols = getProtocols(data) + return c.json(protocols) +} + +export const participateInExchange = async (c: Context) => { + const workflow = validateWorkflow(c.req.param('workflowId') ?? 'claim') + const exchange = await getExchangeData(c.req.param('exchangeId'), workflow.id) + + const config = getConfig() + let requestBody + try { + requestBody = await c.req.json() + } catch { + requestBody = null + } + + if (!requestBody || !Object.keys(requestBody).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 c.json(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 didAuth = requestBody + const didAuthVerified = await verifyDIDAuth({ + presentation: didAuth, + challenge: exchange.challenge + }) + + if (!didAuthVerified) { + c.status(401) + return c.json({ + code: 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 c.json({ + 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) + credential.credentialSubject.id = didAuth.holder + } catch (error) { + c.status(400) + return c.json({ + code: 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( + `http://${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 c.json({ + response: { verifiablePresentation }, + format: 'application/vc' + }) + } +} + +export const getProtocols = (exchange: App.Exchange) => { + const verifiablePresentationRequest = getDIDAuthVPR(exchange) + const serviceEndpoint = + verifiablePresentationRequest.interact.service[0].serviceEndpoint ?? '' + const protocols = { + iu: `${serviceEndpoint}/protocols?iuv=1`, + vcapi: serviceEndpoint, + lcw: `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=${ + exchange.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..4f6193a --- /dev/null +++ b/src/health.ts @@ -0,0 +1,45 @@ +import { getConfig } from './config' +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', + challenge: '', + tenantName: 'healthz', + exchangeHost: '', + ttl: 60 * 60, // Persist in Keyv for 1 hour + variables: {} + }) + 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..6fb6297 --- /dev/null +++ b/src/hono.ts @@ -0,0 +1,125 @@ +import { Hono, type Context } from 'hono' +import { logger } from 'hono/logger' +import { cors } from 'hono/cors' + +import { ExchangeError } from './transactionManager' +import { + createExchangeBatch, + createExchangeVcapi, + getInteractionsForExchange, + participateInExchange +} from './exchanges' +import { healthCheck } from './health' + +/** + * Wraps a Hono handler with error handling + * @param {Function} viewHandler - The Hono handler to wrap + * @returns {Function} Hono middleware function + */ +const handleErrors = (viewHandler: (c: Context) => Promise) => { + return async (c: Context) => { + try { + return await viewHandler(c) + } catch (error) { + if (error instanceof ExchangeError) { + c.status(error.code) + return c.json({ + code: error.code, + message: error.message + }) + } else { + console.error('Unexpected error:', error) + c.status(500) + return c.json({ + error: 'An unexpected error occurred' + }) + } + } + } +} + +export const app = new Hono() + + .notFound((c) => { + return c.json({ code: 404, message: 'Not found' }, 404) + }) + + .use(logger()) + .use(cors()) + + // Basic health check + .get( + '/', + handleErrors(async (c) => { + return c.json({ message: 'transaction-service server status: ok.' }) + }) + ) + + // Extended health check + .get( + '/healthz', + handleErrors(async (c) => { + return await healthCheck(c) + }) + ) + + /* + 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( + '/exchange', + handleErrors(async (c) => { + return await createExchangeBatch(c) + }) + ) + + // VC-API 0.7 as of 2025-06-08 for a single exchange. + .post( + '/workflows/:workflowId/exchanges', + handleErrors(async (c) => { + return await createExchangeVcapi(c) + }) + ) + + /* + 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( + '/exchange/:exchangeId', + handleErrors(async (c) => { + return await participateInExchange(c) + }) + ) + + // VC-API 0.7 as of 2025-06-08 + .post( + '/workflows/:workflowId/exchanges/:exchangeId', + handleErrors(async (c) => { + return await participateInExchange(c) + }) + ) + + /* 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( + '/workflows/:workflowId/exchanges/:exchangeId/protocols', + handleErrors(async (c) => { + return await getInteractionsForExchange(c) + }) + ) + +export type AppType = typeof app diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..576129c --- /dev/null +++ b/src/server.ts @@ -0,0 +1,18 @@ +import { app } from './hono' +import { getConfig } from './config' +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..9dbf7e1 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=/Users/notto/Projects/skybridgeskills/dcc/dcc-transaction-service/src/data/transactionsFile.json diff --git a/src/test-fixtures/testData.js b/src/test-fixtures/testData.js deleted file mode 100644 index b9c67b0..0000000 --- a/src/test-fixtures/testData.js +++ /dev/null @@ -1,18 +0,0 @@ -import testVC from './testVC.js' - -const getDataForExchangeSetupPost = ( - tenantName, - exchangeHost = 'http://localhost:4005' -) => { - const fakeData = { - tenantName, - exchangeHost, - data: [ - { vc: testVC, retrievalId: 'someId' }, - { vc: testVC, retrievalId: 'blah' } - ] - } - return fakeData -} - -export { getDataForExchangeSetupPost, testVC } diff --git a/src/test-fixtures/testData.ts b/src/test-fixtures/testData.ts new file mode 100644 index 0000000..f42aa8f --- /dev/null +++ b/src/test-fixtures/testData.ts @@ -0,0 +1,20 @@ +import testVC from './testVC.js' + +const getDataForExchangeSetupPost = ( + tenantName: string, + exchangeHost = 'http://localhost:4005', + workflowId = 'didAuth' +) => { + const fakeData: App.ExchangeBatch = { + tenantName, + workflowId, + exchangeHost, + data: [ + { vc: JSON.stringify(testVC), retrievalId: 'someId' }, + { vc: JSON.stringify(testVC), retrievalId: 'blah' } + ] + } + return fakeData +} + +export { getDataForExchangeSetupPost, testVC } 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 cbcc645..0000000 --- a/src/transactionManager.js +++ /dev/null @@ -1,244 +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 if (process.env.REDIS_URI) { - keyv = new Keyv(process.env.REDIS_URI) - } 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..375531e --- /dev/null +++ b/src/transactionManager.ts @@ -0,0 +1,111 @@ +/*! + * Copyright (c) 2023 Digital Credentials Consortium. All rights reserved. + */ +import Keyv from 'keyv' +import KeyvRedis from '@keyv/redis' +import { KeyvFile } from 'keyv-file' +import { getConfig } from './config' +import { StatusCode } from 'hono/utils/http-status' + +// 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) { + keyv = new Keyv(new KeyvRedis(config.redisUri)) + } else { + keyv = new Keyv() + } + } +} +initializeTransactionManager() // call immediately to ensure keyv is initialized + +/** + * @throws {App.ExchangeError} 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 ExchangeError(404, '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.Exchange) => { + const success = await keyv.set(data.exchangeId, data, data.ttl * 1000) + if (!success) { + throw new ExchangeError(500, '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.Exchange) => { + const serviceEndpoint = `${exchange.exchangeHost}/workflows/${exchange.workflowId}/exchanges/${exchange.exchangeId}` + + return { + query: { + type: 'DIDAuthentication' + }, + interact: { + service: [ + { + type: 'VerifiableCredentialApiExchangeService', + serviceEndpoint + }, + { + type: 'UnmediatedPresentationService2021', + serviceEndpoint + }, + { + type: 'CredentialHandlerService' + } + ] + }, + challenge: exchange.challenge, + domain: exchange.exchangeHost + } +} + +/** + * This is meant for testing failures. It deletes the keyv store entirely. + */ +export const clearKeyv = () => { + // @ts-ignore + keyv = undefined +} + +export class ExchangeError extends Error { + code: StatusCode + constructor(code: StatusCode, message: string) { + super(message) + this.code = code + this.name = 'ExchangeError' + } +} diff --git a/src/workflows.ts b/src/workflows.ts new file mode 100644 index 0000000..99108c2 --- /dev/null +++ b/src/workflows.ts @@ -0,0 +1,47 @@ +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: string): App.Workflow | undefined => { + return workflows[workflowId] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c980ca6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "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: { + // ... + } +}) From a4d59aaa89f5e65cefc21d73f89d0949293de67f Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 11 Jun 2025 17:51:06 -0700 Subject: [PATCH 04/20] use tsx for local dev instead of ts-node --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8b492a8..2d2a241 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@digitalcredentials/transaction-service", - "description": "An express app for managing DID Auth and VC-API exchanges.", + "description": "A hono app for managing DID Auth and VC-API exchanges.", "version": "0.3.0", "type": "module", "scripts": { "build": "tsc", "start": "node -r dotenv/config dist/server.js", - "dev": "nodemon -r dotenv/config --exec ts-node src/server.ts", + "dev": "nodemon -r dotenv/config --exec tsx src/server.ts", "dev-noenv": "nodemon --exec ts-node src/server.ts", "test": "dotenvx run -f src/test-fixtures/.env.testing -- vitest", "coverage": "dotenvx run -f src/test-fixtures/.env.testing -- vitest run --coverage", @@ -33,6 +33,7 @@ "keyv-file": "^5.1.2", "morgan": "~1.9.1", "nodemailer": "^6.9.14", + "tsx": "^4.20.1", "zod": "^3.25.56" }, "devDependencies": { From 4d48039b695d88ed2659da2f363980c753431cd7 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 11 Jun 2025 17:51:31 -0700 Subject: [PATCH 05/20] Remove ts-node, use tsx --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 2d2a241..eab3212 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsc", "start": "node -r dotenv/config dist/server.js", "dev": "nodemon -r dotenv/config --exec tsx src/server.ts", - "dev-noenv": "nodemon --exec ts-node src/server.ts", + "dev-noenv": "nodemon --exec tsx src/server.ts", "test": "dotenvx run -f src/test-fixtures/.env.testing -- vitest", "coverage": "dotenvx run -f src/test-fixtures/.env.testing -- vitest run --coverage", "prepare": "husky", @@ -57,7 +57,6 @@ "nodemon": "^2.0.21", "prettier": "3.2.5", "supertest": "^6.3.3", - "ts-node": "^10.9.2", "typescript": "^5.8.3", "vitest": "^3.2.3" }, From 8d816c6e6f31cfe1e9e6dfe9e21e2fa95d804e58 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Wed, 11 Jun 2025 18:14:55 -0700 Subject: [PATCH 06/20] Update docker and local node to 20 --- .nvmrc | 1 + Dockerfile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .nvmrc 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 632b72d..8b7b324 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine WORKDIR /app COPY . . RUN npm install From 70902ac6a210ea4cf7835a6459762dfe23e400f2 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Thu, 12 Jun 2025 11:10:04 -0700 Subject: [PATCH 07/20] namespace keyv values, collect routes for quick perusal --- src/hono.ts | 25 ++++++++++++++++++------- src/transactionManager.ts | 4 +++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/hono.ts b/src/hono.ts index 6fb6297..f0c6647 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -38,6 +38,17 @@ const handleErrors = (viewHandler: (c: Context) => Promise) => { } } +/** A listing of all application routes */ +const routes = { + index: '/', + healthz: '/healthz', + exchangeBatchCreate: '/exchange', + legacyExchangeDetail: '/exchange/:exchangeId', + exchangeCreate: '/workflows/:workflowId/exchanges', + exchangeDetail: '/workflows/:workflowId/exchanges/:exchangeId', + protocols: '/workflows/:workflowId/exchanges/:exchangeId/protocols' +} + export const app = new Hono() .notFound((c) => { @@ -49,7 +60,7 @@ export const app = new Hono() // Basic health check .get( - '/', + routes.index, handleErrors(async (c) => { return c.json({ message: 'transaction-service server status: ok.' }) }) @@ -57,7 +68,7 @@ export const app = new Hono() // Extended health check .get( - '/healthz', + routes.healthz, handleErrors(async (c) => { return await healthCheck(c) }) @@ -72,7 +83,7 @@ export const app = new Hono() // DCC draft protocol for a batch of exchanges that returns wallet queries .post( - '/exchange', + routes.exchangeBatchCreate, handleErrors(async (c) => { return await createExchangeBatch(c) }) @@ -80,7 +91,7 @@ export const app = new Hono() // VC-API 0.7 as of 2025-06-08 for a single exchange. .post( - '/workflows/:workflowId/exchanges', + routes.exchangeCreate, handleErrors(async (c) => { return await createExchangeVcapi(c) }) @@ -96,7 +107,7 @@ export const app = new Hono() */ // DCC draft protocol .post( - '/exchange/:exchangeId', + routes.legacyExchangeDetail, handleErrors(async (c) => { return await participateInExchange(c) }) @@ -104,7 +115,7 @@ export const app = new Hono() // VC-API 0.7 as of 2025-06-08 .post( - '/workflows/:workflowId/exchanges/:exchangeId', + routes.exchangeDetail, handleErrors(async (c) => { return await participateInExchange(c) }) @@ -116,7 +127,7 @@ export const app = new Hono() VC-API 0.7 as of 2025-06-08: https://w3c-ccg.github.io/vc-api/#interaction-url-format */ .get( - '/workflows/:workflowId/exchanges/:exchangeId/protocols', + routes.protocols, handleErrors(async (c) => { return await getInteractionsForExchange(c) }) diff --git a/src/transactionManager.ts b/src/transactionManager.ts index 375531e..4103250 100644 --- a/src/transactionManager.ts +++ b/src/transactionManager.ts @@ -27,7 +27,9 @@ export const initializeTransactionManager = () => { }) }) } else if (config.redisUri) { - keyv = new Keyv(new KeyvRedis(config.redisUri)) + keyv = new Keyv( + new KeyvRedis(config.redisUri, { namespace: 'exchange' }) + ) } else { keyv = new Keyv() } From 3c5f446bd719d61258aabf2b7060c61bfa184be4 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Thu, 12 Jun 2025 19:46:30 -0700 Subject: [PATCH 08/20] Improve type handling and separation of hono code from business logic --- package.json | 2 +- src/app.d.ts | 39 +++++- src/app.test.ts | 56 ++++----- src/exchanges.ts | 222 ++++++++++++---------------------- src/hono.ts | 185 ++++++++++++++++++++-------- src/schema.ts | 107 ++++++++++++++++ src/test-fixtures/testData.ts | 2 +- src/transactionManager.ts | 38 +++--- src/workflows.ts | 4 +- 9 files changed, 395 insertions(+), 260 deletions(-) create mode 100644 src/schema.ts diff --git a/package.json b/package.json index eab3212..70bf51b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "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", + "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", diff --git a/src/app.d.ts b/src/app.d.ts index f288ee7..c56dabf 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -14,6 +14,16 @@ declare global { keyvExpiredCheckDelayMs: number } + interface ErrorResponseBody { + code: number + message: string + details?: Array<{ + code: string + message: string + path: Array + }> + } + interface Credential extends Record { credentialSubject: Record & { id: string @@ -34,16 +44,33 @@ declare global { workflowId?: string } - interface Exchange { + 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 - challenge: string - tenantName: string - exchangeHost: string - ttl: number // Expressed in seconds - variables: Record & { + expires: string + state: 'pending' | 'active' | 'completed' | 'invalid' + variables: { vc?: string redirectUrl?: string + retrievalId?: string + exchangeHost: string + metadata?: Record + challenge: string // Used to authenticate presentations } } diff --git a/src/app.test.ts b/src/app.test.ts index 9a62d7c..099ff76 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,20 +1,18 @@ 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' -import { - getDataForExchangeSetupPost, - testVC -} from './test-fixtures/testData.js' +import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' import { getSignedDIDAuth } from './didAuth.js' import { saveExchange, - initializeTransactionManager, - ExchangeError + initializeTransactionManager } from './transactionManager' import * as transactionManager from './transactionManager' import * as config from './config' -import axios from 'axios' + +import { HTTPException } from 'hono/http-exception' describe('api', function () { const client = testClient(app) @@ -43,12 +41,20 @@ describe('api', function () { expect(response.headers.get('content-type')).toContain('json') }) + test('returns 400 if invalid JSON', async function () { + const response = await client.exchange.$post({ json: '{"invalid/json$' }) + expect(response.status).toBe(400) + const body = (await response.json()) as App.ErrorResponseBody + expect(response.headers.get('content-type')).toContain('json') + expect(body.code).toBe(400) + expect(body.message).toContain('Invalid 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) @@ -92,8 +98,8 @@ describe('api', function () { }) expect(exchangeResponse.headers.get('content-type')).toContain('json') - expect(exchangeResponse.status).toBe(200) 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) @@ -178,6 +184,7 @@ describe('api', function () { 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', @@ -201,7 +208,7 @@ describe('api', function () { // Mock saveExchange to throw an error vi.spyOn({ saveExchange }, 'saveExchange').mockImplementation( async () => { - throw new ExchangeError(500, 'Failed to save exchange.') + throw new HTTPException(500, { message: 'Failed to save exchange.' }) } ) }) @@ -228,10 +235,7 @@ describe('api', function () { const response = await app.request( '/workflows/NO-SUCH-WORKFLOW/exchanges/123', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } + method: 'POST' } ) expect(response.headers.get('Content-Type')).toContain('json') @@ -246,15 +250,12 @@ describe('api', function () { const response = await app.request( '/workflows/didAuth/exchanges/NO-SUCH-EXCHANGE', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } + method: 'POST' } ) expect(response.headers.get('Content-Type')).toContain('json') const body = await response.json() - expect(response.status).toBe(404) // not 400 for invalid body + expect(response.status).toBe(404) expect(body.code).toBe(404) expect(body.message).toBe('Unknown exchangeId.') }) @@ -278,7 +279,7 @@ describe('api', function () { const spy = vi .spyOn(transactionManager, 'saveExchange') .mockImplementation(async () => { - throw new ExchangeError(500, 'Failed to save exchange.') + throw new HTTPException(500, { message: 'Failed to save exchange.' }) }) const response = await client.healthz.$get() @@ -359,14 +360,12 @@ describe('api', function () { const initiationURIPath = new URL(inititationURI).pathname const initiationResponse = await app.request(initiationURIPath, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } + method: 'POST' // empty body to initiate a VC-API exchange }) expect(initiationResponse.headers.get('content-type')).toContain('json') - expect(initiationResponse.status).toBe(200) 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 @@ -412,14 +411,11 @@ describe('api', function () { const initiationURIPath = new URL(inititationURI).pathname const initiationResponse = await app.request(initiationURIPath, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } + method: 'POST' }) expect(initiationResponse.headers.get('content-type')).toContain('json') - expect(initiationResponse.status).toBe(200) 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 diff --git a/src/exchanges.ts b/src/exchanges.ts index 5c8b9ce..0771ddb 100644 --- a/src/exchanges.ts +++ b/src/exchanges.ts @@ -1,20 +1,18 @@ import { saveExchange, getExchangeData, - getDIDAuthVPR, - ExchangeError + getDIDAuthVPR } from './transactionManager' 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 { getConfig } from './config' -import { getWorkflow } from './workflows' import * as Handlebars from 'handlebars' +import { HTTPException } from 'hono/http-exception' import * as https from 'https' +import * as schema from './schema' import { verifyDIDAuth } from './didAuth' -import { z } from 'zod' -import type { Context } from 'hono' export const callService = async ( endpoint: string, @@ -29,102 +27,33 @@ export const callService = async ( return data } -const validateWorkflow = (workflowId: string) => { - const workflow = getWorkflow(workflowId) - if (!workflow) { - throw new ExchangeError(404, 'Unknown workflow.') - } - return workflow -} - -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() - }) - .refine((data) => [data.vc, data.subjectData].some((d) => d !== undefined), { - message: - 'Incomplete exchange data - you must provide either a vc or subjectData' - }) - -const ExchangeDataSchema = 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) - }) - .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' - } - ) - /** Allows the creation of one or a batch of exchanges for a particular tenant. */ -export const createExchangeBatch = async (c: Context) => { - const config = getConfig() - let requestData: App.ExchangeBatch - try { - const body = await c.req.json() - requestData = ExchangeDataSchema.parse(body) as App.ExchangeBatch - } catch (error) { - if (error instanceof z.ZodError) { - const i = error.issues[0] - throw new ExchangeError( - 400, - `${i.code} error at ${JSON.stringify(i.path ?? '')}: ${i.message}` - ) - } else if (error instanceof SyntaxError) { - throw new ExchangeError(400, 'Invalid JSON') - } - throw error - } - - const exchangeRequests: App.Exchange[] = requestData.data.map((d) => { +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(), - challenge: crypto.randomUUID(), - exchangeHost: requestData.exchangeHost, - tenantName: requestData.tenantName, - ttl: config.exchangeTtl, - batchId: requestData.batchId, + 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: requestData.workflowId ?? 'didAuth' + workflowId: workflow.id, + state: 'pending' } }) @@ -142,77 +71,76 @@ export const createExchangeBatch = async (c: Context) => { metadata: e.variables.metadata } }) - return c.json(walletQueries) + return walletQueries } -const vcApiExchangeDataSchema = z.object({ - variables: z.object({ - exchangeHost: z - .string() - .optional() - .default(process.env.DEFAULT_EXCHANGE_HOST ?? 'http://localhost:4004'), - tenantName: z.string(), - batchId: z.string().optional(), - vc: z.any() - }) -}) - -export const createExchangeVcapi = async (c: Context) => { - // There is a legacy URL path that doesn't include the workflowId. - const workflowId = c.req.param('workflowId') ?? 'claim' - const workflow = validateWorkflow(workflowId) - - const data = await c.req.json() - if (!data || !Object.keys(data).length) { - c.status(400) - return c.json({ code: 400, message: 'No exchange creation data provided.' }) +export const createExchangeVcapi = async ({ + data, + config, + workflow +}: { + data: App.ExchangeCreateInput + config: App.Config + workflow: App.Workflow +}) => { + const inputData = schema.vcApiExchangeCreateSchema.parse(data) + + const exchangeInput: 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(data) - const protocols = getProtocols(data) - return c.json(protocols) + return getProtocols(data) } -export const participateInExchange = async (c: Context) => { - const workflow = validateWorkflow(c.req.param('workflowId') ?? 'claim') - const exchange = await getExchangeData(c.req.param('exchangeId'), workflow.id) - - const config = getConfig() - let requestBody - try { - requestBody = await c.req.json() - } catch { - requestBody = null - } - - if (!requestBody || !Object.keys(requestBody).length) { +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 c.json(vpr) + 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 didAuth = requestBody const didAuthVerified = await verifyDIDAuth({ - presentation: didAuth, - challenge: exchange.challenge + presentation: data, + challenge: exchange.variables.challenge }) if (!didAuthVerified) { - c.status(401) - return c.json({ - code: 401, + throw new HTTPException(401, { message: 'Invalid DIDAuth.' }) } - const credentialTemplate = workflow.credentialTemplates?.[0] + 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 c.json({ + return { redirectUrl: exchange.variables.redirectUrl ?? '' - }) + } } // The 'claim' workflow has a template that expects a `vc` variable of the built credential @@ -223,11 +151,9 @@ export const participateInExchange = async (c: Context) => { credentialTemplate.template )(exchange.variables) credential = JSON.parse(builtCredential) - credential.credentialSubject.id = didAuth.holder + credential.credentialSubject.id = data.holder } catch (error) { - c.status(400) - return c.json({ - code: 400, + throw new HTTPException(400, { message: 'Failed to build credential from template' }) } @@ -248,22 +174,24 @@ export const participateInExchange = async (c: Context) => { verifiablePresentation.verifiableCredential = [signedCredential] // VC-API indicates we would wrap this in a presentation, but wallet probably doesn't expect that yet. - return c.json({ + return { response: { verifiablePresentation }, format: 'application/vc' - }) + } } } -export const getProtocols = (exchange: App.Exchange) => { +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.challenge + exchange.variables.challenge }&vc_request_url=${encodeURIComponent(serviceEndpoint)}`, verifiablePresentationRequest // TODO: add "oid4vci" support (claim workflow) diff --git a/src/hono.ts b/src/hono.ts index f0c6647..85847ba 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -1,8 +1,7 @@ import { Hono, type Context } from 'hono' import { logger } from 'hono/logger' import { cors } from 'hono/cors' - -import { ExchangeError } from './transactionManager' +import { createMiddleware } from 'hono/factory' import { createExchangeBatch, createExchangeVcapi, @@ -10,34 +9,78 @@ import { participateInExchange } from './exchanges' import { healthCheck } from './health' +import { HTTPException } from 'hono/http-exception' +import * as schema from './schema' +import { validator } from 'hono/validator' +import z from 'zod' +import { JSONObject } from 'hono/utils/types' +import { getWorkflow } from './workflows' +import { getConfig } from './config' +import { getExchangeData } from './transactionManager' /** * Wraps a Hono handler with error handling * @param {Function} viewHandler - The Hono handler to wrap * @returns {Function} Hono middleware function */ -const handleErrors = (viewHandler: (c: Context) => Promise) => { - return async (c: Context) => { - try { - return await viewHandler(c) - } catch (error) { - if (error instanceof ExchangeError) { - c.status(error.code) - return c.json({ - code: error.code, - message: error.message - }) - } else { - console.error('Unexpected error:', error) - c.status(500) - return c.json({ - error: 'An unexpected error occurred' - }) - } - } +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: '/', @@ -54,25 +97,24 @@ 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, - handleErrors(async (c) => { - return c.json({ message: 'transaction-service server status: ok.' }) - }) - ) + .get(routes.index, async (c) => { + return c.json({ message: 'transaction-service server status: ok.' }) + }) // Extended health check - .get( - routes.healthz, - handleErrors(async (c) => { - return await healthCheck(c) - }) - ) + .get(routes.healthz, healthCheck) /* This is step 1 in an exchange. Creates a new exchange and stores the provided data for later use @@ -84,17 +126,37 @@ export const app = new Hono() // DCC draft protocol for a batch of exchanges that returns wallet queries .post( routes.exchangeBatchCreate, - handleErrors(async (c) => { - return await createExchangeBatch(c) - }) + 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, - handleErrors(async (c) => { - return await createExchangeVcapi(c) - }) + 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 + }) + ) + } ) /* @@ -108,17 +170,43 @@ export const app = new Hono() // DCC draft protocol .post( routes.legacyExchangeDetail, - handleErrors(async (c) => { - return await participateInExchange(c) - }) + 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, - handleErrors(async (c) => { - return await participateInExchange(c) - }) + 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 @@ -126,11 +214,6 @@ export const app = new Hono() 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, - handleErrors(async (c) => { - return await getInteractionsForExchange(c) - }) - ) + .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/test-fixtures/testData.ts b/src/test-fixtures/testData.ts index f42aa8f..a42ce1b 100644 --- a/src/test-fixtures/testData.ts +++ b/src/test-fixtures/testData.ts @@ -5,7 +5,7 @@ const getDataForExchangeSetupPost = ( exchangeHost = 'http://localhost:4005', workflowId = 'didAuth' ) => { - const fakeData: App.ExchangeBatch = { + const fakeData = { tenantName, workflowId, exchangeHost, diff --git a/src/transactionManager.ts b/src/transactionManager.ts index 4103250..d181340 100644 --- a/src/transactionManager.ts +++ b/src/transactionManager.ts @@ -1,14 +1,14 @@ /*! * 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' -import { StatusCode } from 'hono/utils/http-status' // The key value store used for transaction data. -let keyv: Keyv +let keyv: Keyv /** * Intializes the keyv store either in-memory or in file system, according to env. @@ -17,7 +17,7 @@ export const initializeTransactionManager = () => { const config = getConfig() if (!keyv) { if (config.keyvFilePath) { - keyv = new Keyv({ + keyv = new Keyv({ store: new KeyvFile({ filename: config.keyvFilePath, expiredCheckDelay: config.keyvExpiredCheckDelayMs, // How often to check for and remove expired records @@ -27,18 +27,18 @@ export const initializeTransactionManager = () => { }) }) } else if (config.redisUri) { - keyv = new Keyv( + keyv = new Keyv( new KeyvRedis(config.redisUri, { namespace: 'exchange' }) ) } else { - keyv = new Keyv() + keyv = new Keyv() } } } initializeTransactionManager() // call immediately to ensure keyv is initialized /** - * @throws {App.ExchangeError} Unknown exchangeID + * @throws {} Unknown exchangeID * @returns returns stored data if exchangeId exists */ export const getExchangeData = async ( @@ -47,7 +47,7 @@ export const getExchangeData = async ( ) => { const storedData = await keyv.get(exchangeId) if (!storedData || storedData.workflowId !== workflowId) { - throw new ExchangeError(404, 'Unknown exchangeId.') + throw new HTTPException(404, { message: 'Unknown exchangeId.' }) } return storedData } @@ -56,10 +56,11 @@ export const getExchangeData = async ( * 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.Exchange) => { - const success = await keyv.set(data.exchangeId, data, data.ttl * 1000) +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 ExchangeError(500, 'Failed to save exchange.') + throw new HTTPException(500, { message: 'Failed to save exchange.' }) } return success } @@ -68,8 +69,8 @@ export const saveExchange = async (data: App.Exchange) => { * This returns the authentication vpr as described in * https://w3c-ccg.github.io/vp-request-spec/#did-authentication */ -export const getDIDAuthVPR = (exchange: App.Exchange) => { - const serviceEndpoint = `${exchange.exchangeHost}/workflows/${exchange.workflowId}/exchanges/${exchange.exchangeId}` +export const getDIDAuthVPR = (exchange: App.ExchangeDetail) => { + const serviceEndpoint = `${exchange.variables.exchangeHost}/workflows/${exchange.workflowId}/exchanges/${exchange.exchangeId}` return { query: { @@ -90,8 +91,8 @@ export const getDIDAuthVPR = (exchange: App.Exchange) => { } ] }, - challenge: exchange.challenge, - domain: exchange.exchangeHost + challenge: exchange.variables.challenge, + domain: exchange.variables.exchangeHost } } @@ -102,12 +103,3 @@ export const clearKeyv = () => { // @ts-ignore keyv = undefined } - -export class ExchangeError extends Error { - code: StatusCode - constructor(code: StatusCode, message: string) { - super(message) - this.code = code - this.name = 'ExchangeError' - } -} diff --git a/src/workflows.ts b/src/workflows.ts index 99108c2..d75516c 100644 --- a/src/workflows.ts +++ b/src/workflows.ts @@ -42,6 +42,8 @@ const workflows: Record = { /** * Gets a supported workflow by ID. */ -export const getWorkflow = (workflowId: string): App.Workflow | undefined => { +export const getWorkflow = ( + workflowId: keyof typeof workflows +): App.Workflow => { return workflows[workflowId] } From fc4fc8168494931b9bc5e0b551a9f2b7fbdeedf0 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Thu, 12 Jun 2025 19:52:49 -0700 Subject: [PATCH 09/20] Improve malformed body tests --- src/app.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app.test.ts b/src/app.test.ts index 099ff76..43150eb 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -36,18 +36,26 @@ describe('api', function () { describe('POST /exchange', function () { test('returns 400 if no body', async function () { - const response = await client.exchange.$post({ body: null }) + 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 client.exchange.$post({ json: '{"invalid/json$' }) + 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 App.ErrorResponseBody + 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('Invalid JSON') + expect(body.message).toContain('Malformed JSON') }) test('returns array of wallet queries', async function () { @@ -162,7 +170,7 @@ describe('api', function () { }) test('returns error if missing batchId with subjectData', async function () { - const testData = getDataForExchangeSetupPost('test') + const testData = getDataForExchangeSetupPost('test') as App.ExchangeBatch // @ts-ignore delete testData.data[0].vc testData.data[0].subjectData = { hello: 'trouble' } From 8f408b9a167fa0892ca283b40a6586c451f5712f Mon Sep 17 00:00:00 2001 From: Nichols Date: Thu, 12 Jun 2025 20:37:00 -0700 Subject: [PATCH 10/20] use relative path, fixes test failures --- src/test-fixtures/.env.testing | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test-fixtures/.env.testing b/src/test-fixtures/.env.testing index 9dbf7e1..269f47c 100644 --- a/src/test-fixtures/.env.testing +++ b/src/test-fixtures/.env.testing @@ -1 +1 @@ -PERSIST_TO_FILE=/Users/notto/Projects/skybridgeskills/dcc/dcc-transaction-service/src/data/transactionsFile.json +PERSIST_TO_FILE=./src/data/transactionsFile.json From ef52ad2bcb07e0e3800ee81682e4607534ac8b5c Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Fri, 13 Jun 2025 10:36:00 -0700 Subject: [PATCH 11/20] Fix wallet exchanges and optional status service dependency --- src/app.test.ts | 10 +++------- src/config.ts | 10 +++++++--- src/exchanges.ts | 15 ++++++--------- src/hono.ts | 2 +- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/app.test.ts b/src/app.test.ts index 43150eb..cf6cc9c 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -453,13 +453,9 @@ describe('api', function () { // First verify the basic structure expect(body.redirectUrl).toBeUndefined() - expect(body.response.verifiablePresentation).toBeDefined() - expect( - body.response.verifiablePresentation.verifiableCredential - ).toBeDefined() - expect( - body.response.verifiablePresentation.verifiableCredential.length - ).toBe(1) // It will just be the mocked {} + expect(body.type).toBeDefined() // ["VerifiablePresentation"] + expect(body.verifiableCredential).toBeDefined() + expect(body.verifiableCredential.length).toBe(1) // It will just be the mocked {} }) }) }) diff --git a/src/config.ts b/src/config.ts index 82ba447..391933b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,11 +9,15 @@ const defaultTenantName = 'test' const defaultTtlSeconds = 60 * 10 // exchange expires after ten minutes const parseConfig = (): App.Config => { - return { + return Object.freeze({ port: parseInt(process.env.PORT ?? '0') || defaultPort, exchangeHost: process.env.EXCHANGE_HOST ?? defaultExchangeHost, exchangeTtl: parseInt(process.env.EXCHANGE_TTL ?? '0') || defaultTtlSeconds, - statusService: process.env.STATUS_SERVICE ?? defaultStatusService, + // 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, @@ -24,7 +28,7 @@ const parseConfig = (): App.Config => { 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 = () => { diff --git a/src/exchanges.ts b/src/exchanges.ts index 0771ddb..d5e72e2 100644 --- a/src/exchanges.ts +++ b/src/exchanges.ts @@ -8,7 +8,7 @@ import type { Context } from 'hono' // @ts-expect-error createPresentation is untyped import { createPresentation } from '@digitalbazaar/vc' import crypto from 'crypto' -import * as Handlebars from 'handlebars' +import Handlebars from 'handlebars' import { HTTPException } from 'hono/http-exception' import * as https from 'https' import * as schema from './schema' @@ -85,7 +85,7 @@ export const createExchangeVcapi = async ({ }) => { const inputData = schema.vcApiExchangeCreateSchema.parse(data) - const exchangeInput: App.ExchangeDetail = { + const exchange: App.ExchangeDetail = { ...inputData, workflowId: workflow.id, tenantName: data.variables.tenantName, @@ -100,8 +100,8 @@ export const createExchangeVcapi = async ({ state: 'pending' } - await saveExchange(data) - return getProtocols(data) + await saveExchange(exchange) + return getProtocols(exchange) } export const participateInExchange = async ({ @@ -166,7 +166,7 @@ export const participateInExchange = async ({ ) } const signedCredential = await callService( - `http://${config.signingService}/instance/${exchange.tenantName}/credentials/sign`, + `${config.signingService}/instance/${exchange.tenantName}/credentials/sign`, credential ) // generate VP to return VCs @@ -174,10 +174,7 @@ export const participateInExchange = async ({ verifiablePresentation.verifiableCredential = [signedCredential] // VC-API indicates we would wrap this in a presentation, but wallet probably doesn't expect that yet. - return { - response: { verifiablePresentation }, - format: 'application/vc' - } + return verifiablePresentation } } diff --git a/src/hono.ts b/src/hono.ts index 85847ba..0ed0511 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -86,7 +86,7 @@ const routes = { index: '/', healthz: '/healthz', exchangeBatchCreate: '/exchange', - legacyExchangeDetail: '/exchange/:exchangeId', + 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' From e11b08fe8cd9a977e6d2562ad700be528b25a6f7 Mon Sep 17 00:00:00 2001 From: Nichols Date: Mon, 16 Jun 2025 13:35:28 -0700 Subject: [PATCH 12/20] sanity log check --- src/transactionManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/transactionManager.ts b/src/transactionManager.ts index d181340..84ec7cc 100644 --- a/src/transactionManager.ts +++ b/src/transactionManager.ts @@ -27,6 +27,7 @@ export const initializeTransactionManager = () => { }) }) } else if (config.redisUri) { + console.log("Using redis backend for Keyv"); keyv = new Keyv( new KeyvRedis(config.redisUri, { namespace: 'exchange' }) ) From b6fad6d01a10ca45144cbc5962c59086fef3cc60 Mon Sep 17 00:00:00 2001 From: Nichols Date: Mon, 16 Jun 2025 13:43:50 -0700 Subject: [PATCH 13/20] fix health check type issue --- src/health.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/health.ts b/src/health.ts index 4f6193a..ba91ad5 100644 --- a/src/health.ts +++ b/src/health.ts @@ -9,11 +9,13 @@ export const healthCheck = async (c: Context) => { const success = await saveExchange({ exchangeId: `healthz-${timestamp}`, workflowId: 'healthz', - challenge: '', tenantName: 'healthz', - exchangeHost: '', - ttl: 60 * 60, // Persist in Keyv for 1 hour - variables: {} + 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') From 80c3826cea896682da4681855a96ba1dfc6a2e96 Mon Sep 17 00:00:00 2001 From: Nichols Date: Mon, 16 Jun 2025 15:36:35 -0700 Subject: [PATCH 14/20] docker ci --- .github/workflows/main.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e54a80..c418b85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,4 +22,24 @@ 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: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: skybridgeskills/dcc-transaction-service:${{ github.sha }} From 4d50dcc9f011b1850da2c27eeffaa4568a1f1b66 Mon Sep 17 00:00:00 2001 From: Nichols Date: Mon, 16 Jun 2025 15:38:57 -0700 Subject: [PATCH 15/20] dockerhub pat --- .github/workflows/main.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c418b85..9f003c6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,12 +34,11 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + 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 }} - From 59783860eb0c955136051e91fc27d71d37cce157 Mon Sep 17 00:00:00 2001 From: Nichols Date: Tue, 17 Jun 2025 12:55:22 -0700 Subject: [PATCH 16/20] add .js to imports missing module in dist --- src/app.test.ts | 8 ++++---- src/exchanges.ts | 6 +++--- src/health.ts | 2 +- src/hono.ts | 12 ++++++------ src/server.ts | 4 ++-- src/transactionManager.ts | 2 +- tsconfig.json | 1 + 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app.test.ts b/src/app.test.ts index cf6cc9c..3c47cc8 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -2,15 +2,15 @@ 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' +import { app, type AppType } from './hono.js' import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' import { getSignedDIDAuth } from './didAuth.js' import { saveExchange, initializeTransactionManager -} from './transactionManager' -import * as transactionManager from './transactionManager' -import * as config from './config' +} from './transactionManager.js' +import * as transactionManager from './transactionManager.js' +import * as config from './config.js' import { HTTPException } from 'hono/http-exception' diff --git a/src/exchanges.ts b/src/exchanges.ts index d5e72e2..5c2eac6 100644 --- a/src/exchanges.ts +++ b/src/exchanges.ts @@ -2,7 +2,7 @@ import { saveExchange, getExchangeData, getDIDAuthVPR -} from './transactionManager' +} from './transactionManager.js' import axios from 'axios' import type { Context } from 'hono' // @ts-expect-error createPresentation is untyped @@ -11,8 +11,8 @@ import crypto from 'crypto' import Handlebars from 'handlebars' import { HTTPException } from 'hono/http-exception' import * as https from 'https' -import * as schema from './schema' -import { verifyDIDAuth } from './didAuth' +import * as schema from './schema.js' +import { verifyDIDAuth } from './didAuth.js' export const callService = async ( endpoint: string, diff --git a/src/health.ts b/src/health.ts index ba91ad5..a220eff 100644 --- a/src/health.ts +++ b/src/health.ts @@ -1,4 +1,4 @@ -import { getConfig } from './config' +import { getConfig } from './config.js' import { getExchangeData, saveExchange } from './transactionManager.js' import type { Context } from 'hono' diff --git a/src/hono.ts b/src/hono.ts index 0ed0511..586a45d 100644 --- a/src/hono.ts +++ b/src/hono.ts @@ -7,16 +7,16 @@ import { createExchangeVcapi, getInteractionsForExchange, participateInExchange -} from './exchanges' -import { healthCheck } from './health' +} from './exchanges.js' +import { healthCheck } from './health.js' import { HTTPException } from 'hono/http-exception' -import * as schema from './schema' +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' -import { getConfig } from './config' -import { getExchangeData } from './transactionManager' +import { getWorkflow } from './workflows.js' +import { getConfig } from './config.js' +import { getExchangeData } from './transactionManager.js' /** * Wraps a Hono handler with error handling diff --git a/src/server.ts b/src/server.ts index 576129c..0ae7745 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ -import { app } from './hono' -import { getConfig } from './config' +import { app } from './hono.js' +import { getConfig } from './config.js' import { serve } from '@hono/node-server' import { initializeTransactionManager } from './transactionManager.js' diff --git a/src/transactionManager.ts b/src/transactionManager.ts index 84ec7cc..27f85ea 100644 --- a/src/transactionManager.ts +++ b/src/transactionManager.ts @@ -5,7 +5,7 @@ import { HTTPException } from 'hono/http-exception' import Keyv from 'keyv' import KeyvRedis from '@keyv/redis' import { KeyvFile } from 'keyv-file' -import { getConfig } from './config' +import { getConfig } from './config.js' // The key value store used for transaction data. let keyv: Keyv diff --git a/tsconfig.json b/tsconfig.json index c980ca6..845eeca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "module": "ESNext", "moduleResolution": "bundler", "allowImportingTsExtensions": false, "resolveJsonModule": true From 796c5ab3f11e997a2a9d4177fa9143a9b03e60e6 Mon Sep 17 00:00:00 2001 From: Nichols Date: Tue, 17 Jun 2025 17:42:28 -0700 Subject: [PATCH 17/20] fix redis uri --- src/transactionManager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/transactionManager.ts b/src/transactionManager.ts index 27f85ea..4c6e977 100644 --- a/src/transactionManager.ts +++ b/src/transactionManager.ts @@ -27,9 +27,13 @@ export const initializeTransactionManager = () => { }) }) } else if (config.redisUri) { - console.log("Using redis backend for Keyv"); + console.log("Using redis backend for Keyv: " + config.redisUri); + const hasPort = config.redisUri.includes("6379"); keyv = new Keyv( - new KeyvRedis(config.redisUri, { namespace: 'exchange' }) + new KeyvRedis({ + url: hasPort ? config.redisUri : `rediss://${config.redisUri}:6379`, + socket: { tls: hasPort ? false : true } + }, { namespace: 'exchange' }) ) } else { keyv = new Keyv() From 4ab69b431f36f23ac54c29661981b69d696aac95 Mon Sep 17 00:00:00 2001 From: Nichols Date: Wed, 18 Jun 2025 12:25:11 -0700 Subject: [PATCH 18/20] log exchange error --- src/exchanges.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/exchanges.ts b/src/exchanges.ts index 5c2eac6..893d634 100644 --- a/src/exchanges.ts +++ b/src/exchanges.ts @@ -153,6 +153,7 @@ export const participateInExchange = async ({ credential = JSON.parse(builtCredential) credential.credentialSubject.id = data.holder } catch (error) { + console.log(error) throw new HTTPException(400, { message: 'Failed to build credential from template' }) From b78f816554e4ae661106cad8fc9be432091ca0c3 Mon Sep 17 00:00:00 2001 From: Nichols Date: Wed, 18 Jun 2025 14:02:54 -0700 Subject: [PATCH 19/20] log credential --- src/exchanges.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/exchanges.ts b/src/exchanges.ts index 893d634..f344b9b 100644 --- a/src/exchanges.ts +++ b/src/exchanges.ts @@ -151,6 +151,7 @@ export const participateInExchange = async ({ credentialTemplate.template )(exchange.variables) credential = JSON.parse(builtCredential) + console.log(credential) credential.credentialSubject.id = data.holder } catch (error) { console.log(error) From 16243a1febd3fb062fbf5515d725a32627014096 Mon Sep 17 00:00:00 2001 From: Nate Otto Date: Thu, 3 Jul 2025 10:00:55 -0700 Subject: [PATCH 20/20] Add more cryptosuites to support DID Auth --- package.json | 4 ++++ src/didAuth.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 70bf51b..b39c819 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,12 @@ "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", diff --git a/src/didAuth.ts b/src/didAuth.ts index 0db4a39..2d1add7 100644 --- a/src/didAuth.ts +++ b/src/didAuth.ts @@ -3,6 +3,9 @@ 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() @@ -38,7 +41,11 @@ export const getSignedDIDAuth = async ( } // TODO add ecdsa-rdfc-2019 support, and support Ed25519 via multikey -const verificationSuite = new Ed25519Signature2020() +const verificationSuite = [ + new Ed25519Signature2020(), + new DataIntegrityProof({ cryptosuite: ecdsaRdfc2019Cryptosuite }), + new DataIntegrityProof({ cryptosuite: eddsaRdfc2022Cryptosuite }) +] export const verifyDIDAuth = async ({ presentation,