diff --git a/packages/web/src/index.js b/packages/web/src/index.js index a2283398..a8741944 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,112 @@ 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. + * + * 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({ + * onOfferReceived: offer => wallet.acceptDelegationOffer(offer), + * onCredentialReceived: ({credentials}) => refreshUI(credentials), + * }); + */ + 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. + * 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 +859,18 @@ export { enrollUserWithPasskey, authenticateWithPasskey, initializeCloudWalletWithPasskey, + createDelegationOffer, + createOOBInvitation, + acceptDelegationOffer, + handleMessage, + decodeMessage, + issueCredential, + delegateCredential, + getDelegationDetails, + getDelegationChain, + revokeDelegatableCredential, + unrevokeDelegatableCredential, + isDelegatableCredentialRevoked, }; export default { @@ -759,4 +895,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..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()}, + }; }), })); @@ -75,7 +78,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 +93,39 @@ 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 +429,118 @@ 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('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', + }); + 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, + ); + }); + }); });