From aa2fb7354214a52ef385583ffea72683f0c510d5 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Sat, 27 Jun 2026 10:30:06 -0600 Subject: [PATCH] feat: add edge-managed W3C credential flow --- .env.example | 10 + package.json | 4 +- pnpm-lock.yaml | 193 +++++++++- pnpm-workspace.yaml | 1 - src/app/PayloadDetails.tsx | 36 +- src/app/W3CArtifactDetails.tsx | 317 ++++++++++++++++ src/app/credentialActionSubmit.ts | 25 ++ src/app/pendingState.ts | 6 +- src/app/routeData.credentials.ts | 107 +++++- src/app/routeData.dashboard.ts | 7 +- src/app/routeData.ts | 1 + src/app/routeData.types.ts | 25 +- src/app/runtimeCommands/credentials.ts | 91 +++++ src/app/runtimeCommands/payloadDetails.ts | 125 +++++++ src/config.ts | 68 ++++ src/domain/credentials/credentialCommands.ts | 15 + .../credentials/credentialPresentation.ts | 55 +++ src/domain/credentials/w3cVerifierPresets.ts | 131 +++++++ .../credentials/CredentialIssuerRoute.tsx | 52 ++- .../CredentialIssuerTypePanels.tsx | 53 ++- .../credentials/CredentialIssuerTypeRoute.tsx | 88 ++++- src/features/credentials/CredentialShared.tsx | 61 +++- .../CredentialW3CIssuanceControls.tsx | 127 +++++++ .../credentials/CredentialWalletPanels.tsx | 86 +++-- .../credentials/CredentialWalletRoute.tsx | 139 +++++-- .../credentials/CredentialsRouteContext.ts | 2 + src/features/credentials/CredentialsView.tsx | 22 +- src/features/credentials/W3CPresentCtrls.tsx | 300 ++++++++++++++++ src/services/credentials.service.ts | 199 +++++++++- src/state/operations.slice.ts | 1 + src/state/payloadDetails.ts | 8 +- src/state/selectors.ts | 3 + src/workflows/credentials.op.ts | 41 +++ src/workflows/didwebs.op.ts | 14 +- tests/unit/config.test.ts | 55 +++ tests/unit/credentialWalletPanels.test.tsx | 238 +++++++++++- tests/unit/credentialsService.test.ts | 340 +++++++++++++++++- tests/unit/pendingState.test.ts | 20 ++ tests/unit/routeData.test.ts | 111 ++++++ tests/unit/runtimeCommands.test.ts | 59 ++- tests/unit/runtimeWorkflow.test.ts | 82 +++++ tests/unit/w3cArtifactDetails.test.tsx | 54 +++ tests/unit/w3cVerifierPresets.test.ts | 65 ++++ 43 files changed, 3287 insertions(+), 150 deletions(-) create mode 100644 src/app/W3CArtifactDetails.tsx create mode 100644 src/app/credentialActionSubmit.ts create mode 100644 src/domain/credentials/credentialPresentation.ts create mode 100644 src/domain/credentials/w3cVerifierPresets.ts create mode 100644 src/features/credentials/CredentialW3CIssuanceControls.tsx create mode 100644 src/features/credentials/W3CPresentCtrls.tsx create mode 100644 tests/unit/w3cArtifactDetails.test.tsx create mode 100644 tests/unit/w3cVerifierPresets.test.ts diff --git a/.env.example b/.env.example index 694edcfb..a7cdfe08 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,13 @@ VITE_VERIFIER_DIRECT_URL=http://127.0.0.1:9723 VITE_VERIFIER_DASHBOARD_URL=http://127.0.0.1:9923 # VITE_VERIFIER_OOBI_URL= # VITE_TRUSTED_ISSUER_AID= + +# Local Isomer W3C verifier presentation targets. +# Public URLs are used for VP-JWT audience/client_id and evidence polling. +# Submission URLs are used by Dockerized KERIA to POST the signed VP-JWT. +VITE_W3C_ISOMER_PYTHON_PUBLIC_URL=http://127.0.0.1:8788 +VITE_W3C_ISOMER_PYTHON_SUBMISSION_URL=http://isomer-python:8788 +VITE_W3C_ISOMER_NODE_PUBLIC_URL=http://127.0.0.1:8789 +VITE_W3C_ISOMER_NODE_SUBMISSION_URL=http://isomer-node:8788 +VITE_W3C_ISOMER_GO_PUBLIC_URL=http://127.0.0.1:8790 +VITE_W3C_ISOMER_GO_SUBMISSION_URL=http://isomer-go:8788 diff --git a/package.json b/package.json index b0873add..5f3a5458 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "react-dom": "^19.2.5", "react-redux": "^9.2.0", "react-router-dom": "^7.14.2", - "signify-ts": "github:kentbull/signify-ts#8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3" + "signify-did-webs": "0.1.0", + "signify-ts": "^0.4.0", + "signify-w3c": "0.1.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1091e93f..55525f96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,15 @@ importers: react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + signify-did-webs: + specifier: 0.1.0 + version: 0.1.0(signify-ts@0.4.0) signify-ts: - specifier: github:kentbull/signify-ts#8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3 - version: https://codeload.github.com/kentbull/signify-ts/tar.gz/8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3 + specifier: ^0.4.0 + version: 0.4.0 + signify-w3c: + specifier: 0.1.0 + version: 0.1.0(signify-ts@0.4.0)(web-streams-polyfill@3.3.3) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -195,6 +201,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@digitalbazaar/http-client@3.4.1': + resolution: {integrity: sha512-Ahk1N+s7urkgj7WvvUND5f8GiWEPfUw0D41hdElaqLgu8wZScI8gdI0q+qWw5N1d35x7GCRH2uk9mi+Uzo9M3g==} + engines: {node: '>=14.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -466,6 +476,10 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -925,6 +939,10 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1059,6 +1077,9 @@ packages: caniuse-lite@1.0.30001788: resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} + canonicalize@1.0.8: + resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1123,6 +1144,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1271,6 +1296,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -1307,6 +1336,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1337,6 +1370,10 @@ packages: resolution: {integrity: sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==} engines: {node: '>=0.10.0'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1508,6 +1545,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonld@8.3.3: + resolution: {integrity: sha512-9YcilrF+dLfg9NTEof/mJLMtbdX1RJ8dbWtJgE00cMOIohb1lIyJl710vFiTaiHTl6ZYODJuBd32xFvUhmv3kg==} + engines: {node: '>=14'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1519,6 +1560,20 @@ packages: resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} engines: {node: '>=0.10.0'} + ky-universal@0.11.0: + resolution: {integrity: sha512-65KyweaWvk+uKKkCrfAf+xqN2/epw1IJDtlyCPxYffFCMR8u1sp2U65NtWpnozYfZxQ6IUzIlvUcw+hQ82U2Xw==} + engines: {node: '>=14.16'} + peerDependencies: + ky: '>=0.31.4' + web-streams-polyfill: '>=3.2.1' + peerDependenciesMeta: + web-streams-polyfill: + optional: true + + ky@0.33.3: + resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==} + engines: {node: '>=14.16'} + lazy-cache@0.2.7: resolution: {integrity: sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==} engines: {node: '>=0.10.0'} @@ -1551,6 +1606,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -1596,6 +1655,15 @@ packages: resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} engines: {node: '>= 0.4.0'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -1775,6 +1843,10 @@ packages: engines: {node: '>=18'} hasBin: true + rdf-canonize@3.4.0: + resolution: {integrity: sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA==} + engines: {node: '>=12'} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -1884,6 +1956,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shallow-clone@0.1.2: resolution: {integrity: sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==} engines: {node: '>=0.10.0'} @@ -1899,9 +1974,18 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signify-ts@https://codeload.github.com/kentbull/signify-ts/tar.gz/8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3: - resolution: {gitHosted: true, integrity: sha512-zp6zNU5yFlQOoc1w3iCfWA6DlrgA6VAodN5k7YTKC/yzyQmlcP6S5img6PqLwB2EW9rj6rCp0/VLYYpCnro52g==, tarball: https://codeload.github.com/kentbull/signify-ts/tar.gz/8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3} - version: 0.4.0 + signify-did-webs@0.1.0: + resolution: {integrity: sha512-OM+5oupWua/8OUbU/fyIfpgeVWTfrHPKKEi0g0whm5UwK1kuaBhjsY14aIaN9oFd6m7AZtFoDiK+n5kV18YbeA==} + peerDependencies: + signify-ts: ^0.4.0 + + signify-ts@0.4.0: + resolution: {integrity: sha512-LwUsXfmtfCI1lt2SAPYi6+CZQQS7Gaca8UrVGTQ2YEI0XbU5ygNpwy3+EUarRUlc7cgi5HW97kOyX1Ps5fXkmw==} + + signify-w3c@0.1.0: + resolution: {integrity: sha512-dnbY3HAHwQQONsyhT/ZDwBphzGdQUaCpX7ZNewPtdiARuwCOmlmrkzacLzUtVXFcTE/gkwvP0nT8lZx9ttl1Qg==} + peerDependencies: + signify-ts: ^0.4.0 smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} @@ -2024,6 +2108,10 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -2128,6 +2216,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} @@ -2171,6 +2263,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} @@ -2318,6 +2413,14 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@digitalbazaar/http-client@3.4.1(web-streams-polyfill@3.3.3)': + dependencies: + ky: 0.33.3 + ky-universal: 0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3) + undici: 5.29.0 + transitivePeerDependencies: + - web-streams-polyfill + '@dnd-kit/accessibility@3.1.1(react@19.2.5)': dependencies: react: 19.2.5 @@ -2538,6 +2641,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@fastify/busboy@2.1.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2990,6 +3095,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3094,6 +3203,8 @@ snapshots: caniuse-lite@1.0.30001788: {} + canonicalize@1.0.8: {} + chai@6.2.2: {} chromium-bidi@14.0.0(devtools-protocol@0.0.1595872): @@ -3159,6 +3270,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} debug@4.4.3: @@ -3336,6 +3449,8 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -3370,6 +3485,11 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3396,6 +3516,10 @@ snapshots: dependencies: for-in: 1.0.2 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fraction.js@5.3.4: {} fs-extra@10.1.0: @@ -3548,6 +3672,15 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonld@8.3.3(web-streams-polyfill@3.3.3): + dependencies: + '@digitalbazaar/http-client': 3.4.1(web-streams-polyfill@3.3.3) + canonicalize: 1.0.8 + lru-cache: 6.0.0 + rdf-canonize: 3.4.0 + transitivePeerDependencies: + - web-streams-polyfill + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3560,6 +3693,16 @@ snapshots: dependencies: is-buffer: 1.1.6 + ky-universal@0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3): + dependencies: + abort-controller: 3.0.0 + ky: 0.33.3 + node-fetch: 3.3.2 + optionalDependencies: + web-streams-polyfill: 3.3.3 + + ky@0.33.3: {} + lazy-cache@0.2.7: {} lazy-cache@1.0.4: {} @@ -3589,6 +3732,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lru-cache@7.18.3: {} magic-string@0.30.21: @@ -3636,6 +3783,14 @@ snapshots: netmask@2.1.1: {} + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.37: {} object-assign@4.1.1: {} @@ -3837,6 +3992,10 @@ snapshots: - typescript - utf-8-validate + rdf-canonize@3.4.0: + dependencies: + setimmediate: 1.0.5 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -3948,6 +4107,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setimmediate@1.0.5: {} + shallow-clone@0.1.2: dependencies: is-extendable: 0.1.1 @@ -3963,7 +4124,11 @@ snapshots: siginfo@2.0.0: {} - signify-ts@https://codeload.github.com/kentbull/signify-ts/tar.gz/8bdeb48516b4a9d8d7e6818e0bc37e4b26908ec3: + signify-did-webs@0.1.0(signify-ts@0.4.0): + dependencies: + signify-ts: 0.4.0 + + signify-ts@0.4.0: dependencies: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 @@ -3972,6 +4137,14 @@ snapshots: mathjs: 15.2.0 structured-headers: 0.5.0 + signify-w3c@0.1.0(signify-ts@0.4.0)(web-streams-polyfill@3.3.3): + dependencies: + '@noble/hashes': 1.8.0 + jsonld: 8.3.3(web-streams-polyfill@3.3.3) + signify-ts: 0.4.0 + transitivePeerDependencies: + - web-streams-polyfill + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -4109,6 +4282,10 @@ snapshots: undici-types@7.19.2: optional: true + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + universalify@2.0.1: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -4169,6 +4346,8 @@ snapshots: transitivePeerDependencies: - msw + web-streams-polyfill@3.3.3: {} + webdriver-bidi-protocol@0.4.1: {} which@2.0.2: @@ -4196,6 +4375,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@1.10.3: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53e97972..eb242786 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ allowBuilds: esbuild: true puppeteer: true - signify-ts: true diff --git a/src/app/PayloadDetails.tsx b/src/app/PayloadDetails.tsx index ce0fac18..d0e2950a 100644 --- a/src/app/PayloadDetails.tsx +++ b/src/app/PayloadDetails.tsx @@ -1,6 +1,11 @@ import { Box, Stack, Tooltip, Typography } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { monoValueSx } from './consoleStyles'; +import { + JsonArtifactDetails, + W3CJwtArtifactDetails, +} from './W3CArtifactDetails'; import type { PayloadDetailRecord } from '../state/payloadDetails'; const abbreviate = (value: string, maxLength: number): string => { @@ -23,6 +28,7 @@ export interface PayloadDetailsProps { details: readonly PayloadDetailRecord[]; dense?: boolean; maxLength?: number; + showStructured?: boolean; } /** @@ -32,14 +38,34 @@ export const PayloadDetails = ({ details, dense = false, maxLength = dense ? 52 : 84, + showStructured = !dense, }: PayloadDetailsProps) => { - if (details.length === 0) { + const visibleDetails = showStructured + ? details + : details.filter( + (detail) => detail.kind !== 'jwt' && detail.kind !== 'json' + ); + + if (visibleDetails.length === 0) { return null; } return ( - {details.map((detail) => ( + {visibleDetails.map((detail) => + detail.kind === 'jwt' ? ( + + ) : detail.kind === 'json' ? ( + + ) : ( + alpha(theme.palette.primary.main, 0.06), cursor: detail.copyable ? 'copy' : 'default', minWidth: 0, }} @@ -120,7 +147,8 @@ export const PayloadDetails = ({ )} - ))} + ) + )} ); }; diff --git a/src/app/W3CArtifactDetails.tsx b/src/app/W3CArtifactDetails.tsx new file mode 100644 index 00000000..cf385320 --- /dev/null +++ b/src/app/W3CArtifactDetails.tsx @@ -0,0 +1,317 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Stack, + Typography, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { decodeJwt } from 'signify-w3c'; +import type { ReactNode } from 'react'; +import { monoValueSx } from './consoleStyles'; +import { TelemetryRow } from './Console'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const stringValue = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + +const stringArrayText = (value: unknown): string | null => { + if (Array.isArray(value)) { + const items = value.flatMap((item) => { + const text = stringValue(item); + return text === null ? [] : [text]; + }); + return items.length === 0 ? null : items.join(', '); + } + + return stringValue(value); +}; + +const jsonText = (value: unknown): string => { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const copyValue = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +const JsonCodeBlock = ({ value }: { value: string }) => ( + + {value} + +); + +const CopyButton = ({ value, label }: { value: string; label: string }) => ( + +); + +const DetailAccordion = ({ + title, + children, +}: { + title: string; + children: ReactNode; +}) => ( + + }> + {title} + + {children} + +); + +const nestedVcJwtTokens = (payload: Record): string[] => { + const vp = isRecord(payload.vp) ? payload.vp : null; + const credentials = Array.isArray(vp?.verifiableCredential) + ? vp.verifiableCredential + : []; + + return credentials.flatMap((credential) => { + const token = stringValue(credential); + return token === null ? [] : [token]; + }); +}; + +const jwtSummaryRows = ( + header: Record, + payload: Record, + nestedCount: number +): Array<[string, string]> => { + const vc = isRecord(payload.vc) ? payload.vc : null; + const vp = isRecord(payload.vp) ? payload.vp : null; + return [ + ['alg', stringValue(header.alg) ?? 'Not available'], + ['kid', stringValue(header.kid) ?? 'Not available'], + ['iss', stringValue(payload.iss) ?? 'Not available'], + ['sub', stringValue(payload.sub) ?? 'Not available'], + ['jti', stringValue(payload.jti) ?? 'Not available'], + ['aud', stringValue(payload.aud) ?? 'Not available'], + ['nonce', stringValue(payload.nonce) ?? 'Not available'], + [ + 'credential type', + vc === null + ? 'Not available' + : (stringArrayText(vc.type) ?? 'Not available'), + ], + [ + 'presentation type', + vp === null + ? 'Not available' + : (stringArrayText(vp.type) ?? 'Not available'), + ], + ['nested VC-JWTs', String(nestedCount)], + ]; +}; + +/** + * Decode and render a compact W3C JWT for local inspection. This component does + * not verify signatures. + */ +export const W3CJwtArtifactDetails = ({ + token, + label, + level = 0, +}: { + token: string; + label: string; + level?: number; +}) => { + const segments = token.split('.'); + const decodedResult: + | { + ok: true; + header: Record; + payload: Record; + nested: string[]; + summaryRows: Array<[string, string]>; + } + | { ok: false; message: string } = (() => { + try { + const decoded = decodeJwt(token); + const header = decoded.header; + const payload = decoded.payload; + const nested = level >= 2 ? [] : nestedVcJwtTokens(payload); + return { + ok: true, + header, + payload, + nested, + summaryRows: jwtSummaryRows(header, payload, nested.length), + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error + ? error.message + : 'Unable to decode JWT.', + }; + } + })(); + + if (!decodedResult.ok) { + return ( + + {decodedResult.message} + + + + {token} + + + + + + ); + } + + const { header, payload, nested, summaryRows } = decodedResult; + + return ( + + {label} + + {summaryRows.map(([rowLabel, value]) => ( + + ))} + + + + + + + + + + + + + + + + + {token} + + + + + + + + {segments[2] ?? 'Not available'} + + {segments[2] !== undefined && ( + + )} + + + {nested.length > 0 && ( + + + {nested.map((nestedToken, index) => ( + + ))} + + + )} + + ); +}; + +/** Render a JSON payload artifact with collapsed raw detail and copy support. */ +export const JsonArtifactDetails = ({ + label, + value, +}: { + label: string; + value: string; +}) => ( + + + + + + +); diff --git a/src/app/credentialActionSubmit.ts b/src/app/credentialActionSubmit.ts new file mode 100644 index 00000000..30d09bd4 --- /dev/null +++ b/src/app/credentialActionSubmit.ts @@ -0,0 +1,25 @@ +/** + * Shared credential action submission helpers. + * + * Credentials and Dashboard both submit W3C presentation commands through the + * `/credentials` route action so route parsing stays in one boundary. + */ + +const newRequestId = (): string => + globalThis.crypto?.randomUUID?.() ?? + `credential-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +interface CredentialFormSubmitter { + submit( + formData: FormData, + options: { method: 'post'; action: string } + ): void; +} + +export const submitCredentialAction = ( + fetcher: CredentialFormSubmitter, + formData: FormData +): void => { + formData.set('requestId', newRequestId()); + fetcher.submit(formData, { method: 'post', action: '/credentials' }); +}; diff --git a/src/app/pendingState.ts b/src/app/pendingState.ts index 6a7d33ce..be0d7422 100644 --- a/src/app/pendingState.ts +++ b/src/app/pendingState.ts @@ -76,11 +76,15 @@ const intentLabel = (intent: string): string | null => { return 'Granting credential...'; } + if (intent === 'startW3CIssuance') { + return 'Starting W3C issuance...'; + } + if (intent === 'admit') { return 'Admitting credential...'; } - if (intent === 'present') { + if (intent === 'present' || intent === 'presentCredential') { return 'Presenting credential...'; } diff --git a/src/app/routeData.credentials.ts b/src/app/routeData.credentials.ts index 90902dc9..543eab09 100644 --- a/src/app/routeData.credentials.ts +++ b/src/app/routeData.credentials.ts @@ -4,6 +4,8 @@ import type { AdmitCredentialGrantInput, GrantCredentialInput, IssueSediCredentialInput, + PresentCredentialInput, + StartW3CIssuanceInput, } from '../domain/credentials/credentialCommands'; import type { CredentialActionData, @@ -11,6 +13,7 @@ import type { RouteDataRuntime, } from './routeData.types'; import { formString, toRouteError } from './routeData.shared'; +import { appConfig } from '../config'; /** * Credential route action boundary. @@ -35,7 +38,9 @@ const credentialIntentFromString = (value: string): CredentialIntent => value === 'createRegistry' || value === 'issueCredential' || value === 'grantCredential' || + value === 'startW3CIssuance' || value === 'admitCredentialGrant' || + value === 'presentCredential' || value === 'refreshCredentials' ? value : 'resolveSchema'; @@ -66,10 +71,14 @@ export const loadCredentials = async ( runtime.credentials.syncInventory({ signal: request?.signal }), ]); await runtime.credentials.syncIpexActivity({ signal: request?.signal }); - return { status: 'ready' }; + return { + status: 'ready', + verifiers: [...appConfig.w3cVerifiers], + }; } catch (error) { return { status: 'error', + verifiers: [...appConfig.w3cVerifiers], message: `Unable to refresh credential inventory: ${toRouteError(error).message}`, }; } @@ -81,6 +90,23 @@ const formBoolean = (formData: FormData, field: string): boolean => { return value === 'true' || value === 'on' || value === '1'; }; +const verifierRequestFromForm = ( + formData: FormData +): Record | null => { + const raw = formString(formData, 'verifierRequest').trim(); + if (raw.length === 0) { + return null; + } + try { + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +}; + /** * Resolve the selected issueable credential type from the app catalog. * @@ -323,6 +349,43 @@ const grantCredentialAction = ({ ); }; +/** + * Starts QVI-side W3C issuance from an already issued native VRD credential. + */ +const startW3CIssuanceAction = ({ + runtime, + formData, + requestId, +}: CredentialActionContext): CredentialActionData => { + const intent = 'startW3CIssuance'; + const input: StartW3CIssuanceInput = { + issuerAlias: formString(formData, 'issuerAlias').trim(), + issuerAid: formString(formData, 'issuerAid').trim(), + credentialSaid: formString(formData, 'credentialSaid').trim(), + }; + if ( + input.issuerAlias.length === 0 || + input.issuerAid.length === 0 || + input.credentialSaid.length === 0 + ) { + return { + intent, + ok: false, + message: 'Issuer identifier, issuer AID, and credential SAID are required.', + requestId, + }; + } + + return credentialStartedResult( + intent, + runtime.credentials.startW3CIssuance( + input, + requestIdOption(requestId) + ), + `Starting W3C issuance for ${input.credentialSaid}` + ); +}; + /** * Starts holder-side admission of a received credential grant. The handler * narrows notification form data into the workflow input and keeps IPEX admit @@ -362,6 +425,44 @@ const admitCredentialGrantAction = ({ ); }; +/** + * Starts W3C presentation for an admitted VRD credential. + */ +const presentCredentialAction = ({ + runtime, + formData, + requestId, +}: CredentialActionContext): CredentialActionData => { + const intent = 'presentCredential'; + const verifierRequest = verifierRequestFromForm(formData); + const input: PresentCredentialInput = { + presenterAlias: formString(formData, 'presenterAlias').trim(), + presenterAid: formString(formData, 'presenterAid').trim(), + credentialSaid: formString(formData, 'credentialSaid').trim(), + verifierRequest: verifierRequest ?? {}, + }; + if ( + input.presenterAlias.length === 0 || + input.presenterAid.length === 0 || + input.credentialSaid.length === 0 || + verifierRequest === null + ) { + return { + intent, + ok: false, + message: + 'Presenter identifier, credential, and verifier request JSON are required.', + requestId, + }; + } + + return credentialStartedResult( + intent, + runtime.credentials.startPresent(input, requestIdOption(requestId)), + `Presenting credential ${input.credentialSaid}` + ); +}; + /** * Dispatches credential route intents to named handlers. The explicit switch * is the local command map for this route family and should stay small enough @@ -381,8 +482,12 @@ const runCredentialIntentAction = ( return issueCredentialAction(context); case 'grantCredential': return grantCredentialAction(context); + case 'startW3CIssuance': + return startW3CIssuanceAction(context); case 'admitCredentialGrant': return admitCredentialGrantAction(context); + case 'presentCredential': + return presentCredentialAction(context); default: return { intent: 'unsupported', diff --git a/src/app/routeData.dashboard.ts b/src/app/routeData.dashboard.ts index 660a2237..7693a551 100644 --- a/src/app/routeData.dashboard.ts +++ b/src/app/routeData.dashboard.ts @@ -1,5 +1,6 @@ import type { DashboardLoaderData, RouteDataRuntime } from './routeData.types'; import { toRouteError } from './routeData.shared'; +import { appConfig } from '../config'; /** * Loader for `/dashboard`. @@ -24,10 +25,14 @@ export const loadDashboard = async ( runtime.credentials.syncInventory({ signal: request?.signal }), ]); await runtime.credentials.syncIpexActivity({ signal: request?.signal }); - return { status: 'ready' }; + return { + status: 'ready', + verifiers: [...appConfig.w3cVerifiers], + }; } catch (error) { return { status: 'error', + verifiers: [...appConfig.w3cVerifiers], message: `Unable to refresh dashboard inventory: ${toRouteError(error).message}`, }; } diff --git a/src/app/routeData.ts b/src/app/routeData.ts index 5204f1d2..49388259 100644 --- a/src/app/routeData.ts +++ b/src/app/routeData.ts @@ -15,6 +15,7 @@ export type { CredentialActionData, MultisigActionData, RouteDataRuntime, + W3CVerifierRequestPreset, } from './routeData.types'; export { loadDashboard } from './routeData.dashboard'; export { loadClient, rootAction } from './routeData.root'; diff --git a/src/app/routeData.types.ts b/src/app/routeData.types.ts index e2777674..71d58c44 100644 --- a/src/app/routeData.types.ts +++ b/src/app/routeData.types.ts @@ -15,6 +15,7 @@ import type { MultisigRuntimeCommands, NotificationRuntimeCommands, } from './runtimeCommands'; +import type { W3CVerifierRequestPreset } from '../domain/credentials/w3cVerifierPresets'; /** * Canonical route used for startup redirects, unknown paths, and successful @@ -22,6 +23,8 @@ import type { */ export const DEFAULT_APP_PATH = '/dashboard'; +export type { W3CVerifierRequestPreset }; + /** * Loader result used when a connected Signify client is required. */ @@ -43,8 +46,12 @@ export type IdentifiersLoaderData = * Loader data for `/dashboard`. */ export type DashboardLoaderData = - | { status: 'ready' } - | { status: 'error'; message: string } + | { status: 'ready'; verifiers: W3CVerifierRequestPreset[] } + | { + status: 'error'; + message: string; + verifiers: W3CVerifierRequestPreset[]; + } | BlockedRouteData; /** @@ -93,8 +100,12 @@ export type ClientLoaderData = * Loader data for the credentials route. */ export type CredentialsLoaderData = - | { status: 'ready' } - | { status: 'error'; message: string } + | { status: 'ready'; verifiers: W3CVerifierRequestPreset[] } + | { + status: 'error'; + message: string; + verifiers: W3CVerifierRequestPreset[]; + } | BlockedRouteData; /** @@ -139,6 +150,7 @@ export type ContactActionData = | 'respondChallenge' | 'verifyChallenge' | 'dismissExchangeNotification' + | 'markNotificationRead' | 'approveDelegationRequest' | 'delete' | 'updateAlias'; @@ -163,6 +175,7 @@ export type ContactActionData = | 'respondChallenge' | 'verifyChallenge' | 'dismissExchangeNotification' + | 'markNotificationRead' | 'approveDelegationRequest' | 'delete' | 'updateAlias' @@ -183,7 +196,9 @@ export type CredentialActionData = | 'createRegistry' | 'issueCredential' | 'grantCredential' + | 'startW3CIssuance' | 'admitCredentialGrant' + | 'presentCredential' | 'refreshCredentials'; ok: true; message: string; @@ -196,7 +211,9 @@ export type CredentialActionData = | 'createRegistry' | 'issueCredential' | 'grantCredential' + | 'startW3CIssuance' | 'admitCredentialGrant' + | 'presentCredential' | 'refreshCredentials' | 'unsupported'; ok: false; diff --git a/src/app/runtimeCommands/credentials.ts b/src/app/runtimeCommands/credentials.ts index fd04ab94..0627ee4a 100644 --- a/src/app/runtimeCommands/credentials.ts +++ b/src/app/runtimeCommands/credentials.ts @@ -3,24 +3,33 @@ import type { CreateCredentialRegistryInput, GrantCredentialInput, IssueSediCredentialInput, + PresentCredentialInput, ResolveCredentialSchemaInput, + StartW3CIssuanceInput, } from '../../domain/credentials/credentialCommands'; import type { CredentialSummaryRecord, RegistryRecord, SchemaRecord, } from '../../domain/credentials/credentialTypes'; +import type { + W3CIssuanceView, + W3CPresentTxView, +} from '../../services/credentials.service'; import { admitCredentialGrantOp, createCredentialRegistryOp, grantCredentialOp, issueSediCredentialOp, + presentCredentialOp, resolveCredentialSchemaOp, + startW3CIssuanceOp, syncCredentialInventoryOp, syncCredentialIpexActivityOp, syncCredentialRegistriesOp, syncKnownCredentialSchemasOp, } from '../../workflows/credentials.op'; +import { w3cPresentationPayloadDetails } from './payloadDetails'; import { credentialsRoute } from './helpers'; import type { BackgroundWorkflowRunOptions, @@ -51,10 +60,18 @@ export interface CredentialRuntimeCommands { input: GrantCredentialInput, options?: RequestIdOptions ): BackgroundWorkflowStartResult; + startW3CIssuance( + input: StartW3CIssuanceInput, + options?: RequestIdOptions + ): BackgroundWorkflowStartResult; startAdmit( input: AdmitCredentialGrantInput, options?: RequestIdOptions ): BackgroundWorkflowStartResult; + startPresent( + input: PresentCredentialInput, + options?: RequestIdOptions + ): BackgroundWorkflowStartResult; } /** @@ -71,7 +88,9 @@ export const createCredentialRuntimeCommands = ( startCreateRegistry: startCreateCredentialRegistry(context), startIssue: startIssueCredential(context), startGrant: startGrantCredential(context), + startW3CIssuance: startW3CIssuance(context), startAdmit: startAdmitCredentialGrant(context), + startPresent: startPresentCredential(context), }); const syncCredentialInventory = @@ -158,6 +177,17 @@ const startGrantCredential = grantCredentialOptions(input, options) ); +const startW3CIssuance = + (context: RuntimeCommandContext) => + ( + input: StartW3CIssuanceInput, + options: RequestIdOptions = {} + ): BackgroundWorkflowStartResult => + context.startBackgroundWorkflow( + () => startW3CIssuanceOp(input), + w3cIssuanceOptions(input, options) + ); + const startAdmitCredentialGrant = (context: RuntimeCommandContext) => ( @@ -169,6 +199,17 @@ const startAdmitCredentialGrant = admitCredentialGrantOptions(input, options) ); +const startPresentCredential = + (context: RuntimeCommandContext) => + ( + input: PresentCredentialInput, + options: RequestIdOptions = {} + ): BackgroundWorkflowStartResult => + context.startBackgroundWorkflow( + () => presentCredentialOp(input), + presentCredentialOptions(input, options) + ); + const resolveCredentialSchemaOptions = ( input: ResolveCredentialSchemaInput, options: RequestIdOptions @@ -267,6 +308,30 @@ const grantCredentialOptions = ( }, }); +const w3cIssuanceOptions = ( + input: StartW3CIssuanceInput, + options: RequestIdOptions +): BackgroundWorkflowRunOptions => ({ + requestId: options.requestId, + label: `Starting W3C issuance for ${input.credentialSaid}`, + title: 'Start W3C issuance', + description: + 'Starts QVI-side W3C VC-JWT issuance for a native VRD credential.', + kind: 'w3cIssuance', + resourceKeys: [`credential:${input.credentialSaid}:w3c-issue`], + resultRoute: credentialsRoute, + successNotification: { + title: 'W3C VRD issued', + message: 'The W3C VC-JWT was issued from the native VRD credential.', + severity: 'success', + }, + failureNotification: { + title: 'W3C issuance failed', + message: 'The W3C VRD could not be issued.', + severity: 'error', + }, +}); + const admitCredentialGrantOptions = ( input: AdmitCredentialGrantInput, options: RequestIdOptions @@ -290,3 +355,29 @@ const admitCredentialGrantOptions = ( severity: 'error', }, }); + +const presentCredentialOptions = ( + input: PresentCredentialInput, + options: RequestIdOptions +): BackgroundWorkflowRunOptions => ({ + requestId: options.requestId, + label: `Presenting credential ${input.credentialSaid}`, + title: 'Present credential', + description: + 'Creates a KERIA W3C presentation transaction from a runtime verifier request.', + kind: 'presentCredential', + resourceKeys: [`credential:${input.credentialSaid}:w3c-present`], + resultRoute: credentialsRoute, + successNotification: { + title: 'Credential presented', + message: 'KERIA recorded the W3C presentation transaction result.', + severity: 'success', + }, + payloadDetails: w3cPresentationPayloadDetails, + failureNotification: { + title: 'Credential presentation failed', + message: + 'The credential could not be presented through the verifier request.', + severity: 'error', + }, +}); diff --git a/src/app/runtimeCommands/payloadDetails.ts b/src/app/runtimeCommands/payloadDetails.ts index 655230bc..54f8a6d3 100644 --- a/src/app/runtimeCommands/payloadDetails.ts +++ b/src/app/runtimeCommands/payloadDetails.ts @@ -18,6 +18,14 @@ const stringArray = (value: unknown): string[] => const detailId = (label: string, index: number): string => `${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${index}`; +const jsonText = (value: unknown): string | null => { + try { + return JSON.stringify(value, null, 2); + } catch { + return null; + } +}; + const aliasForAid = (state: RootState, aid: string): string | null => { const localAlias = state.identifiers.byPrefix[aid]?.name?.trim(); if (localAlias !== undefined && localAlias.length > 0) { @@ -176,6 +184,122 @@ export const delegationPayloadDetails = ( return dedupeDetails(details); }; +/** + * Extract KERIA W3C presentation transaction ids from workflow results. + */ +export const w3cPresentationPayloadDetails = ( + result: unknown +): PayloadDetailRecord[] => { + if (!isRecord(result)) { + return []; + } + + const details: PayloadDetailRecord[] = []; + const presentTxId = stringValue(result.presentTxId); + const state = stringValue(result.state); + const submissionState = stringValue(result.submissionState); + const submissionEndpoint = stringValue(result.submissionEndpoint); + const vpJwt = stringValue(result.vpJwt); + const vcJwt = stringValue(result.vcJwt); + const verifierRequest = isRecord(result.verifierRequest) + ? result.verifierRequest + : isRecord(result.requestDescriptor) + ? result.requestDescriptor + : null; + const verifierResponse = isRecord(result.verifierResponse) + ? result.verifierResponse + : null; + const verifierOperation = stringValue(verifierResponse?.name); + const verifierRequestText = + verifierRequest === null ? null : jsonText(verifierRequest); + const verifierResponseText = + verifierResponse === null ? null : jsonText(verifierResponse); + + if (presentTxId !== null) { + details.push({ + id: detailId('w3c-present-tx', details.length), + label: 'Present Tx', + value: presentTxId, + kind: 'text', + copyable: true, + }); + } + if (state !== null) { + details.push({ + id: detailId('w3c-present-state', details.length), + label: 'Present State', + value: state, + kind: 'text', + copyable: false, + }); + } + if (submissionState !== null) { + details.push({ + id: detailId('w3c-submission-state', details.length), + label: 'Submission State', + value: submissionState, + kind: 'text', + copyable: false, + }); + } + if (verifierOperation !== null) { + details.push({ + id: detailId('w3c-verifier-operation', details.length), + label: 'Verifier Operation', + value: verifierOperation, + kind: 'text', + copyable: true, + }); + } + if (submissionEndpoint !== null) { + details.push({ + id: detailId('w3c-submission-endpoint', details.length), + label: 'Submission Endpoint', + value: submissionEndpoint, + kind: 'url', + copyable: true, + }); + } + if (vpJwt !== null) { + details.push({ + id: detailId('w3c-vp-jwt', details.length), + label: 'VP-JWT', + value: vpJwt, + kind: 'jwt', + copyable: true, + }); + } + if (vcJwt !== null) { + details.push({ + id: detailId('w3c-vc-jwt', details.length), + label: 'VC-JWT', + value: vcJwt, + kind: 'jwt', + copyable: true, + }); + } + if (verifierRequestText !== null) { + details.push({ + id: detailId('w3c-verifier-request', details.length), + label: 'Verifier Request', + value: verifierRequestText, + kind: 'json', + copyable: true, + }); + } + if (verifierResponseText !== null) { + details.push({ + id: detailId('w3c-verifier-response', details.length), + label: 'Verifier Response', + value: verifierResponseText, + kind: 'json', + copyable: true, + }); + } + + return dedupeDetails(details); +}; + /** * Compose runtime payload extractors without letting `AppRuntime` inspect * domain-specific workflow result shapes. @@ -187,6 +311,7 @@ export const combinedPayloadDetails = ( dedupeDetails([ ...oobiPayloadDetails(result), ...delegationPayloadDetails(result, state), + ...w3cPresentationPayloadDetails(result), ]); const dedupeDetails = ( diff --git a/src/config.ts b/src/config.ts index 83735463..61f4b6a7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,9 @@ import { Tier } from 'signify-ts'; +import { + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS, + W3C_VERIFY_VP_PATH, + type W3CVerifierRequestPreset, +} from './domain/credentials/w3cVerifierPresets'; /** * Runtime configuration shared by browser code and Node-based smoke scripts. @@ -158,6 +163,7 @@ export interface AppConfig { roles: RoleConfig; schemas: SchemaConfigs; verifier: VerifierConfig; + w3cVerifiers: W3CVerifierRequestPreset[]; defaultTier: Tier; } @@ -252,6 +258,67 @@ const buildConnectionOptions = ( return options; }; +const defaultW3CVerifierPresetById = new Map( + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS.map((preset) => [ + preset.id, + preset, + ]) +); + +const buildW3CVerifierPreset = ( + runtimeEnv: RuntimeEnv, + id: string, + publicUrlEnvName: string, + submissionUrlEnvName: string +): W3CVerifierRequestPreset => { + const fallback = defaultW3CVerifierPresetById.get(id); + if (fallback === undefined) { + throw new Error(`Unknown local W3C verifier preset ${id}.`); + } + + return { + ...fallback, + publicBaseUrl: stringFromEnv( + publicUrlEnvName, + runtimeEnv[publicUrlEnvName], + fallback.publicBaseUrl + ), + submissionBaseUrl: stringFromEnv( + submissionUrlEnvName, + runtimeEnv[submissionUrlEnvName], + fallback.submissionBaseUrl + ), + verifyVpPath: stringFromEnv( + 'VITE_W3C_VERIFY_VP_PATH', + runtimeEnv.VITE_W3C_VERIFY_VP_PATH, + fallback.verifyVpPath || W3C_VERIFY_VP_PATH + ), + }; +}; + +const buildW3CVerifierPresets = ( + runtimeEnv: RuntimeEnv +): W3CVerifierRequestPreset[] => [ + buildW3CVerifierPreset( + runtimeEnv, + 'isomer-python', + 'VITE_W3C_ISOMER_PYTHON_PUBLIC_URL', + 'VITE_W3C_ISOMER_PYTHON_SUBMISSION_URL' + ), + buildW3CVerifierPreset( + runtimeEnv, + 'isomer-node', + 'VITE_W3C_ISOMER_NODE_PUBLIC_URL', + 'VITE_W3C_ISOMER_NODE_SUBMISSION_URL' + ), + buildW3CVerifierPreset( + runtimeEnv, + 'isomer-go', + 'VITE_W3C_ISOMER_GO_PUBLIC_URL', + 'VITE_W3C_ISOMER_GO_SUBMISSION_URL' + ), +]; + /** * Build app config from an explicit environment map. * @@ -386,6 +453,7 @@ export const buildAppConfig = (runtimeEnv: RuntimeEnv): AppConfig => { runtimeEnv.VITE_TRUSTED_ISSUER_AID ), }, + w3cVerifiers: buildW3CVerifierPresets(runtimeEnv), defaultTier: Tier.low, }; }; diff --git a/src/domain/credentials/credentialCommands.ts b/src/domain/credentials/credentialCommands.ts index ba9197b9..51681127 100644 --- a/src/domain/credentials/credentialCommands.ts +++ b/src/domain/credentials/credentialCommands.ts @@ -37,6 +37,13 @@ export interface GrantCredentialInput { credentialSaid: string; } +/** Command data for QVI-side W3C issuance from a native VRD credential. */ +export interface StartW3CIssuanceInput { + issuerAlias: string; + issuerAid: string; + credentialSaid: string; +} + /** Command data for holder-side admission of an inbound credential grant. */ export interface AdmitCredentialGrantInput { holderAlias: string; @@ -44,3 +51,11 @@ export interface AdmitCredentialGrantInput { notificationId: string; grantSaid: string; } + +/** Command data for presenting one VRD ACDC through the W3C VC-JWT path. */ +export interface PresentCredentialInput { + presenterAlias: string; + presenterAid: string; + credentialSaid: string; + verifierRequest: Record; +} diff --git a/src/domain/credentials/credentialPresentation.ts b/src/domain/credentials/credentialPresentation.ts new file mode 100644 index 00000000..30597fff --- /dev/null +++ b/src/domain/credentials/credentialPresentation.ts @@ -0,0 +1,55 @@ +import type { CredentialSummaryRecord } from './credentialTypes'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; + +/** + * KERIA's current W3C presentation path supports the Isomer VRD credential only. + * + * Keep this explicit in the app so W3C Present does not look like a generic + * replacement for IPEX Grant or a generic ACDC presentation mechanism. + */ +export const W3C_PRESENTABLE_VRD_SCHEMA_SAID = + 'EAyv2DLocYxJlPrWAfYBuHWDpjCStdQBzNLg0-3qQ-KP'; + +/** Return whether this credential can use the current W3C Present workflow. */ +export const isW3CPresentableVrdCredential = ( + credential: CredentialSummaryRecord +): boolean => credential.schemaSaid === W3C_PRESENTABLE_VRD_SCHEMA_SAID; + +/** Return whether this native credential can start QVI-side W3C issuance. */ +export const isW3CIssuableVrdCredential = ( + credential: CredentialSummaryRecord +): boolean => + credential.direction === 'issued' && + credential.schemaSaid === W3C_PRESENTABLE_VRD_SCHEMA_SAID; + +/** Select the local issuer AID that can start QVI-side W3C issuance. */ +export const selectCredentialW3CIssuer = ( + credential: CredentialSummaryRecord, + identifiers: readonly IdentifierSummary[] +): IdentifierSummary | null => { + if (credential.issuerAid === null) { + return null; + } + + return ( + identifiers.find( + (identifier) => identifier.prefix === credential.issuerAid + ) ?? null + ); +}; + +/** Select the local AID that should run W3C Present for this credential. */ +export const selectW3CPresenter = ( + credential: CredentialSummaryRecord, + identifiers: readonly IdentifierSummary[] +): IdentifierSummary | null => { + if (credential.direction !== 'held' || credential.holderAid === null) { + return null; + } + + return ( + identifiers.find( + (identifier) => identifier.prefix === credential.holderAid + ) ?? null + ); +}; diff --git a/src/domain/credentials/w3cVerifierPresets.ts b/src/domain/credentials/w3cVerifierPresets.ts new file mode 100644 index 00000000..88d59c7e --- /dev/null +++ b/src/domain/credentials/w3cVerifierPresets.ts @@ -0,0 +1,131 @@ +export interface W3CVerifierRequestPreset { + id: string; + label: string; + publicBaseUrl: string; + submissionBaseUrl: string; + verifyVpPath: string; +} + +export interface W3CVerifierRequestDescriptor + extends Record { + verifierId: string; + verifierLabel: string; + verifierOrigin: string; + origin: string; + format: 'vp+jwt'; + formats: ['vp+jwt']; + client_id: string; + aud: string; + nonce: string; + response_uri: string; + submissionEndpoint: string; +} + +export const W3C_VERIFY_VP_PATH = '/verify/vp'; +export const DEFAULT_W3C_VERIFIER_PRESET_ID = 'isomer-python'; + +export const DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS: readonly W3CVerifierRequestPreset[] = + [ + { + id: 'isomer-python', + label: 'Isomer Python', + publicBaseUrl: 'http://127.0.0.1:8788', + submissionBaseUrl: 'http://isomer-python:8788', + verifyVpPath: W3C_VERIFY_VP_PATH, + }, + { + id: 'isomer-node', + label: 'Isomer Node', + publicBaseUrl: 'http://127.0.0.1:8789', + submissionBaseUrl: 'http://isomer-node:8788', + verifyVpPath: W3C_VERIFY_VP_PATH, + }, + { + id: 'isomer-go', + label: 'Isomer Go', + publicBaseUrl: 'http://127.0.0.1:8790', + submissionBaseUrl: 'http://isomer-go:8788', + verifyVpPath: W3C_VERIFY_VP_PATH, + }, + ]; + +export const defaultW3CVerifierPreset = ( + presets: readonly W3CVerifierRequestPreset[] = + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS +): W3CVerifierRequestPreset => + presetById(DEFAULT_W3C_VERIFIER_PRESET_ID, presets) ?? + presets[0] ?? + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS[0]; + +export const presetById = ( + id: string, + presets: readonly W3CVerifierRequestPreset[] = + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS +): W3CVerifierRequestPreset | null => + presets.find((preset) => preset.id === id) ?? null; + +export const verifierPresetForSelection = ( + selectedVerifierId: string, + presets: readonly W3CVerifierRequestPreset[] = + DEFAULT_LOCAL_ISOMER_W3C_VERIFIER_PRESETS +): W3CVerifierRequestPreset => + presetById(selectedVerifierId, presets) ?? defaultW3CVerifierPreset(presets); + +export const createW3CPresentationNonce = ( + verifierId: string, + credentialSaid: string +): string => { + const random = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `react-${verifierId}-${credentialSaid.slice(0, 12)}-${random}`; +}; + +export const buildW3CVerifierRequestDescriptor = ({ + preset, + nonce, +}: { + preset: W3CVerifierRequestPreset; + nonce: string; +}): W3CVerifierRequestDescriptor => { + const publicBaseUrl = stripTrailingSlash(preset.publicBaseUrl); + const submissionBaseUrl = stripTrailingSlash(preset.submissionBaseUrl); + const path = pathWithLeadingSlash(preset.verifyVpPath); + const verifierEndpoint = `${publicBaseUrl}${path}`; + const submissionEndpoint = `${submissionBaseUrl}${path}`; + + return { + verifierId: preset.id, + verifierLabel: preset.label, + verifierOrigin: publicBaseUrl, + origin: publicBaseUrl, + format: 'vp+jwt', + formats: ['vp+jwt'], + client_id: verifierEndpoint, + aud: verifierEndpoint, + nonce, + response_uri: submissionEndpoint, + submissionEndpoint, + }; +}; + +export const buildW3CVerifierRequestJson = ({ + preset, + credentialSaid, +}: { + preset: W3CVerifierRequestPreset; + credentialSaid: string; +}): string => + JSON.stringify( + buildW3CVerifierRequestDescriptor({ + preset, + nonce: createW3CPresentationNonce(preset.id, credentialSaid), + }), + null, + 2 + ); + +const stripTrailingSlash = (value: string): string => value.replace(/\/+$/, ''); + +const pathWithLeadingSlash = (value: string): string => + value.startsWith('/') ? value : `/${value}`; diff --git a/src/features/credentials/CredentialIssuerRoute.tsx b/src/features/credentials/CredentialIssuerRoute.tsx index 9cebead7..22b5e41c 100644 --- a/src/features/credentials/CredentialIssuerRoute.tsx +++ b/src/features/credentials/CredentialIssuerRoute.tsx @@ -20,6 +20,7 @@ import { useAppSelector } from '../../state/hooks'; import { selectCredentialRegistries, selectCredentialSchemas, + selectDidWebsDidsByAid, selectIssueableCredentialTypeViews, selectIssuedCredentials, } from '../../state/selectors'; @@ -37,6 +38,9 @@ import { timestampText, } from './credentialDisplay'; import { useCredentialsRouteContext } from './CredentialsRouteContext'; +import { CredentialW3CIssuanceControls } from './CredentialW3CIssuanceControls'; +import type { CredentialSummaryRecord } from '../../domain/credentials/credentialTypes'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; /** * Issuer route for one selected local AID. @@ -47,15 +51,19 @@ import { useCredentialsRouteContext } from './CredentialsRouteContext'; */ export const CredentialIssuerRoute = () => { const { + actionStatus, actionRunning, + identifiers, selectedIdentifier, submitResolveSchema, + submitCredentialForm, } = useCredentialsRouteContext(); const navigate = useNavigate(); const credentialTypes = useAppSelector(selectIssueableCredentialTypeViews); const schemas = useAppSelector(selectCredentialSchemas); const issuedCredentials = useAppSelector(selectIssuedCredentials); const registries = useAppSelector(selectCredentialRegistries); + const didWebsByAid = useAppSelector(selectDidWebsDidsByAid); if (selectedIdentifier === null) { return null; @@ -85,6 +93,29 @@ export const CredentialIssuerRoute = () => { issuedCredentials, selectedIdentifier.prefix ); + const didWebsReadyByAid = new Map( + Object.entries(didWebsByAid).map(([aid, did]) => [ + aid, + did.loadState === 'ready' && did.did !== null, + ]) + ); + const issuanceAction = + actionStatus !== null && + 'intent' in actionStatus && + actionStatus.intent === 'startW3CIssuance' + ? actionStatus + : null; + const submitStartW3CIssuance = ( + credential: CredentialSummaryRecord, + issuer: IdentifierSummary + ) => { + const formData = new FormData(); + formData.set('intent', 'startW3CIssuance'); + formData.set('issuerAlias', issuer.name); + formData.set('issuerAid', issuer.prefix); + formData.set('credentialSaid', credential.said); + submitCredentialForm(formData); + }; return ( @@ -270,7 +301,7 @@ export const CredentialIssuerRoute = () => { /> ) : ( - +
Type @@ -279,6 +310,7 @@ export const CredentialIssuerRoute = () => { Status Issued Credential + W3C Issue @@ -324,6 +356,24 @@ export const CredentialIssuerRoute = () => { 24 )} + + + ) )} diff --git a/src/features/credentials/CredentialIssuerTypePanels.tsx b/src/features/credentials/CredentialIssuerTypePanels.tsx index 1763c7c3..31912562 100644 --- a/src/features/credentials/CredentialIssuerTypePanels.tsx +++ b/src/features/credentials/CredentialIssuerTypePanels.tsx @@ -15,10 +15,16 @@ import { TextField, Typography, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import InventoryIcon from '@mui/icons-material/Inventory'; import SendIcon from '@mui/icons-material/Send'; -import { ConsolePanel, EmptyState, StatusPill, TelemetryRow } from '../../app/Console'; +import { + ConsolePanel, + EmptyState, + StatusPill, + TelemetryRow, +} from '../../app/Console'; import { clickablePanelSx, monoValueSx } from '../../app/consoleStyles'; import { abbreviateMiddle } from '../../domain/contacts/contactHelpers'; import { @@ -34,6 +40,9 @@ import type { ContactRecord } from '../../state/contacts.slice'; import type { CredentialRegistryTile } from './credentialViewModels'; import { statusTone } from './credentialDisplay'; import { CredentialRecordRows } from './CredentialShared'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; +import { CredentialW3CIssuanceControls } from './CredentialW3CIssuanceControls'; +import type { CredentialActionData } from '../../app/routeData'; /** * Registry selection and creation controls for issuer credential-type routes. @@ -149,8 +158,8 @@ export const CredentialRegistrySelector = ({ borderRadius: 1, p: 1.5, bgcolor: selected - ? 'rgba(118, 232, 255, 0.08)' - : 'rgba(13, 23, 34, 0.72)', + ? 'action.selected' + : 'background.paper', }, clickablePanelSx, ]} @@ -220,7 +229,11 @@ export const SediVoterIssueForm = ({ onIssue: () => void; }) => ( <> - + Holder contact @@ -129,7 +153,7 @@ export const WalletStackPreview = ({ borderRadius: 1, height: 118, p: 1.5, - bgcolor: 'rgba(13, 23, 34, 0.72)', + bgcolor: 'background.paper', }} > @@ -156,8 +180,12 @@ export const WalletStackPreview = ({ bgcolor: index === 0 ? 'background.paper' - : 'rgba(16, 29, 42, 0.96)', - boxShadow: '0 16px 32px rgba(0, 0, 0, 0.24)', + : 'background.default', + boxShadow: (theme) => + `0 16px 32px ${alpha( + theme.palette.common.black, + theme.palette.mode === 'dark' ? 0.24 : 0.1 + )}`, zIndex: previewCredentials.length - index, }} > @@ -195,7 +223,11 @@ export const OverviewMetric = ({ {label} @@ -248,7 +280,11 @@ export const OverviewChoiceCard = ({ borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper', - boxShadow: '0 18px 42px rgba(0, 0, 0, 0.2)', + boxShadow: (theme) => + `0 18px 42px ${alpha( + theme.palette.common.black, + theme.palette.mode === 'dark' ? 0.2 : 0.08 + )}`, p: 2, display: 'flex', flexDirection: 'column', @@ -260,7 +296,8 @@ export const OverviewChoiceCard = ({ position: 'absolute', inset: 0, pointerEvents: 'none', - borderTop: '1px solid rgba(118, 232, 255, 0.16)', + borderTop: (theme) => + `1px solid ${alpha(theme.palette.primary.light, 0.16)}`, }, }, clickablePanelSx, diff --git a/src/features/credentials/CredentialW3CIssuanceControls.tsx b/src/features/credentials/CredentialW3CIssuanceControls.tsx new file mode 100644 index 00000000..1327bcca --- /dev/null +++ b/src/features/credentials/CredentialW3CIssuanceControls.tsx @@ -0,0 +1,127 @@ +import { Button, Stack, Typography } from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { UI_SOUND_HOVER_VALUE } from '../../app/uiSound'; +import type { CredentialActionData } from '../../app/routeData'; +import { + isW3CIssuableVrdCredential, + selectCredentialW3CIssuer, +} from '../../domain/credentials/credentialPresentation'; +import type { CredentialSummaryRecord } from '../../domain/credentials/credentialTypes'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; + +interface CredentialW3CIssuanceControlsProps { + credential: CredentialSummaryRecord; + identifiers: readonly IdentifierSummary[]; + didWebsReadyByAid: ReadonlyMap; + actionRunning: boolean; + issuanceAction?: CredentialActionData | null; + onStartIssuance: ( + credential: CredentialSummaryRecord, + issuer: IdentifierSummary + ) => void; +} + +/** + * Issuer-side manual fallback for creating the W3C VRD VC-JWT twin. + * + * This control is intentionally shown on the QVI-held issuer record, not on + * the LE-held credential. The LE can present only after the QVI starts W3C + * issuance, KERIA delivers the W3C grant, and the holder edge imports/admites + * exactly one eligible held W3C credential. + */ +export const CredentialW3CIssuanceControls = ({ + credential, + identifiers, + didWebsReadyByAid, + actionRunning, + issuanceAction = null, + onStartIssuance, +}: CredentialW3CIssuanceControlsProps) => { + const issuer = selectCredentialW3CIssuer(credential, identifiers); + const schemaSupported = isW3CIssuableVrdCredential(credential); + const statusIssuable = + credential.status === 'issued' || credential.status === 'grantSent'; + const didWebsReady = + issuer !== null && didWebsReadyByAid.get(issuer.prefix) === true; + const issuanceActionResult = + issuanceAction?.intent === 'startW3CIssuance' + ? issuanceAction + : null; + + // Keep the fallback narrow: W3C issuance needs the active native VRD and + // the local issuer AID. DID/webs setup is performed as part of the action. + const blocker = !schemaSupported + ? 'Only issuer-side VRD credentials can start W3C issuance.' + : !statusIssuable + ? `Credential status is ${credential.status}; W3C issuance requires an active issuer-side VRD credential.` + : issuer === null + ? 'This wallet does not control the credential issuer AID.' + : actionRunning + ? 'A credential command is already running.' + : null; + const readyMessage = didWebsReady + ? 'Ready to start QVI-side W3C VC-JWT issuance from this native VRD.' + : 'DID/webs setup will run before QVI-side W3C VC-JWT issuance.'; + + return ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + + + {blocker ?? readyMessage} + + {issuanceActionResult !== null && ( + + {issuanceActionResult.message} + + )} + + ); +}; diff --git a/src/features/credentials/CredentialWalletPanels.tsx b/src/features/credentials/CredentialWalletPanels.tsx index d0d8853c..f8329af8 100644 --- a/src/features/credentials/CredentialWalletPanels.tsx +++ b/src/features/credentials/CredentialWalletPanels.tsx @@ -1,13 +1,14 @@ -import { - Box, - Button, - Stack, - Typography, -} from '@mui/material'; +import { Box, Button, Stack, Typography } from '@mui/material'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import RefreshIcon from '@mui/icons-material/Refresh'; -import { ConsolePanel, EmptyState, StatusPill, TelemetryRow } from '../../app/Console'; -import { clickablePanelSx, monoValueSx } from '../../app/consoleStyles'; +import { + ConsolePanel, + EmptyState, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import type { CredentialActionData } from '../../app/routeData'; +import { monoValueSx } from '../../app/consoleStyles'; import { abbreviateMiddle } from '../../domain/contacts/contactHelpers'; import type { IssueableCredentialTypeView } from '../../domain/credentials/credentialCatalog'; import type { @@ -15,12 +16,11 @@ import type { CredentialSummaryRecord, SchemaRecord, } from '../../domain/credentials/credentialTypes'; +import type { W3CVerifierRequestPreset } from '../../domain/credentials/w3cVerifierPresets'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; import type { CredentialWalletStats } from './credentialViewModels'; -import { - schemaLabel, - schemaStatusTone, - statusTone, -} from './credentialDisplay'; +import { schemaLabel, schemaStatusTone, statusTone } from './credentialDisplay'; +import { W3CPresentCtrls } from './W3CPresentCtrls.tsx'; /** * Holder-side credential type readiness panel. @@ -263,19 +263,36 @@ export const InboundGrantsPanel = ({ ); /** - * Holder-side admitted credential list. Rows navigate to the full dashboard - * credential detail route. + * Holder-side admitted credential list. W3C Present stays a row-local command. */ export const HeldCredentialsPanel = ({ credentials, credentialTypesBySchema, schemasBySaid, - onOpenCredential, + identifiers, + didWebsReadyByAid, + verifiers, + selectedVerifierId, + presentationAction, + actionRunning, + onVerifierChange, + onPresent, }: { credentials: readonly CredentialSummaryRecord[]; credentialTypesBySchema: ReadonlyMap; schemasBySaid: ReadonlyMap; - onOpenCredential: (credentialSaid: string) => void; + identifiers: readonly IdentifierSummary[]; + didWebsReadyByAid: ReadonlyMap; + verifiers: readonly W3CVerifierRequestPreset[]; + selectedVerifierId: string; + presentationAction?: CredentialActionData | null; + actionRunning: boolean; + onVerifierChange: (verifierRequestJson: string) => void; + onPresent: ( + credential: CredentialSummaryRecord, + presenter: IdentifierSummary, + verifierRequestJson: string + ) => void; }) => ( {credentials.length === 0 ? ( @@ -288,25 +305,13 @@ export const HeldCredentialsPanel = ({ {credentials.map((credential) => ( onOpenCredential(credential.said)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onOpenCredential(credential.said); - } + sx={{ + border: 1, + borderColor: 'divider', + borderRadius: 1, + p: 1.5, + bgcolor: 'background.paper', }} - sx={[ - { - border: 1, - borderColor: 'divider', - borderRadius: 1, - p: 1.5, - bgcolor: 'rgba(13, 23, 34, 0.72)', - }, - clickablePanelSx, - ]} > {abbreviateMiddle(credential.said, 28)} + ))} diff --git a/src/features/credentials/CredentialWalletRoute.tsx b/src/features/credentials/CredentialWalletRoute.tsx index 316821c7..9ac4733b 100644 --- a/src/features/credentials/CredentialWalletRoute.tsx +++ b/src/features/credentials/CredentialWalletRoute.tsx @@ -1,18 +1,22 @@ +import { useEffect, useMemo, useState } from 'react'; import { Box, Button, Stack } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router-dom'; import { useAppSelector } from '../../state/hooks'; import { selectCredentialGrantNotifications, + selectDidWebsDidsByAid, selectHeldCredentials, selectIssueableCredentialTypeViews, selectCredentialSchemas, } from '../../state/selectors'; +import { useAppRuntime } from '../../app/runtimeHooks'; import { grantsForAid, heldCredentialsForAid, walletStatsForAid, } from './credentialViewModels'; +import type { CredentialSummaryRecord } from '../../domain/credentials/credentialTypes'; import { credentialPath } from './credentialDisplay'; import { useCredentialsRouteContext } from './CredentialsRouteContext'; import { @@ -21,29 +25,32 @@ import { WalletCredentialTypesPanel, WalletStatsPanel, } from './CredentialWalletPanels'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; +import { selectW3CPresenter } from '../../domain/credentials/credentialPresentation'; /** * Wallet route for one selected local AID. * * This route owns holder-side schema readiness, inbound grant admission, and - * held credential navigation. + * held credential W3C Present actions. */ export const CredentialWalletRoute = () => { - const navigate = useNavigate(); + const runtime = useAppRuntime(); const { actionRunning, + actionStatus, selectedIdentifier, + identifiers, submitResolveSchema, submitCredentialForm, + w3cVerifiers, } = useCredentialsRouteContext(); const credentialTypes = useAppSelector(selectIssueableCredentialTypeViews); const schemas = useAppSelector(selectCredentialSchemas); const heldCredentials = useAppSelector(selectHeldCredentials); const grantNotifications = useAppSelector(selectCredentialGrantNotifications); - - if (selectedIdentifier === null) { - return null; - } + const didWebsByAid = useAppSelector(selectDidWebsDidsByAid); + const [selectedVerifierId, setSelectedVerifierId] = useState(''); const credentialTypesBySchema = new Map( credentialTypes.map((credentialType) => [ @@ -54,22 +61,87 @@ export const CredentialWalletRoute = () => { const schemasBySaid = new Map( schemas.map((schema) => [schema.said, schema]) ); - const selectedAidHeldCredentials = heldCredentialsForAid( - heldCredentials, - selectedIdentifier.prefix + const selectedAid = selectedIdentifier?.prefix ?? ''; + const selectedAidHeldCredentials = useMemo( + () => heldCredentialsForAid(heldCredentials, selectedAid), + [heldCredentials, selectedAid] + ); + const didWebsReadyByAid = useMemo( + () => + new Map( + Object.entries(didWebsByAid).map(([aid, did]) => [ + aid, + did.loadState === 'ready' && did.did !== null, + ]) + ), + [didWebsByAid] + ); + const presentationPresenters = useMemo( + () => + Array.from( + new Map( + selectedAidHeldCredentials + .map((credential) => + selectW3CPresenter( + credential, + identifiers + ) + ) + .filter( + ( + presenter + ): presenter is IdentifierSummary => + presenter !== null + ) + .map((presenter) => [presenter.prefix, presenter]) + ).values() + ), + [identifiers, selectedAidHeldCredentials] + ); + const presentationPresenterRefreshKey = JSON.stringify( + presentationPresenters + .map(({ name, prefix }) => ({ name, prefix })) + .sort((left, right) => left.prefix.localeCompare(right.prefix)) ); const selectedAidGrants = grantsForAid( grantNotifications, - selectedIdentifier.prefix + selectedAid ); const walletStats = walletStatsForAid({ - aid: selectedIdentifier.prefix, + aid: selectedAid, heldCredentials, grants: grantNotifications, }); + const effectiveVerifierId = selectedVerifierId; const unresolvedWalletCredentialType = credentialTypes.find((type) => type.schemaStatus !== 'resolved') ?? null; + useEffect(() => { + const presenters = JSON.parse(presentationPresenterRefreshKey) as Array< + Pick + >; + + if (presenters.length === 0) { + return undefined; + } + + const controller = new AbortController(); + for (const presenter of presenters) { + void runtime.didwebs + .refreshIdentifierDid(presenter.name, presenter.prefix, { + signal: controller.signal, + track: false, + }) + .catch(() => undefined); + } + + return () => controller.abort(); + }, [runtime, presentationPresenterRefreshKey]); + + if (selectedIdentifier === null) { + return null; + } + const submitAdmit = (notificationId: string, grantSaid: string) => { const formData = new FormData(); formData.set('intent', 'admitCredentialGrant'); @@ -80,8 +152,22 @@ export const CredentialWalletRoute = () => { submitCredentialForm(formData); }; - const openHeldCredential = (credentialSaid: string) => { - navigate(`/dashboard/credentials/${encodeURIComponent(credentialSaid)}`); + const submitPresent = ( + credential: CredentialSummaryRecord, + presenter: IdentifierSummary, + verifierRequestJson: string + ) => { + if (verifierRequestJson.length === 0) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'presentCredential'); + formData.set('presenterAlias', presenter.name); + formData.set('presenterAid', presenter.prefix); + formData.set('credentialSaid', credential.said); + formData.set('verifierRequest', verifierRequestJson); + submitCredentialForm(formData); }; return ( @@ -96,6 +182,25 @@ export const CredentialWalletRoute = () => { Back + { schemasBySaid={schemasBySaid} onAdmit={submitAdmit} /> - ); }; diff --git a/src/features/credentials/CredentialsRouteContext.ts b/src/features/credentials/CredentialsRouteContext.ts index ebeba6a7..e6fd9113 100644 --- a/src/features/credentials/CredentialsRouteContext.ts +++ b/src/features/credentials/CredentialsRouteContext.ts @@ -1,6 +1,7 @@ import { useOutletContext } from 'react-router-dom'; import type { CredentialActionData } from '../../app/routeData'; import type { IssueableCredentialTypeView } from '../../domain/credentials/credentialCatalog'; +import type { W3CVerifierRequestPreset } from '../../domain/credentials/w3cVerifierPresets'; import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; export interface CredentialsRouteContextValue { @@ -9,6 +10,7 @@ export interface CredentialsRouteContextValue { selectedAid: string; selectedIdentifier: IdentifierSummary | null; identifiers: readonly IdentifierSummary[]; + w3cVerifiers: readonly W3CVerifierRequestPreset[]; navigateToAid: (aid: string) => void; submitRefresh: () => void; submitResolveSchema: (credentialType: IssueableCredentialTypeView) => void; diff --git a/src/features/credentials/CredentialsView.tsx b/src/features/credentials/CredentialsView.tsx index bf73f9e1..e56fa8b4 100644 --- a/src/features/credentials/CredentialsView.tsx +++ b/src/features/credentials/CredentialsView.tsx @@ -19,6 +19,7 @@ import { ConnectionRequired } from '../../app/ConnectionRequired'; import { ConsolePanel, EmptyState, PageHeader, StatusPill } from '../../app/Console'; import { useAppRuntime } from '../../app/runtimeHooks'; import type { CredentialActionData, CredentialsLoaderData } from '../../app/routeData'; +import { submitCredentialAction } from '../../app/credentialActionSubmit'; import type { IssueableCredentialTypeView } from '../../domain/credentials/credentialCatalog'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { @@ -33,22 +34,6 @@ import { credentialPath } from './credentialDisplay'; import { AidSelector } from './CredentialShared'; import type { CredentialsRouteContextValue } from './CredentialsRouteContext'; -const newRequestId = (): string => - globalThis.crypto?.randomUUID?.() ?? - `credential-${Date.now()}-${Math.random().toString(16).slice(2)}`; - -interface FormSubmitter { - submit( - formData: FormData, - options: { method: 'post'; action: string } - ): void; -} - -const submitWithId = (fetcher: FormSubmitter, formData: FormData): void => { - formData.set('requestId', newRequestId()); - fetcher.submit(formData, { method: 'post', action: '/credentials' }); -}; - /** * Credentials route layout. * @@ -70,6 +55,8 @@ export const CredentialsView = () => { const selectedIdentifier = identifiers.find((identifier) => identifier.prefix === selectedAid) ?? null; + const w3cVerifiers = + loaderData.status === 'blocked' ? [] : loaderData.verifiers; useEffect(() => { if (aidParam === undefined && walletSelectedAid !== null) { @@ -124,7 +111,7 @@ export const CredentialsView = () => { }; const submitCredentialForm = (formData: FormData) => { - submitWithId(fetcher, formData); + submitCredentialAction(fetcher, formData); }; const submitRefresh = () => { @@ -147,6 +134,7 @@ export const CredentialsView = () => { selectedAid, selectedIdentifier, identifiers, + w3cVerifiers, navigateToAid, submitRefresh, submitResolveSchema, diff --git a/src/features/credentials/W3CPresentCtrls.tsx b/src/features/credentials/W3CPresentCtrls.tsx new file mode 100644 index 00000000..548dcb09 --- /dev/null +++ b/src/features/credentials/W3CPresentCtrls.tsx @@ -0,0 +1,300 @@ +import { + useMemo, + useState, + type ChangeEvent, +} from 'react'; +import { + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import { appConfig } from '../../config'; +import { UI_SOUND_HOVER_VALUE } from '../../app/uiSound'; +import { + isW3CPresentableVrdCredential, + selectW3CPresenter, +} from '../../domain/credentials/credentialPresentation'; +import type { CredentialSummaryRecord } from '../../domain/credentials/credentialTypes'; +import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; +import type { CredentialActionData } from '../../app/routeData'; +import { + buildW3CVerifierRequestJson, + verifierPresetForSelection, + type W3CVerifierRequestPreset, +} from '../../domain/credentials/w3cVerifierPresets'; + +interface W3CPresentCtrlsProps { + credential: CredentialSummaryRecord; + identifiers: readonly IdentifierSummary[]; + didWebsReadyByAid: ReadonlyMap; + verifiers?: readonly W3CVerifierRequestPreset[]; + selectedVerifierId: string; + actionRunning: boolean; + presentationAction?: CredentialActionData | null; + onVerifierChange: (verifierId: string) => void; + onPresent: ( + credential: CredentialSummaryRecord, + presenter: IdentifierSummary, + verifierRequestJson: string + ) => void; +} + +const controlId = (credentialSaid: string): string => + `w3c-verifier-request-${credentialSaid.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + +const parseVerifierRequest = ( + value: string +): Record | null => { + try { + const parsed = JSON.parse(value); + return typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +}; + +type VerifierRequestOverride = { + key: string; + value: string; + manual: boolean; +}; + +/** + * Shared W3C presentation controls for admitted VRD credentials. + * + * KERIA owns the presentation transaction and verifier submission workflow. + * This component accepts a runtime verifier request descriptor instead of a + * deployment-time verifier allowlist entry. + */ +export const W3CPresentCtrls = ({ + credential, + identifiers, + didWebsReadyByAid, + verifiers = appConfig.w3cVerifiers, + selectedVerifierId, + actionRunning, + presentationAction = null, + onVerifierChange, + onPresent, +}: W3CPresentCtrlsProps) => { + const presenter = selectW3CPresenter(credential, identifiers); + const availableVerifiers = + verifiers.length > 0 ? verifiers : appConfig.w3cVerifiers; + const selectedVerifier = verifierPresetForSelection( + selectedVerifierId, + availableVerifiers + ); + const verifierRequestKey = [ + credential.said, + selectedVerifier.id, + selectedVerifier.publicBaseUrl, + selectedVerifier.submissionBaseUrl, + selectedVerifier.verifyVpPath, + ].join('|'); + const defaultVerifierRequestJson = useMemo( + () => + buildW3CVerifierRequestJson({ + preset: selectedVerifier, + credentialSaid: credential.said, + }), + [credential.said, selectedVerifier] + ); + const [verifierRequestOverride, setVerifierRequestOverride] = + useState(null); + const activeVerifierRequestOverride = + verifierRequestOverride?.key === verifierRequestKey + ? verifierRequestOverride + : null; + const verifierRequestJson = + activeVerifierRequestOverride?.value ?? defaultVerifierRequestJson; + const manualVerifierRequest = + activeVerifierRequestOverride?.manual === true; + const verifierRequest = parseVerifierRequest(verifierRequestJson); + const buildSelectedVerifierRequestJson = () => + buildW3CVerifierRequestJson({ + preset: selectedVerifier, + credentialSaid: credential.said, + }); + const verifierLabelId = controlId(credential.said); + const verifierSelectorLabelId = `${verifierLabelId}-selector-label`; + const schemaSupported = isW3CPresentableVrdCredential(credential); + const statusPresentable = + credential.status === 'admitted' || + credential.status === 'issued' || + credential.status === 'grantSent'; + const didWebsReady = + presenter !== null && didWebsReadyByAid.get(presenter.prefix) === true; + const presentationActionResult = + presentationAction?.intent === 'presentCredential' + ? presentationAction + : null; + + function getW3CPresentBlockerMsg(): string | null { + if (!statusPresentable) { + return `Credential status is ${credential.status}; W3C Present requires an active issued or admitted VRD credential.`; + } + if (!schemaSupported) { + return 'Only supported VRD credentials can be presented through W3C.'; + } + if (presenter === null) { + return 'This wallet does not control the credential holder AID required for W3C Present.'; + } + if (verifierRequest === null) { + return 'Enter a valid runtime verifier request JSON object.'; + } + if (actionRunning) { + return 'A credential command is already running.'; + } + return null; + } + + const blockerMsg = getW3CPresentBlockerMsg(); + const readyMessage = didWebsReady + ? 'Ready to create a KERIA W3C presentation transaction from this verifier request.' + : 'DID/webs setup will run before W3C presentation.'; + + function selectVerifier( + event: SelectChangeEvent + ): void { + const nextVerifier = verifierPresetForSelection( + event.target.value, + availableVerifiers + ); + onVerifierChange(nextVerifier.id); + setVerifierRequestOverride(null); + } + + function verifierRequestChange( + event: ChangeEvent + ): void { + setVerifierRequestOverride({ + key: verifierRequestKey, + value: event.target.value, + manual: true, + }); + } + + function presentW3C(): void { + if ( + blockerMsg !== null || + presenter === null || + verifierRequest === null + ) { + return; + } + + const requestJson = manualVerifierRequest + ? verifierRequestJson + : buildSelectedVerifierRequestJson(); + if (!manualVerifierRequest) { + setVerifierRequestOverride({ + key: verifierRequestKey, + value: requestJson, + manual: false, + }); + } + onPresent(credential, presenter, requestJson); + } + + return ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + + Verifier + + + + + + + + {blockerMsg ?? readyMessage} + + {presentationActionResult !== null && ( + + {presentationActionResult.message} + + )} + + ); +}; diff --git a/src/services/credentials.service.ts b/src/services/credentials.service.ts index ea3654f9..094087fc 100644 --- a/src/services/credentials.service.ts +++ b/src/services/credentials.service.ts @@ -7,6 +7,15 @@ import { type Operation as KeriaOperation, type SignifyClient, } from 'signify-ts'; +import { + issueW3CCredential, + presentW3CCredential, + W3CKeriaClient, + type W3CHeldCredential, + type W3CIssuanceContext, + type W3CPresentationResult, +} from 'signify-w3c'; +import { ensureDidWebsSetup } from 'signify-did-webs'; import { callPromise, toErrorText } from '../effects/promise'; import type { OperationLogger } from '../signify/client'; import type { @@ -61,6 +70,16 @@ const CREDENTIAL_FETCH_RETRIES = 10; const CREDENTIAL_FETCH_RETRY_MS = 1000; const EXCHANGE_QUERY_LIMIT = 200; +export type W3CIssuanceView = W3CIssuanceContext; + +export interface W3CPresentTxView extends W3CPresentationResult { + presentTxId: string; + vcJwt: string | null; + verifierRequest: Record; + submissionEndpoint: string | null; + sourceCredentialSaid: string; +} + const keriaTimestamp = (): string => new Date().toISOString().replace('Z', '000+00:00'); @@ -162,9 +181,7 @@ export function* createCredentialRegistryService({ 'Registry name' ); - const registries = yield* callPromise(() => - client.registries().list(name) - ); + const registries = yield* callPromise(() => client.registries().list(name)); const existing = registries.find( (registry) => @@ -315,9 +332,7 @@ export function* issueSediCredentialService({ throw new Error('Issued credential response did not include a SAID.'); } - const credential = yield* callPromise(() => - client.credentials().get(said) - ); + const credential = yield* callPromise(() => client.credentials().get(said)); return credentialRecordFromKeriaCredential({ credential, credentialTypes: ISSUEABLE_CREDENTIAL_TYPES, @@ -356,7 +371,9 @@ export function* grantIssuedCredentialService({ recipient, acdc: new Serder(serderSad(credential.sad, 'Credential SAD')), anc: new Serder(serderSad(credential.anc, 'Credential anchor')), - iss: new Serder(serderSad(credential.iss, 'Credential issue event')), + iss: new Serder( + serderSad(credential.iss, 'Credential issue event') + ), ancAttachment: stringValue(credential.ancatc) ?? undefined, datetime: keriaTimestamp(), }) @@ -702,6 +719,174 @@ export function* admitCredentialGrantService({ }); } +/** + * Start QVI-side W3C VC-JWT issuance from a native VRD source credential. + * + * The browser edge runtime builds and signs the VC-JWT, submits it to KERIA + * for validation, then signs the grant EXN that delivers the artifact. + */ +export function* startW3CIssuanceService({ + client, + issuerAlias, + issuerAid, + credentialSaid, + timeoutMs, + pollMs, +}: { + client: SignifyClient; + issuerAlias: string; + issuerAid: string; + credentialSaid: string; + timeoutMs: number; + pollMs?: number; +}): EffectionOperation { + const name = requireNonEmpty(issuerAlias, 'Issuer identifier'); + requireNonEmpty(issuerAid, 'Issuer AID'); + const sourceSaid = requireNonEmpty(credentialSaid, 'Credential SAID'); + yield* callPromise(() => + ensureDidWebsSetup({ + client, + name, + timeoutMs, + pollMs, + }) + ); + return yield* callPromise(() => + issueW3CCredential({ + client, + issuerName: name, + sourceCredentialSaid: sourceSaid, + timeoutMs, + pollMs, + }) + ); +} + +/** + * Build and submit a holder-signed W3C VP-JWT from a runtime verifier request. + * + * The browser edge runtime owns VP assembly and signing. KERIA validates the + * submitted VP-JWT against the selected held credential and verifier request, + * forwards it, and records the result. + */ +export function* presentCredentialService({ + client, + presenterAlias, + presenterAid, + credentialSaid, + verifierRequest, + timeoutMs, + pollMs, +}: { + client: SignifyClient; + presenterAlias: string; + presenterAid: string; + credentialSaid: string; + verifierRequest: Record; + timeoutMs: number; + pollMs?: number; +}): EffectionOperation { + const name = requireNonEmpty(presenterAlias, 'Presenter identifier'); + requireNonEmpty(presenterAid, 'Presenter AID'); + const said = requireNonEmpty(credentialSaid, 'Credential SAID'); + if (Object.keys(verifierRequest).length === 0) { + throw new Error('Verifier request JSON is required.'); + } + yield* callPromise(() => + ensureDidWebsSetup({ + client, + name, + timeoutMs, + pollMs, + }) + ); + const requestDescriptor = { + ...verifierRequest, + credentialSaid: said, + }; + const w3c = new W3CKeriaClient(client); + const credentials = yield* callPromise(() => w3c.credentials(name)); + const held = selectHeldW3CCredential(credentials, said); + if (held === null) { + throw new Error( + `No held W3C credential was found for source credential ${said}.` + ); + } + const credentialId = requireNonEmpty( + String(held.credentialId ?? ''), + 'W3C held credential id' + ); + const result = yield* callPromise(() => + presentW3CCredential({ + client, + holderName: name, + credentialId, + verifierRequest: requestDescriptor, + }) + ); + const view = presentationViewFromResult({ + result, + held, + requestDescriptor, + sourceCredentialSaid: said, + }); + if (view.state === 'failed') { + throw new Error( + view.error ?? + `W3C presentation ${view.presentationId} ended as failed.` + ); + } + return view; +} + +const selectHeldW3CCredential = ( + credentials: readonly W3CHeldCredential[], + credentialSaid: string +): W3CHeldCredential | null => + credentials.find((credential) => { + const credentialId = stringValue(credential.credentialId); + const sourceCredentialSaid = stringValue( + credential.sourceCredentialSaid + ); + return ( + credentialId === credentialSaid || + sourceCredentialSaid === credentialSaid + ); + }) ?? null; + +const presentationViewFromResult = ({ + result, + held, + requestDescriptor, + sourceCredentialSaid, +}: { + result: W3CPresentationResult; + held: W3CHeldCredential; + requestDescriptor: Record; + sourceCredentialSaid: string; +}): W3CPresentTxView => { + const presentationId = requireNonEmpty( + String(result.presentationId ?? result.presentTxId ?? ''), + 'W3C presentation id' + ); + const submissionEndpoint = + stringValue(result.submissionEndpoint) ?? + stringValue(requestDescriptor.submissionEndpoint) ?? + stringValue(requestDescriptor.response_uri); + return { + ...result, + presentationId, + presentTxId: presentationId, + selectedCredentialId: + result.selectedCredentialId ?? String(held.credentialId ?? ''), + vcJwt: stringValue(held.vcJwt) ?? null, + verifierRequest: requestDescriptor, + requestDescriptor: result.requestDescriptor ?? requestDescriptor, + submissionEndpoint, + sourceCredentialSaid, + }; +}; + /** * Load issued and held credentials for local AIDs and project them into * serializable credential summary records. diff --git a/src/state/operations.slice.ts b/src/state/operations.slice.ts index b899ca84..12b9667e 100644 --- a/src/state/operations.slice.ts +++ b/src/state/operations.slice.ts @@ -48,6 +48,7 @@ export type OperationKind = | 'createRegistry' | 'issueCredential' | 'grantCredential' + | 'w3cIssuance' | 'admitCredential' | 'presentCredential' | 'pollNotifications' diff --git a/src/state/payloadDetails.ts b/src/state/payloadDetails.ts index ca65167d..cf06cbfd 100644 --- a/src/state/payloadDetails.ts +++ b/src/state/payloadDetails.ts @@ -1,5 +1,11 @@ /** Payload detail type for meaningful workflow artifacts. */ -export type PayloadDetailKind = 'oobi' | 'aid' | 'url' | 'text'; +export type PayloadDetailKind = + | 'oobi' + | 'aid' + | 'url' + | 'text' + | 'jwt' + | 'json'; /** Serializable, copyable payload surfaced on operations and notifications. */ export interface PayloadDetailRecord { diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 8db6e78d..0e6f00e1 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -36,6 +36,9 @@ export const selectDidWebsDidByAid = ? null : (state.didwebs.byAid[aid] ?? null); +/** Select did:webs DID facts keyed by local AID. */ +export const selectDidWebsDidsByAid = (state: RootState) => state.didwebs.byAid; + /** Select only the session status for shell rendering. */ export const selectConnectionStatus = (state: RootState) => state.session.status; diff --git a/src/workflows/credentials.op.ts b/src/workflows/credentials.op.ts index f7c5e6ea..a4a0149f 100644 --- a/src/workflows/credentials.op.ts +++ b/src/workflows/credentials.op.ts @@ -10,6 +10,10 @@ import { listCredentialInventoryService, listCredentialRegistriesService, listKnownCredentialSchemasService, + presentCredentialService, + startW3CIssuanceService, + type W3CIssuanceView, + type W3CPresentTxView, resolveCredentialSchemaService, } from '../services/credentials.service'; import { @@ -34,7 +38,9 @@ import type { CreateCredentialRegistryInput, GrantCredentialInput, IssueSediCredentialInput, + PresentCredentialInput, ResolveCredentialSchemaInput, + StartW3CIssuanceInput, } from '../domain/credentials/credentialCommands'; import { localIdentifierAids, syncSessionInventoryOp } from './contacts.op'; @@ -43,7 +49,9 @@ export type { CreateCredentialRegistryInput, GrantCredentialInput, IssueSediCredentialInput, + PresentCredentialInput, ResolveCredentialSchemaInput, + StartW3CIssuanceInput, } from '../domain/credentials/credentialCommands'; /** @@ -218,6 +226,39 @@ export function* admitCredentialGrantOp( return credential; } +/** + * Workflow for QVI-side W3C VRD issuance from a native VRD credential. + */ +export function* startW3CIssuanceOp( + input: StartW3CIssuanceInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + return yield* startW3CIssuanceService({ + client: services.runtime.requireConnectedClient(), + issuerAlias: input.issuerAlias, + issuerAid: input.issuerAid, + credentialSaid: input.credentialSaid, + timeoutMs: services.config.operations.timeoutMs, + }); +} + +/** + * Workflow for presenting a VRD credential through the W3C VC-JWT path. + */ +export function* presentCredentialOp( + input: PresentCredentialInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + return yield* presentCredentialService({ + client: services.runtime.requireConnectedClient(), + presenterAlias: input.presenterAlias, + presenterAid: input.presenterAid, + credentialSaid: input.credentialSaid, + verifierRequest: input.verifierRequest, + timeoutMs: services.config.operations.timeoutMs, + }); +} + /** * Synchronize issued and held credential inventory for local identifiers. */ diff --git a/src/workflows/didwebs.op.ts b/src/workflows/didwebs.op.ts index 70439208..26aff9b8 100644 --- a/src/workflows/didwebs.op.ts +++ b/src/workflows/didwebs.op.ts @@ -1,4 +1,5 @@ import type { Operation as EffectionOperation } from 'effection'; +import { getDidWebsSetup } from 'signify-did-webs'; import { callPromise, toErrorText } from '../effects/promise'; import { AppServicesContext } from '../effects/contexts'; import { @@ -28,14 +29,17 @@ export function* refreshIdentifierDidWebsOp({ ); try { - const dws = yield* callPromise(() => - services.runtime.requireConnectedClient().identifiers().dws(name) + const setup = yield* callPromise(() => + getDidWebsSetup({ + client: services.runtime.requireConnectedClient(), + name, + }) ); const payload: DidWebsDidPayload = { aid, - did: dws.dws, - didJsonUrl: dws.didJsonUrl, - keriCesrUrl: dws.keriCesrUrl, + did: setup.dws, + didJsonUrl: setup.didJsonUrl, + keriCesrUrl: setup.keriCesrUrl, updatedAt: new Date().toISOString(), }; services.store.dispatch(didWebsDidLoaded(payload)); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index e43d880f..b7989da9 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -41,6 +41,29 @@ describe('buildAppConfig', () => { oobiUrl: null, trustedIssuerAid: null, }); + expect(config.w3cVerifiers).toEqual([ + { + id: 'isomer-python', + label: 'Isomer Python', + publicBaseUrl: 'http://127.0.0.1:8788', + submissionBaseUrl: 'http://isomer-python:8788', + verifyVpPath: '/verify/vp', + }, + { + id: 'isomer-node', + label: 'Isomer Node', + publicBaseUrl: 'http://127.0.0.1:8789', + submissionBaseUrl: 'http://isomer-node:8788', + verifyVpPath: '/verify/vp', + }, + { + id: 'isomer-go', + label: 'Isomer Go', + publicBaseUrl: 'http://127.0.0.1:8790', + submissionBaseUrl: 'http://isomer-go:8788', + verifyVpPath: '/verify/vp', + }, + ]); }); it('parses app runtime overrides and optional cloud connection options', () => { @@ -72,6 +95,18 @@ describe('buildAppConfig', () => { VITE_VERIFIER_DASHBOARD_URL: 'http://verifier.example.test:9923', VITE_VERIFIER_OOBI_URL: 'http://verifier.example.test/oobi', VITE_TRUSTED_ISSUER_AID: 'trusted-issuer-aid', + VITE_W3C_ISOMER_PYTHON_PUBLIC_URL: + 'http://python-public.example.test', + VITE_W3C_ISOMER_PYTHON_SUBMISSION_URL: + 'http://python-submit.example.test', + VITE_W3C_ISOMER_NODE_PUBLIC_URL: + 'http://node-public.example.test', + VITE_W3C_ISOMER_NODE_SUBMISSION_URL: + 'http://node-submit.example.test', + VITE_W3C_ISOMER_GO_PUBLIC_URL: 'http://go-public.example.test', + VITE_W3C_ISOMER_GO_SUBMISSION_URL: + 'http://go-submit.example.test', + VITE_W3C_VERIFY_VP_PATH: '/custom/verify/vp', }); expect(config.connectionOptions).toEqual([ @@ -117,6 +152,26 @@ describe('buildAppConfig', () => { oobiUrl: 'http://verifier.example.test/oobi', trustedIssuerAid: 'trusted-issuer-aid', }); + expect(config.w3cVerifiers).toMatchObject([ + { + id: 'isomer-python', + publicBaseUrl: 'http://python-public.example.test', + submissionBaseUrl: 'http://python-submit.example.test', + verifyVpPath: '/custom/verify/vp', + }, + { + id: 'isomer-node', + publicBaseUrl: 'http://node-public.example.test', + submissionBaseUrl: 'http://node-submit.example.test', + verifyVpPath: '/custom/verify/vp', + }, + { + id: 'isomer-go', + publicBaseUrl: 'http://go-public.example.test', + submissionBaseUrl: 'http://go-submit.example.test', + verifyVpPath: '/custom/verify/vp', + }, + ]); }); it('supports the legacy credential schema env names', () => { diff --git a/tests/unit/credentialWalletPanels.test.tsx b/tests/unit/credentialWalletPanels.test.tsx index bacabf05..fa5cfe84 100644 --- a/tests/unit/credentialWalletPanels.test.tsx +++ b/tests/unit/credentialWalletPanels.test.tsx @@ -1,6 +1,15 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import { HeldCredentialsPanel } from '../../src/features/credentials/CredentialWalletPanels'; +import { CredentialW3CIssuanceControls } from '../../src/features/credentials/CredentialW3CIssuanceControls'; +import { W3CPresentCtrls } from '../../src/features/credentials/W3CPresentCtrls'; +import { IssuedCredentialsForTypePanel } from '../../src/features/credentials/CredentialIssuerTypePanels'; +import { appConfig } from '../../src/config'; +import { + selectCredentialW3CIssuer, + selectW3CPresenter, + W3C_PRESENTABLE_VRD_SCHEMA_SAID, +} from '../../src/domain/credentials/credentialPresentation'; import type { CredentialSummaryRecord, SchemaRecord, @@ -39,21 +48,244 @@ const schema = { } satisfies SchemaRecord; describe('wallet credential panels', () => { - it('renders held credential rows as detail navigation targets without inline expansion details', () => { + it('selects the correct local W3C issuer and presenter roles', () => { + const identifiers = [ + { name: 'issuer', prefix: 'Eissuer' }, + { name: 'holder', prefix: 'Eholder' }, + ]; + + expect(selectCredentialW3CIssuer(credential, identifiers)?.prefix).toBe( + 'Eissuer' + ); + expect( + selectW3CPresenter(credential, identifiers)?.prefix + ).toBe('Eholder'); + expect( + selectW3CPresenter( + { ...credential, direction: 'issued' }, + identifiers + ) + ).toBeNull(); + }); + + it('renders held credential rows as inline workflow cards without detail navigation', () => { const markup = renderToStaticMarkup( ); - expect(markup).toContain('role="button"'); + expect(markup).not.toContain('role="button"'); expect(markup).toContain('Verifiable Reference Data (VRD) Credential'); + expect(markup).toContain('Present'); expect(markup).toContain('Ecredential'); expect(markup).not.toContain('Schema SAID'); expect(markup).not.toContain('Issuer'); expect(markup).not.toContain('Holder'); }); + + it('renders the shared verifier selector and explicit W3C Present blockers', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Verifier request'); + expect(markup).toContain('Isomer Python'); + expect(markup).toContain('http://127.0.0.1:8788/verify/vp'); + expect(markup).toContain('Present'); + expect(markup).toContain( + 'This wallet does not control the credential holder AID required for W3C Present.' + ); + }); + + it('uses a local holder as the presenter for held VRD credentials', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain( + 'Ready to create a KERIA W3C presentation transaction from this verifier request.' + ); + }); + + it('allows W3C presentation while presenter did:webs setup is pending', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Present'); + expect(markup).toContain( + 'DID/webs setup will run before W3C presentation.' + ); + }); + + it('does not present issued VRD credentials through the issuer role', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain( + 'This wallet does not control the credential holder AID required for W3C Present.' + ); + }); + + it('renders a ready issuer-side W3C issuance fallback for issued VRD credentials', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Start W3C issuance'); + expect(markup).toContain( + 'Ready to start QVI-side W3C VC-JWT issuance from this native VRD.' + ); + }); + + it('allows W3C issuance while issuer did:webs setup is pending', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Start W3C issuance'); + expect(markup).toContain( + 'DID/webs setup will run before QVI-side W3C VC-JWT issuance.' + ); + }); + + it('blocks W3C issuance fallback outside the local issuer role', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Start W3C issuance'); + expect(markup).toContain( + 'Only issuer-side VRD credentials can start W3C issuance.' + ); + }); + + it('renders W3C issuance alongside issuer IPEX Grant controls', () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain('Grant'); + expect(markup).toContain('Start W3C issuance'); + expect(markup).not.toContain('Present'); + expect(markup).not.toContain('Verifier request'); + }); }); diff --git a/tests/unit/credentialsService.test.ts b/tests/unit/credentialsService.test.ts index a387e991..8757697d 100644 --- a/tests/unit/credentialsService.test.ts +++ b/tests/unit/credentialsService.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { - CredentialResult, - Operation as KeriaOperation, - SignifyClient, +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type CredentialResult, + type Operation as KeriaOperation, + type SignifyClient, } from 'signify-ts'; import { createAppRuntime } from '../../src/app/runtime'; import { @@ -11,6 +11,8 @@ import { listCredentialInventoryService, listCredentialRegistriesService, listKnownCredentialSchemasService, + presentCredentialService, + startW3CIssuanceService, } from '../../src/services/credentials.service'; import { credentialGrantFromExchange, @@ -21,6 +23,30 @@ import { normalizeSediVoterAttributes } from '../../src/domain/credentials/sediV import { ISSUEABLE_CREDENTIAL_TYPES } from '../../src/config/credentialCatalog'; import type { NotificationRecord } from '../../src/state/notifications.slice'; +const w3cMocks = vi.hoisted(() => ({ + credentials: vi.fn(), + issueW3CCredential: vi.fn(), + presentW3CCredential: vi.fn(), +})); +const didWebsMocks = vi.hoisted(() => ({ + ensureDidWebsSetup: vi.fn(), +})); + +vi.mock('signify-w3c', () => ({ + W3C_GRANT_ROUTE: '/w3c/vc/grant', + issueW3CCredential: w3cMocks.issueW3CCredential, + presentW3CCredential: w3cMocks.presentW3CCredential, + W3CKeriaClient: vi.fn().mockImplementation(function () { + return { + credentials: w3cMocks.credentials, + }; + }), +})); + +vi.mock('signify-did-webs', () => ({ + ensureDidWebsSetup: didWebsMocks.ensureDidWebsSetup, +})); + const loadedAt = '2026-04-22T00:00:00.000Z'; const testCredentialTypes = [ @@ -147,6 +173,304 @@ const makeAdmitClient = () => { }; describe('credential service helpers', () => { + beforeEach(() => { + w3cMocks.credentials.mockReset(); + w3cMocks.issueW3CCredential.mockReset(); + w3cMocks.presentW3CCredential.mockReset(); + didWebsMocks.ensureDidWebsSetup.mockReset(); + didWebsMocks.ensureDidWebsSetup.mockResolvedValue({ + ready: true, + dws: 'did:webs:example.com:dws:Eaid', + }); + }); + + it('starts W3C issuance through the edge artifact builder', async () => { + const runtime = createAppRuntime({ storage: null }); + const client = {} as unknown as SignifyClient; + w3cMocks.issueW3CCredential.mockResolvedValue({ + issuanceId: 'issuance-1', + state: 'grant_sent', + sourceCredentialSaid: 'Ecredential', + holderAid: 'Eholder', + holderDid: 'did:webs:example.com:dws:Eholder', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + schemaSaid: 'Eschema', + statusUrl: 'http://status.example/w3c/vc/status/Ecredential', + profile: 'gleif-vrd-isomer-v1', + vcJwt: 'vc.jwt', + grantSaid: 'grant-said', + }); + + try { + const result = await runtime.runWorkflow( + () => + startW3CIssuanceService({ + client, + issuerAlias: 'issuer', + issuerAid: 'Eissuer', + credentialSaid: 'Ecredential', + timeoutMs: 1000, + pollMs: 1, + }), + { + scope: 'app', + track: false, + kind: 'w3cIssuance', + } + ); + + expect(result).toEqual( + expect.objectContaining({ + issuanceId: 'issuance-1', + state: 'grant_sent', + vcJwt: 'vc.jwt', + grantSaid: 'grant-said', + }) + ); + expect(didWebsMocks.ensureDidWebsSetup).toHaveBeenCalledWith({ + client, + name: 'issuer', + timeoutMs: 1000, + pollMs: 1, + }); + expect( + didWebsMocks.ensureDidWebsSetup.mock.invocationCallOrder[0] + ).toBeLessThan( + w3cMocks.issueW3CCredential.mock.invocationCallOrder[0] + ); + expect(w3cMocks.issueW3CCredential).toHaveBeenCalledWith({ + client, + issuerName: 'issuer', + sourceCredentialSaid: 'Ecredential', + timeoutMs: 1000, + pollMs: 1, + }); + } finally { + await runtime.destroy(); + } + }); + + it('presents a held W3C credential through one edge-built VP-JWT submission', async () => { + const runtime = createAppRuntime({ storage: null }); + const client = {} as unknown as SignifyClient; + w3cMocks.credentials.mockResolvedValue([ + { + credentialId: 'held-id', + holderName: 'presenter', + holderAid: 'Epresenter', + holderDid: 'did:webs:example.com:dws:Epresenter', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + sourceCredentialSaid: 'Ecredential', + schemaSaid: 'Eschema', + profile: 'gleif-vrd-isomer-v1', + statusUrl: 'http://status.example/w3c/vc/status/Ecredential', + vcJwt: 'vc.jwt', + state: 'admitted', + }, + ]); + w3cMocks.presentW3CCredential.mockResolvedValue({ + presentationId: 'presentation-1', + state: 'submitted', + holderName: 'presenter', + holderAid: 'Epresenter', + selectedCredentialId: 'held-id', + verifierResponse: { ok: true }, + }); + + try { + const result = await runtime.runWorkflow( + () => + presentCredentialService({ + client, + presenterAlias: 'presenter', + presenterAid: 'Epresenter', + credentialSaid: 'Ecredential', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + response_uri: 'http://verifier.example/verify/vp', + }, + timeoutMs: 1000, + pollMs: 1, + }), + { + scope: 'app', + track: false, + kind: 'presentCredential', + } + ); + + expect(result).toEqual( + expect.objectContaining({ + presentationId: 'presentation-1', + presentTxId: 'presentation-1', + state: 'submitted', + verifierResponse: { ok: true }, + }) + ); + expect(didWebsMocks.ensureDidWebsSetup).toHaveBeenCalledWith({ + client, + name: 'presenter', + timeoutMs: 1000, + pollMs: 1, + }); + expect( + didWebsMocks.ensureDidWebsSetup.mock.invocationCallOrder[0] + ).toBeLessThan(w3cMocks.credentials.mock.invocationCallOrder[0]); + expect(w3cMocks.credentials).toHaveBeenCalledWith('presenter'); + expect(w3cMocks.presentW3CCredential).toHaveBeenCalledWith({ + client, + holderName: 'presenter', + credentialId: 'held-id', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + response_uri: 'http://verifier.example/verify/vp', + credentialSaid: 'Ecredential', + }, + }); + } finally { + await runtime.destroy(); + } + }); + + it('fails W3C presentation when no held W3C credential matches the clicked source credential', async () => { + const runtime = createAppRuntime({ storage: null }); + const client = {} as unknown as SignifyClient; + w3cMocks.credentials.mockResolvedValue([ + { + credentialId: 'other-held-id', + holderName: 'presenter', + holderAid: 'Epresenter', + holderDid: 'did:webs:example.com:dws:Epresenter', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + sourceCredentialSaid: 'Eother', + schemaSaid: 'Eschema', + profile: 'gleif-vrd-isomer-v1', + statusUrl: 'http://status.example/w3c/vc/status/Eother', + vcJwt: 'other.vc.jwt', + state: 'admitted', + }, + ]); + + try { + await expect( + runtime.runWorkflow( + () => + presentCredentialService({ + client, + presenterAlias: 'presenter', + presenterAid: 'Epresenter', + credentialSaid: 'Ecredential', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + }, + timeoutMs: 1000, + pollMs: 1, + }), + { + scope: 'app', + track: false, + kind: 'presentCredential', + } + ) + ).rejects.toThrow( + 'No held W3C credential was found for source credential Ecredential.' + ); + expect(w3cMocks.presentW3CCredential).not.toHaveBeenCalled(); + } finally { + await runtime.destroy(); + } + }); + + it('fails W3C presentation when KERIA reports a failed result', async () => { + const runtime = createAppRuntime({ storage: null }); + const client = {} as unknown as SignifyClient; + w3cMocks.credentials.mockResolvedValue([ + { + credentialId: 'held-id', + holderName: 'presenter', + holderAid: 'Epresenter', + holderDid: 'did:webs:example.com:dws:Epresenter', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + sourceCredentialSaid: 'Ecredential', + schemaSaid: 'Eschema', + profile: 'gleif-vrd-isomer-v1', + statusUrl: 'http://status.example/w3c/vc/status/Ecredential', + vcJwt: 'vc.jwt', + state: 'admitted', + }, + ]); + w3cMocks.presentW3CCredential.mockResolvedValue({ + presentationId: 'presentation-failed', + state: 'failed', + error: 'verifier rejected the presentation', + }); + + try { + await expect( + runtime.runWorkflow( + () => + presentCredentialService({ + client, + presenterAlias: 'presenter', + presenterAid: 'Epresenter', + credentialSaid: 'Ecredential', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + }, + timeoutMs: 1000, + pollMs: 1, + }), + { + scope: 'app', + track: false, + kind: 'presentCredential', + } + ) + ).rejects.toThrow('verifier rejected the presentation'); + } finally { + await runtime.destroy(); + } + }); + + it('rejects W3C presentation without verifier request JSON', async () => { + const runtime = createAppRuntime({ storage: null }); + const client = {} as unknown as SignifyClient; + + try { + await expect( + runtime.runWorkflow( + () => + presentCredentialService({ + client, + presenterAlias: 'presenter', + presenterAid: 'Epresenter', + credentialSaid: 'Ecredential', + verifierRequest: {}, + timeoutMs: 1000, + pollMs: 1, + }), + { + scope: 'app', + track: false, + kind: 'presentCredential', + } + ) + ).rejects.toThrow('Verifier request'); + expect(w3cMocks.credentials).not.toHaveBeenCalled(); + expect(w3cMocks.presentW3CCredential).not.toHaveBeenCalled(); + } finally { + await runtime.destroy(); + } + }); + it('normalizes SEDI voter credential attributes', () => { expect( normalizeSediVoterAttributes({ @@ -340,11 +664,7 @@ describe('credential service helpers', () => { const runtime = createAppRuntime({ storage: null }); const credentials = { list: vi.fn( - async ({ - filter, - }: { - filter: Record; - }) => { + async ({ filter }: { filter: Record }) => { if (filter['-i'] === 'Eissuer') { return [ { diff --git a/tests/unit/pendingState.test.ts b/tests/unit/pendingState.test.ts index f7b69ca2..cfb5dc23 100644 --- a/tests/unit/pendingState.test.ts +++ b/tests/unit/pendingState.test.ts @@ -87,6 +87,26 @@ describe('derivePendingState', () => { }); }); + it('labels W3C issuance fallback submissions', () => { + const fetcher: PendingFetcher = { + state: 'submitting', + formData: formData('startW3CIssuance'), + formAction: '/credentials', + }; + + expect( + derivePendingState({ + navigation: idleNavigation, + fetchers: [fetcher], + connectionStatus: 'connected', + }) + ).toEqual({ + active: true, + label: 'Starting W3C issuance...', + source: 'fetcher', + }); + }); + it('keeps runtime connection pending after router state settles', () => { expect( derivePendingState({ diff --git a/tests/unit/routeData.test.ts b/tests/unit/routeData.test.ts index 6fb5232e..23e8acdb 100644 --- a/tests/unit/routeData.test.ts +++ b/tests/unit/routeData.test.ts @@ -19,6 +19,7 @@ import { rootAction, type RouteDataRuntime, } from '../../src/app/routeData'; +import { appConfig } from '../../src/config'; import { ISSUEABLE_CREDENTIAL_TYPES } from '../../src/config/credentialCatalog'; import type { SignifyClientConfig, @@ -177,6 +178,7 @@ const makeRuntime = (overrides: RuntimeOverrides = {}): RouteDataRuntime => { syncRegistries: vi.fn(async () => ({})), syncIpexActivity: vi.fn(async () => ({})), syncKnownSchemas: vi.fn(async () => ({})), + listW3CVerifiers: vi.fn(async () => []), startResolveSchema: vi.fn(() => ({ status: 'accepted', requestId: 'resolve-schema-request-1', @@ -197,11 +199,21 @@ const makeRuntime = (overrides: RuntimeOverrides = {}): RouteDataRuntime => { requestId: 'grant-credential-request-1', operationRoute: '/operations/grant-credential-request-1', })), + startW3CIssuance: vi.fn(() => ({ + status: 'accepted', + requestId: 'w3c-issuance-request-1', + operationRoute: '/operations/w3c-issuance-request-1', + })), startAdmit: vi.fn(() => ({ status: 'accepted', requestId: 'admit-credential-request-1', operationRoute: '/operations/admit-credential-request-1', })), + startPresent: vi.fn(() => ({ + status: 'accepted', + requestId: 'present-credential-request-1', + operationRoute: '/operations/present-credential-request-1', + })), }, multisig: { getGroupDetails: vi.fn(async (identifier: IdentifierSummary) => @@ -377,6 +389,7 @@ describe('route loaders', () => { await expect(loadDashboard(runtime)).resolves.toEqual({ status: 'ready', + verifiers: appConfig.w3cVerifiers, }); expect(runtime.refreshState).toHaveBeenCalledOnce(); expect(runtime.identifiers.list).toHaveBeenCalledOnce(); @@ -397,6 +410,7 @@ describe('route loaders', () => { await expect(loadDashboard(runtime)).resolves.toEqual({ status: 'ready', + verifiers: appConfig.w3cVerifiers, }); }); @@ -505,6 +519,7 @@ describe('route loaders', () => { await expect(loadCredentials(runtime)).resolves.toEqual({ status: 'ready', + verifiers: appConfig.w3cVerifiers, }); expect(calls[0]).toBe('identifiers'); expect(calls).toEqual( @@ -1336,6 +1351,102 @@ describe('route actions', () => { ); }); + it('starts W3C issuance through the credentials action', async () => { + const runtime = makeRuntime(); + + await expect( + credentialsAction( + runtime, + makeRequest('/credentials', { + intent: 'startW3CIssuance', + requestId: 'w3c-issuance-request-1', + issuerAlias: 'issuer', + issuerAid: 'Eissuer', + credentialSaid: 'Ecredential', + }) + ) + ).resolves.toEqual({ + intent: 'startW3CIssuance', + ok: true, + message: 'Starting W3C issuance for Ecredential', + requestId: 'w3c-issuance-request-1', + operationRoute: '/operations/w3c-issuance-request-1', + }); + expect(runtime.credentials.startW3CIssuance).toHaveBeenCalledWith( + { + issuerAlias: 'issuer', + issuerAid: 'Eissuer', + credentialSaid: 'Ecredential', + }, + expect.objectContaining({ requestId: 'w3c-issuance-request-1' }) + ); + }); + + it('starts W3C credential presentation through the credentials action', async () => { + const runtime = makeRuntime(); + + await expect( + credentialsAction( + runtime, + makeRequest('/credentials', { + intent: 'presentCredential', + requestId: 'present-credential-request-1', + presenterAlias: 'issuer', + presenterAid: 'Eissuer', + credentialSaid: 'Ecredential', + verifierRequest: JSON.stringify({ + aud: 'https://verifier.example', + nonce: 'nonce-1', + }), + }) + ) + ).resolves.toEqual({ + intent: 'presentCredential', + ok: true, + message: 'Presenting credential Ecredential', + requestId: 'present-credential-request-1', + operationRoute: '/operations/present-credential-request-1', + }); + expect(runtime.credentials.startPresent).toHaveBeenCalledWith( + { + presenterAlias: 'issuer', + presenterAid: 'Eissuer', + credentialSaid: 'Ecredential', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + }, + }, + expect.objectContaining({ + requestId: 'present-credential-request-1', + }) + ); + }); + + it('rejects W3C credential presentation without a local presenter', async () => { + const runtime = makeRuntime(); + + await expect( + credentialsAction( + runtime, + makeRequest('/credentials', { + intent: 'presentCredential', + credentialSaid: 'Ecredential', + verifierRequest: JSON.stringify({ + aud: 'https://verifier.example', + nonce: 'nonce-1', + }), + }) + ) + ).resolves.toMatchObject({ + intent: 'presentCredential', + ok: false, + message: + 'Presenter identifier, credential, and verifier request JSON are required.', + }); + expect(runtime.credentials.startPresent).not.toHaveBeenCalled(); + }); + it('rejects malformed challenge word submissions', async () => { const runtime = makeRuntime(); diff --git a/tests/unit/runtimeCommands.test.ts b/tests/unit/runtimeCommands.test.ts index 641604e7..b0123756 100644 --- a/tests/unit/runtimeCommands.test.ts +++ b/tests/unit/runtimeCommands.test.ts @@ -159,8 +159,7 @@ describe('runtime command adapters', () => { resultRoute: { label: 'View identifiers', path: '/identifiers' }, successNotification: { title: 'Delegated rotation complete', - message: - 'The delegator approved the rotation for Edelegate.', + message: 'The delegator approved the rotation for Edelegate.', severity: 'success', }, failureNotification: { @@ -327,12 +326,26 @@ describe('runtime command adapters', () => { holderAid: 'Eholder', credentialSaid: 'Ecredential', }); + commands.startW3CIssuance({ + issuerAlias: 'issuer', + issuerAid: 'Eissuer', + credentialSaid: 'Ecredential', + }); commands.startAdmit({ holderAlias: 'holder', holderAid: 'Eholder', notificationId: 'note-1', grantSaid: 'Egrant', }); + commands.startPresent({ + presenterAlias: 'issuer', + presenterAid: 'Eissuer', + credentialSaid: 'Ecredential', + verifierRequest: { + aud: 'https://verifier.example', + nonce: 'nonce-1', + }, + }); expect(startedOptions[0]).toMatchObject({ label: 'Adding SEDI credential type', @@ -405,6 +418,24 @@ describe('runtime command adapters', () => { }, }); expect(startedOptions[4]).toMatchObject({ + label: 'Starting W3C issuance for Ecredential', + title: 'Start W3C issuance', + kind: 'w3cIssuance', + resourceKeys: ['credential:Ecredential:w3c-issue'], + resultRoute: { label: 'View credentials', path: '/credentials' }, + successNotification: { + title: 'W3C VRD issued', + message: + 'The W3C VC-JWT was issued from the native VRD credential.', + severity: 'success', + }, + failureNotification: { + title: 'W3C issuance failed', + message: 'The W3C VRD could not be issued.', + severity: 'error', + }, + }); + expect(startedOptions[5]).toMatchObject({ label: 'Admitting credential grant Egrant', title: 'Admit credential grant', kind: 'admitCredential', @@ -421,6 +452,25 @@ describe('runtime command adapters', () => { severity: 'error', }, }); + expect(startedOptions[6]).toMatchObject({ + label: 'Presenting credential Ecredential', + title: 'Present credential', + kind: 'presentCredential', + resourceKeys: ['credential:Ecredential:w3c-present'], + resultRoute: { label: 'View credentials', path: '/credentials' }, + successNotification: { + title: 'Credential presented', + message: + 'KERIA recorded the W3C presentation transaction result.', + severity: 'success', + }, + failureNotification: { + title: 'Credential presentation failed', + message: + 'The credential could not be presented through the verifier request.', + severity: 'error', + }, + }); }); it('builds challenge response, request, and verify metadata', () => { @@ -604,7 +654,10 @@ describe('runtime command adapters', () => { label: 'Joining multisig group team', title: 'Join multisig group', kind: 'acceptMultisigInception', - resourceKeys: ['multisig:proposal:Eexn-inception', 'multisig:group:team'], + resourceKeys: [ + 'multisig:proposal:Eexn-inception', + 'multisig:group:team', + ], }); expect(startedOptions[5]).toMatchObject({ label: 'Approving multisig role for team', diff --git a/tests/unit/runtimeWorkflow.test.ts b/tests/unit/runtimeWorkflow.test.ts index da188c21..3a85ede1 100644 --- a/tests/unit/runtimeWorkflow.test.ts +++ b/tests/unit/runtimeWorkflow.test.ts @@ -5,6 +5,7 @@ import { createAppRuntime, type AppRuntime } from '../../src/app/runtime'; import { delegationPayloadDetails, oobiPayloadDetails, + w3cPresentationPayloadDetails, } from '../../src/app/runtimeCommands/payloadDetails'; import type { IdentifierSummary } from '../../src/domain/identifiers/identifierTypes'; import { appNotificationRecorded } from '../../src/state/appNotifications.slice'; @@ -110,6 +111,14 @@ const makeWorkflowClient = ({ }; describe('AppRuntime workflow bridge', () => { + const jwt = (payload: Record): string => { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); + return `${encode({ alg: 'EdDSA', kid: 'kid-1', typ: 'JWT' })}.${encode( + payload + )}.signature`; + }; + it('records successful Effection workflow completion', async () => { const store = createAppStore(); const runtime = createAppRuntime({ store, storage: null }); @@ -468,6 +477,79 @@ describe('AppRuntime workflow bridge', () => { await runtime.destroy(); }); + it('extracts W3C presentation JWT and verifier payload details', () => { + const vcJwt = jwt({ + iss: 'did:webs:issuer', + sub: 'did:webs:holder', + jti: 'urn:said:Ecredential', + vc: { type: ['VerifiableCredential', 'VRDCredential'] }, + }); + const vpJwt = jwt({ + iss: 'did:webs:holder', + jti: 'urn:uuid:presentation', + aud: 'https://verifier.example/verify/vp', + nonce: 'nonce-1', + vp: { + type: ['VerifiablePresentation'], + verifiableCredential: [vcJwt], + }, + }); + + const details = w3cPresentationPayloadDetails({ + presentTxId: 'presentation-1', + state: 'submitted', + submissionState: 'accepted', + submissionEndpoint: 'http://isomer-python:8788/verify/vp', + vpJwt, + vcJwt, + verifierRequest: { + aud: 'https://verifier.example/verify/vp', + nonce: 'nonce-1', + }, + verifierResponse: { + name: 'verifier-op-1', + done: true, + }, + }); + + expect(details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Present Tx', + value: 'presentation-1', + kind: 'text', + }), + expect.objectContaining({ + label: 'Submission Endpoint', + value: 'http://isomer-python:8788/verify/vp', + kind: 'url', + }), + expect.objectContaining({ + label: 'VP-JWT', + value: vpJwt, + kind: 'jwt', + copyable: true, + }), + expect.objectContaining({ + label: 'VC-JWT', + value: vcJwt, + kind: 'jwt', + copyable: true, + }), + expect.objectContaining({ + label: 'Verifier Request', + value: expect.stringContaining('"nonce": "nonce-1"'), + kind: 'json', + }), + expect.objectContaining({ + label: 'Verifier Response', + value: expect.stringContaining('"name": "verifier-op-1"'), + kind: 'json', + }), + ]) + ); + }); + it('shows known aliases before delegated operation AID payload values', async () => { const store = createAppStore(); const runtime = createAppRuntime({ store, storage: null }); diff --git a/tests/unit/w3cArtifactDetails.test.tsx b/tests/unit/w3cArtifactDetails.test.tsx new file mode 100644 index 00000000..cb6e23d8 --- /dev/null +++ b/tests/unit/w3cArtifactDetails.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { W3CJwtArtifactDetails } from '../../src/app/W3CArtifactDetails'; + +const jwt = (payload: Record): string => { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); + return `${encode({ alg: 'EdDSA', kid: 'kid-1', typ: 'JWT' })}.${encode( + payload + )}.signature`; +}; + +describe('W3C artifact details', () => { + it('renders decoded VP-JWT sections and nested VC-JWT access', () => { + const vcJwt = jwt({ + iss: 'did:webs:issuer', + sub: 'did:webs:holder', + jti: 'urn:said:Ecredential', + vc: { type: ['VerifiableCredential', 'VRDCredential'] }, + }); + const vpJwt = jwt({ + iss: 'did:webs:holder', + jti: 'urn:uuid:presentation', + aud: 'https://verifier.example/verify/vp', + nonce: 'nonce-1', + vp: { + type: ['VerifiablePresentation'], + verifiableCredential: [vcJwt], + }, + }); + + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('VP-JWT'); + expect(html).toContain('JOSE header'); + expect(html).toContain('JWT payload'); + expect(html).toContain('Raw compact token'); + expect(html).toContain('Signature segment'); + expect(html).toContain('Nested VC-JWTs (1)'); + expect(html).toContain('aria-expanded="false"'); + }); + + it('renders invalid JWT fallback with raw token access', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('JWT must contain exactly three segments'); + expect(html).toContain('Raw compact token'); + expect(html).toContain('not-a-jwt'); + }); +}); diff --git a/tests/unit/w3cVerifierPresets.test.ts b/tests/unit/w3cVerifierPresets.test.ts new file mode 100644 index 00000000..53961ffd --- /dev/null +++ b/tests/unit/w3cVerifierPresets.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + buildW3CVerifierRequestDescriptor, + buildW3CVerifierRequestJson, + DEFAULT_W3C_VERIFIER_PRESET_ID, + defaultW3CVerifierPreset, + presetById, + verifierPresetForSelection, + type W3CVerifierRequestPreset, +} from '../../src/domain/credentials/w3cVerifierPresets'; + +describe('W3C verifier presets', () => { + it('defaults to the Python Isomer verifier', () => { + const preset = defaultW3CVerifierPreset(); + + expect(DEFAULT_W3C_VERIFIER_PRESET_ID).toBe('isomer-python'); + expect(preset.id).toBe('isomer-python'); + expect(presetById('isomer-node')?.id).toBe('isomer-node'); + expect(verifierPresetForSelection('missing').id).toBe( + 'isomer-python' + ); + }); + + it('builds descriptors with separate audience and KERIA submission URLs', () => { + const preset: W3CVerifierRequestPreset = { + id: 'isomer-python', + label: 'Isomer Python', + publicBaseUrl: 'http://127.0.0.1:8788/', + submissionBaseUrl: 'http://isomer-python:8788/', + verifyVpPath: 'verify/vp', + }; + + expect( + buildW3CVerifierRequestDescriptor({ + preset, + nonce: 'nonce-1', + }) + ).toEqual({ + verifierId: 'isomer-python', + verifierLabel: 'Isomer Python', + verifierOrigin: 'http://127.0.0.1:8788', + origin: 'http://127.0.0.1:8788', + format: 'vp+jwt', + formats: ['vp+jwt'], + client_id: 'http://127.0.0.1:8788/verify/vp', + aud: 'http://127.0.0.1:8788/verify/vp', + nonce: 'nonce-1', + response_uri: 'http://isomer-python:8788/verify/vp', + submissionEndpoint: 'http://isomer-python:8788/verify/vp', + }); + }); + + it('serializes request JSON with a fresh React nonce prefix', () => { + const json = buildW3CVerifierRequestJson({ + preset: defaultW3CVerifierPreset(), + credentialSaid: 'EcredentialSaid', + }); + const descriptor = JSON.parse(json) as Record; + + expect(descriptor.verifierId).toBe('isomer-python'); + expect(descriptor.nonce).toEqual( + expect.stringMatching(/^react-isomer-python-Ecredential/) + ); + }); +});