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 }) => (
+ }
+ onClick={() => copyValue(value)}
+ >
+ Copy {label}
+
+);
+
+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()}
+ >
+
+ }
+ disabled={blocker !== null}
+ data-testid="w3c-start-issuance-button"
+ data-credential-said={credential.said}
+ data-ui-sound={UI_SOUND_HOVER_VALUE}
+ onClick={() => {
+ if (blocker === null && issuer !== null) {
+ onStartIssuance(credential, issuer);
+ }
+ }}
+ >
+ Start W3C issuance
+
+
+
+ {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
+
+
+
+
+ }
+ disabled={blockerMsg !== null}
+ data-testid="w3c-present-button"
+ data-credential-said={credential.said}
+ data-ui-sound={UI_SOUND_HOVER_VALUE}
+ onClick={presentW3C}
+ >
+ Present
+
+
+
+ {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/)
+ );
+ });
+});