From d07607c5a3950ce59e55febf56d740520bc0c216 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 29 Jul 2026 13:54:04 -0300 Subject: [PATCH 1/3] Add delegation support to web wallet --- packages/web/src/index.js | 114 +++++++++++++++++++++++++++++++++ packages/web/src/index.test.js | 107 ++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index a2283398..0dbb040b 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -28,6 +28,24 @@ import {createCredentialProvider} from '@docknetwork/wallet-sdk-core/src/credent import {createDIDProvider} from '@docknetwork/wallet-sdk-core/src/did-provider'; import {createMessageProvider} from '@docknetwork/wallet-sdk-core/src/message-provider'; import {createVerificationController} from '@docknetwork/wallet-sdk-core/src/verification-controller'; +import { + createDelegationOffer, + createOOBInvitation, + acceptDelegationOffer, + handleMessage, + decodeMessage, +} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-offer'; +import { + issueCredential, + delegateCredential, +} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-issuance'; +import {getDelegationDetails} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-policy'; +import {getDelegationChain} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-chain'; +import { + revokeDelegatableCredential, + unrevokeDelegatableCredential, + isDelegatableCredentialRevoked, +} from '@docknetwork/wallet-sdk-core/src/delegation/delegation-revocation'; import {blockchainService} from '@docknetwork/wallet-sdk-wasm/src/services/blockchain'; import { checkPasskeySupport, @@ -637,6 +655,78 @@ async function initialize({ }, }; }, + + /** + * Routes incoming DIDComm delegation messages (offers, credential + * requests, issuance, acks) to the delegation handlers automatically. + * + * Without this, delegation messages must be dispatched manually via + * `handleMessage`. Call once after initialization to participate in + * delegation flows as issuer and/or holder. Listen for the + * `delegationOfferReceived` and `delegatedCredentialReceived` events on + * `wallet.eventManager` to react in the UI. + * + * @returns {Function} Unsubscribe function that stops routing. + * + * @example + * const stop = wallet.enableDelegation(); + * wallet.wallet.eventManager.addListener('delegationOfferReceived', offer => { + * // prompt the user, then accept + * wallet.acceptDelegationOffer(offer); + * }); + */ + enableDelegation: () => + messageProvider.addMessageListener(message => + handleMessage(message, {wallet, messageProvider}), + ), + + /** + * Creates a delegation offer for a role and persists it on the wallet. + * Pass the returned offer to `createOOBInvitation` to produce a shareable + * URL. Works for both root issuance (no `credentialId`) and re-delegation + * (pass the delegatable credential's `credentialId`). + * + * @async + * @param {Object} params - see core createDelegationOffer; `wallet` is bound automatically + * @returns {Promise} The delegation offer + */ + createDelegationOffer: params => + createDelegationOffer({wallet, ...params}), + + /** + * Accepts a received delegation offer, sending a credential request back + * to the issuer. + * + * @async + * @param {Object} delegationOffer - The offer from a `delegationOfferReceived` event + * @returns {Promise} + */ + acceptDelegationOffer: delegationOffer => + acceptDelegationOffer({delegationOffer, wallet, messageProvider}), + + /** + * Resolves the full delegation details for a stored delegatable credential: + * policy, role, remaining delegation depth, chain, and the roles the holder + * may re-delegate to. + * + * @async + * @param {Object} credential - A stored delegatable credential + * @returns {Promise} Delegation details + */ + getDelegationDetails: credential => + getDelegationDetails(credential, wallet), + + /** + * Manually dispatch a DIDComm delegation message (e.g. an OOB invitation + * URL scanned from a QR code) to the delegation handlers. Prefer + * `enableDelegation()` for automatic routing of relayed messages. + * + * @async + * @param {string|Object} message - OOB URL string or DIDComm message object + * @returns {Promise} + */ + handleMessage: message => + handleMessage(message, {wallet, messageProvider}), }; if (passkeyMnemonic) { @@ -735,6 +825,18 @@ export { enrollUserWithPasskey, authenticateWithPasskey, initializeCloudWalletWithPasskey, + createDelegationOffer, + createOOBInvitation, + acceptDelegationOffer, + handleMessage, + decodeMessage, + issueCredential, + delegateCredential, + getDelegationDetails, + getDelegationChain, + revokeDelegatableCredential, + unrevokeDelegatableCredential, + isDelegatableCredentialRevoked, }; export default { @@ -759,4 +861,16 @@ export default { enrollUserWithPasskey, authenticateWithPasskey, initializeCloudWalletWithPasskey, + createDelegationOffer, + createOOBInvitation, + acceptDelegationOffer, + handleMessage, + decodeMessage, + issueCredential, + delegateCredential, + getDelegationDetails, + getDelegationChain, + revokeDelegatableCredential, + unrevokeDelegatableCredential, + isDelegatableCredentialRevoked, }; diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index 9310d9f5..2cc42621 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -75,7 +75,10 @@ jest.mock('@docknetwork/wallet-sdk-core/src/did-provider', () => ({ jest.mock('@docknetwork/wallet-sdk-core/src/message-provider', () => ({ createMessageProvider: jest.fn().mockImplementation(() => { mockCallOrder.push('createMessageProvider'); - return {mockMessageProvider: true}; + return { + mockMessageProvider: true, + addMessageListener: jest.fn().mockReturnValue(() => {}), + }; }), })); @@ -87,6 +90,36 @@ jest.mock('@docknetwork/wallet-sdk-wasm/src/services/blockchain', () => ({ blockchainService: {ensureBlockchainReady: jest.fn()}, })); +jest.mock( + '@docknetwork/wallet-sdk-core/src/delegation/delegation-offer', + () => ({ + createDelegationOffer: jest.fn().mockResolvedValue({id: 'offer-1'}), + createOOBInvitation: jest.fn(), + acceptDelegationOffer: jest.fn().mockResolvedValue(undefined), + handleMessage: jest.fn().mockResolvedValue(undefined), + decodeMessage: jest.fn(), + }), +); +jest.mock( + '@docknetwork/wallet-sdk-core/src/delegation/delegation-issuance', + () => ({issueCredential: jest.fn(), delegateCredential: jest.fn()}), +); +jest.mock( + '@docknetwork/wallet-sdk-core/src/delegation/delegation-policy', + () => ({getDelegationDetails: jest.fn().mockResolvedValue({role: {}})}), +); +jest.mock('@docknetwork/wallet-sdk-core/src/delegation/delegation-chain', () => ({ + getDelegationChain: jest.fn(), +})); +jest.mock( + '@docknetwork/wallet-sdk-core/src/delegation/delegation-revocation', + () => ({ + revokeDelegatableCredential: jest.fn(), + unrevokeDelegatableCredential: jest.fn(), + isDelegatableCredentialRevoked: jest.fn(), + }), +); + const { initializeCloudWallet, } = require('@docknetwork/wallet-sdk-core/src/cloud-wallet'); @@ -390,4 +423,76 @@ describe('WalletSDK initialize', () => { ); }); }); + + describe('delegation', () => { + const { + createDelegationOffer, + acceptDelegationOffer, + handleMessage, + } = require('@docknetwork/wallet-sdk-core/src/delegation/delegation-offer'); + const { + getDelegationDetails, + } = require('@docknetwork/wallet-sdk-core/src/delegation/delegation-policy'); + const config = { + edvUrl: 'https://edv.example.com', + edvAuthKey: 'auth-key', + masterKey: new Uint8Array([1, 2, 3]), + networkId: 'testnet', + }; + + it('re-exports delegation functions', () => { + [ + 'createDelegationOffer', + 'createOOBInvitation', + 'acceptDelegationOffer', + 'handleMessage', + 'decodeMessage', + 'issueCredential', + 'delegateCredential', + 'getDelegationDetails', + 'getDelegationChain', + 'revokeDelegatableCredential', + 'unrevokeDelegatableCredential', + 'isDelegatableCredentialRevoked', + ].forEach(name => expect(typeof WalletSDK[name]).toBe('function')); + }); + + it('enableDelegation registers a message listener that routes to handleMessage', async () => { + const wallet = await WalletSDK.initialize(config); + const stop = wallet.enableDelegation(); + + expect(wallet.messageProvider.addMessageListener).toHaveBeenCalledTimes(1); + // Invoke the registered listener and confirm it delegates to handleMessage. + const listener = + wallet.messageProvider.addMessageListener.mock.calls[0][0]; + await listener('some-oob-url'); + expect(handleMessage).toHaveBeenCalledWith('some-oob-url', { + wallet: wallet.wallet, + messageProvider: wallet.messageProvider, + }); + expect(typeof stop).toBe('function'); + }); + + it('binds wallet/messageProvider to the delegation methods', async () => { + const wallet = await WalletSDK.initialize(config); + + await wallet.createDelegationOffer({issuerDID: 'did:x', delegationRole: 'r'}); + expect(createDelegationOffer).toHaveBeenCalledWith( + expect.objectContaining({wallet: wallet.wallet, issuerDID: 'did:x'}), + ); + + await wallet.acceptDelegationOffer({id: 'offer-1'}); + expect(acceptDelegationOffer).toHaveBeenCalledWith({ + delegationOffer: {id: 'offer-1'}, + wallet: wallet.wallet, + messageProvider: wallet.messageProvider, + }); + + await wallet.getDelegationDetails({id: 'cred-1'}); + expect(getDelegationDetails).toHaveBeenCalledWith( + {id: 'cred-1'}, + wallet.wallet, + ); + }); + }); }); From ff1c91fb63005e4d73617a54ff496d7de8285525 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 29 Jul 2026 14:48:22 -0300 Subject: [PATCH 2/3] address PR comments --- packages/web/src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index 0dbb040b..dc550472 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -664,7 +664,7 @@ async function initialize({ * `handleMessage`. Call once after initialization to participate in * delegation flows as issuer and/or holder. Listen for the * `delegationOfferReceived` and `delegatedCredentialReceived` events on - * `wallet.eventManager` to react in the UI. + * `wallet.wallet.eventManager` to react in the UI. * * @returns {Function} Unsubscribe function that stops routing. * @@ -690,7 +690,7 @@ async function initialize({ * @param {Object} params - see core createDelegationOffer; `wallet` is bound automatically * @returns {Promise} The delegation offer */ - createDelegationOffer: params => + createDelegationOffer: (params = {}) => createDelegationOffer({wallet, ...params}), /** From b21f2a7acbcf4ade0984d9360f0ff84150bbc6b8 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 29 Jul 2026 15:11:47 -0300 Subject: [PATCH 3/3] address PR comments --- packages/web/src/index.js | 62 ++++++++++++++++++++++++++-------- packages/web/src/index.test.js | 60 ++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index dc550472..a8741944 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -662,23 +662,58 @@ async function initialize({ * * Without this, delegation messages must be dispatched manually via * `handleMessage`. Call once after initialization to participate in - * delegation flows as issuer and/or holder. Listen for the - * `delegationOfferReceived` and `delegatedCredentialReceived` events on - * `wallet.wallet.eventManager` to react in the UI. + * delegation flows as issuer and/or holder. * - * @returns {Function} Unsubscribe function that stops routing. + * Pass optional callbacks to react in the UI without reaching into the + * core wallet's event manager: + * - `onOfferReceived(offer)` — a delegation offer arrived (accept it with + * `acceptDelegationOffer(offer)`) + * - `onCredentialReceived(payload)` — a delegated credential was issued + * and stored + * + * @param {Object} [handlers] + * @param {Function} [handlers.onOfferReceived] + * @param {Function} [handlers.onCredentialReceived] + * @returns {Function} Unsubscribe function that stops routing and removes + * the event listeners. * * @example - * const stop = wallet.enableDelegation(); - * wallet.wallet.eventManager.addListener('delegationOfferReceived', offer => { - * // prompt the user, then accept - * wallet.acceptDelegationOffer(offer); + * const stop = wallet.enableDelegation({ + * onOfferReceived: offer => wallet.acceptDelegationOffer(offer), + * onCredentialReceived: ({credentials}) => refreshUI(credentials), * }); */ - enableDelegation: () => - messageProvider.addMessageListener(message => - handleMessage(message, {wallet, messageProvider}), - ), + enableDelegation: ({onOfferReceived, onCredentialReceived} = {}) => { + const removeMessageListener = messageProvider.addMessageListener( + message => handleMessage(message, {wallet, messageProvider}), + ); + + const subscriptions = []; + if (onOfferReceived) { + wallet.eventManager.addListener( + 'delegationOfferReceived', + onOfferReceived, + ); + subscriptions.push(['delegationOfferReceived', onOfferReceived]); + } + if (onCredentialReceived) { + wallet.eventManager.addListener( + 'delegatedCredentialReceived', + onCredentialReceived, + ); + subscriptions.push([ + 'delegatedCredentialReceived', + onCredentialReceived, + ]); + } + + return () => { + removeMessageListener(); + subscriptions.forEach(([event, handler]) => + wallet.eventManager.removeListener(event, handler), + ); + }; + }, /** * Creates a delegation offer for a role and persists it on the wallet. @@ -725,8 +760,7 @@ async function initialize({ * @param {string|Object} message - OOB URL string or DIDComm message object * @returns {Promise} */ - handleMessage: message => - handleMessage(message, {wallet, messageProvider}), + handleMessage: message => handleMessage(message, {wallet, messageProvider}), }; if (passkeyMnemonic) { diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index 2cc42621..b2bf4a3b 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -54,7 +54,10 @@ jest.mock('./passkey', () => ({ jest.mock('@docknetwork/wallet-sdk-core/src/wallet', () => ({ createWallet: jest.fn().mockImplementation(async () => { mockCallOrder.push('createWallet'); - return {mockWallet: true}; + return { + mockWallet: true, + eventManager: {addListener: jest.fn(), removeListener: jest.fn()}, + }; }), })); @@ -108,9 +111,12 @@ jest.mock( '@docknetwork/wallet-sdk-core/src/delegation/delegation-policy', () => ({getDelegationDetails: jest.fn().mockResolvedValue({role: {}})}), ); -jest.mock('@docknetwork/wallet-sdk-core/src/delegation/delegation-chain', () => ({ - getDelegationChain: jest.fn(), -})); +jest.mock( + '@docknetwork/wallet-sdk-core/src/delegation/delegation-chain', + () => ({ + getDelegationChain: jest.fn(), + }), +); jest.mock( '@docknetwork/wallet-sdk-core/src/delegation/delegation-revocation', () => ({ @@ -461,7 +467,9 @@ describe('WalletSDK initialize', () => { const wallet = await WalletSDK.initialize(config); const stop = wallet.enableDelegation(); - expect(wallet.messageProvider.addMessageListener).toHaveBeenCalledTimes(1); + expect(wallet.messageProvider.addMessageListener).toHaveBeenCalledTimes( + 1, + ); // Invoke the registered listener and confirm it delegates to handleMessage. const listener = wallet.messageProvider.addMessageListener.mock.calls[0][0]; @@ -473,10 +481,50 @@ describe('WalletSDK initialize', () => { expect(typeof stop).toBe('function'); }); + it('enableDelegation subscribes callbacks to wallet events and unsubscribes on stop', async () => { + const wallet = await WalletSDK.initialize(config); + const onOfferReceived = jest.fn(); + const onCredentialReceived = jest.fn(); + + const removeMessageListener = jest.fn(); + wallet.messageProvider.addMessageListener.mockReturnValueOnce( + removeMessageListener, + ); + + const stop = wallet.enableDelegation({ + onOfferReceived, + onCredentialReceived, + }); + + expect(wallet.wallet.eventManager.addListener).toHaveBeenCalledWith( + 'delegationOfferReceived', + onOfferReceived, + ); + expect(wallet.wallet.eventManager.addListener).toHaveBeenCalledWith( + 'delegatedCredentialReceived', + onCredentialReceived, + ); + + stop(); + + expect(removeMessageListener).toHaveBeenCalledTimes(1); + expect(wallet.wallet.eventManager.removeListener).toHaveBeenCalledWith( + 'delegationOfferReceived', + onOfferReceived, + ); + expect(wallet.wallet.eventManager.removeListener).toHaveBeenCalledWith( + 'delegatedCredentialReceived', + onCredentialReceived, + ); + }); + it('binds wallet/messageProvider to the delegation methods', async () => { const wallet = await WalletSDK.initialize(config); - await wallet.createDelegationOffer({issuerDID: 'did:x', delegationRole: 'r'}); + await wallet.createDelegationOffer({ + issuerDID: 'did:x', + delegationRole: 'r', + }); expect(createDelegationOffer).toHaveBeenCalledWith( expect.objectContaining({wallet: wallet.wallet, issuerDID: 'did:x'}), );