diff --git a/package.json b/package.json index cdfaad9e..f0694328 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "responsive:smoke": "node tests/responsive-smoke.mjs", "unit:test": "vitest run tests/unit", "scenario:test": "vitest run tests/scenarios/salty.test.ts tests/scenarios/randy.test.ts tests/scenarios/witnessed.test.ts tests/scenarios/challenge.test.ts tests/scenarios/controller-rotation.test.ts tests/scenarios/oobi-contacts.test.ts tests/scenarios/credentials.test.ts", + "delegation:test": "vitest run tests/scenarios/optional/delegation.test.ts", "scenario:test:all": "vitest run tests/scenarios", "test:ci": "pnpm lint && pnpm build && pnpm unit:test && pnpm responsive:smoke && pnpm keria:smoke -- --mode connect && pnpm keria:smoke && pnpm scenario:test && pnpm contact:ui-smoke && pnpm browser:smoke" }, diff --git a/src/app/PayloadDetails.tsx b/src/app/PayloadDetails.tsx index 2bc6207d..ce0fac18 100644 --- a/src/app/PayloadDetails.tsx +++ b/src/app/PayloadDetails.tsx @@ -45,7 +45,7 @@ export const PayloadDetails = ({ title={ detail.copyable ? `Copy ${detail.label}` - : detail.value + : (detail.displayValue ?? detail.value) } > - {abbreviate(detail.value, maxLength)} + {abbreviate( + detail.displayValue ?? detail.value, + maxLength + )} {detail.copyable && ( { const credentialGrants = useAppSelector( selectActionableCredentialGrantNotifications ); + const delegationRequests = useAppSelector( + selectActionableDelegationRequestNotifications + ); const identifiers = useAppSelector(selectIdentifiers); const connectDialogOpen = connectOpen && connection.status !== 'connected'; const pending = derivePendingState({ @@ -75,11 +79,13 @@ const RootLayoutContent = () => { recentNotifications={appNotifications} challengeRequests={challengeRequests} credentialGrants={credentialGrants} + delegationRequests={delegationRequests} identifiers={identifiers} unreadNotificationCount={ unreadAppNotifications.length + challengeRequests.length + - credentialGrants.length + credentialGrants.length + + delegationRequests.length } onMenuClick={() => setDrawerOpen(true)} onConnectClick={() => setConnectOpen(true)} diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx index 232ce8ad..1b8ab710 100644 --- a/src/app/TopBar.tsx +++ b/src/app/TopBar.tsx @@ -41,6 +41,7 @@ import type { AppNotificationRecord } from '../state/appNotifications.slice'; import type { ChallengeRequestNotification, CredentialGrantNotification, + DelegationRequestNotification, } from '../state/notifications.slice'; import type { OperationRecord } from '../state/operations.slice'; import type { IdentifierSummary } from '../features/identifiers/identifierTypes'; @@ -78,6 +79,8 @@ export interface TopBarProps { challengeRequests: readonly ChallengeRequestNotification[]; /** Actionable credential grants discovered from KERIA notifications. */ credentialGrants: readonly CredentialGrantNotification[]; + /** Actionable delegation requests discovered from KERIA notifications. */ + delegationRequests: readonly DelegationRequestNotification[]; /** Local identifiers available for responding to challenge requests. */ identifiers: readonly IdentifierSummary[]; /** Number of unread app notifications plus actionable challenge requests. */ @@ -101,6 +104,7 @@ export const TopBar = ({ recentNotifications, challengeRequests, credentialGrants, + delegationRequests, identifiers, unreadNotificationCount, onMenuClick, @@ -127,6 +131,7 @@ export const TopBar = ({ [recentNotifications] ); const credentialFetcher = useFetcher(); + const delegationFetcher = useFetcher(); const visibleChallengeRequests = useMemo( () => challengeRequests.slice(0, 3), [challengeRequests] @@ -135,6 +140,10 @@ export const TopBar = ({ () => credentialGrants.slice(0, 3), [credentialGrants] ); + const visibleDelegationRequests = useMemo( + () => delegationRequests.slice(0, 3), + [delegationRequests] + ); useEffect(() => { if (!notificationsOpen || unreadNotificationCount === 0) { @@ -229,6 +238,34 @@ export const TopBar = ({ setNotificationsAnchor(null); }; + const approveDelegationRequest = ( + request: DelegationRequestNotification + ) => { + const delegator = identifiers.find( + (identifier) => identifier.prefix === request.delegatorAid + ); + if (delegator === undefined) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'approveDelegationRequest'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', request.notificationId); + formData.set('delegatorName', delegator.name); + formData.set('delegatorAid', request.delegatorAid); + formData.set('delegateAid', request.delegateAid); + formData.set('delegateEventSaid', request.delegateEventSaid); + formData.set('sequence', request.sequence); + formData.set('sourceAid', request.sourceAid ?? ''); + formData.set('createdAt', request.createdAt); + delegationFetcher.submit(formData, { + method: 'post', + action: '/notifications', + }); + setNotificationsAnchor(null); + }; + return ( {identifier.name} /{' '} - {abbreviateMiddle(identifier.prefix, 18)} + {abbreviateMiddle( + identifier.prefix, + 18 + )} ))} @@ -345,7 +385,8 @@ export const TopBar = ({ renderValue={(value) => { const registry = readyRegistries.find( - (candidate) => candidate.id === value + (candidate) => + candidate.id === value ) ?? selectedRegistry; return `Registry: ${registry.registryName}`; }} @@ -402,11 +443,7 @@ export const TopBar = ({ data-ui-sound={UI_SOUND_HOVER_VALUE} onClick={toggleHoverSoundMuted} > - {hoverSoundMuted ? ( - - ) : ( - - )} + {hoverSoundMuted ? : } @@ -567,13 +604,188 @@ export const TopBar = ({ {recentNotifications.length === 0 && visibleChallengeRequests.length === 0 && - visibleCredentialGrants.length === 0 ? ( + visibleCredentialGrants.length === 0 && + visibleDelegationRequests.length === 0 ? ( ) : ( <> + {visibleDelegationRequests.map((request) => { + const delegator = identifiers.find( + (identifier) => + identifier.prefix === + request.delegatorAid + ); + const canApprove = + delegator !== undefined && + delegationFetcher.state === 'idle'; + + return ( + + + + + Delegation request + + + Delegator{' '} + {delegator === undefined + ? abbreviateMiddle( + request.delegatorAid, + 28 + ) + : `${delegator.name} / ${abbreviateMiddle( + request.delegatorAid, + 18 + )}`} + + + Delegate{' '} + {abbreviateMiddle( + request.delegateAid, + 28 + )} + + + Event{' '} + {abbreviateMiddle( + request.delegateEventSaid, + 28 + )}{' '} + seq {request.sequence} + + {formatTimestamp( + request.createdAt + ) !== null && ( + + {formatTimestamp( + request.createdAt + )} + + )} + + + + + + + + ); + })} {visibleChallengeRequests.map((request) => ( 0 || + visibleDelegationRequests.length > 0 || visibleCredentialGrants.length > 0) && visibleNotifications.length > 0 && ( diff --git a/src/app/routeData.ts b/src/app/routeData.ts index 3294a6b6..283edbaf 100644 --- a/src/app/routeData.ts +++ b/src/app/routeData.ts @@ -21,6 +21,7 @@ import type { VerifyContactChallengeInput, } from '../workflows/challenges.op'; import type { DismissExchangeNotificationInput } from '../workflows/notifications.op'; +import type { ApproveDelegationInput } from '../workflows/delegations.op'; import type { AdmitCredentialGrantInput, CreateCredentialRegistryInput, @@ -139,6 +140,7 @@ export type ContactActionData = | 'respondChallenge' | 'verifyChallenge' | 'dismissExchangeNotification' + | 'approveDelegationRequest' | 'delete' | 'updateAlias'; ok: true; @@ -162,6 +164,7 @@ export type ContactActionData = | 'respondChallenge' | 'verifyChallenge' | 'dismissExchangeNotification' + | 'approveDelegationRequest' | 'delete' | 'updateAlias' | 'unsupported'; @@ -321,6 +324,11 @@ export interface RouteDataRuntime { input: DismissExchangeNotificationInput, options?: { signal?: AbortSignal; requestId?: string } ): Promise; + /** Start manual delegation approval in the background. */ + startApproveDelegation( + input: ApproveDelegationInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; /** Start adding the SEDI credential schema type in the background. */ startResolveCredentialSchema( input: ResolveCredentialSchemaInput, @@ -391,6 +399,7 @@ const contactIntentFromString = ( value === 'respondChallenge' || value === 'verifyChallenge' || value === 'dismissExchangeNotification' || + value === 'approveDelegationRequest' || value === 'delete' || value === 'updateAlias' ? value @@ -1094,6 +1103,80 @@ export const contactsAction = async ( }; } + if (intent === 'approveDelegationRequest') { + const notificationId = formString( + formData, + 'notificationId' + ).trim(); + const delegatorName = formString(formData, 'delegatorName').trim(); + const delegatorAid = formString(formData, 'delegatorAid').trim(); + const delegateAid = formString(formData, 'delegateAid').trim(); + const delegateEventSaid = formString( + formData, + 'delegateEventSaid' + ).trim(); + const sequence = formString(formData, 'sequence').trim(); + const sourceAid = formString(formData, 'sourceAid').trim(); + const createdAt = formString(formData, 'createdAt').trim(); + if ( + notificationId.length === 0 || + delegatorName.length === 0 || + delegatorAid.length === 0 || + delegateAid.length === 0 || + delegateEventSaid.length === 0 || + sequence.length === 0 || + createdAt.length === 0 + ) { + return { + intent, + ok: false, + message: + 'Notification id, delegator, delegate event, sequence, and request time are required.', + requestId, + }; + } + + const started = runtime.startApproveDelegation( + { + notificationId, + delegatorName, + request: { + notificationId, + delegatorAid, + delegateAid, + delegateEventSaid, + sequence, + anchor: { + i: delegateAid, + s: sequence, + d: delegateEventSaid, + }, + sourceAid: sourceAid.length > 0 ? sourceAid : null, + createdAt, + status: 'actionable', + }, + }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Approving delegation for ${delegateAid}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + if (intent === 'delete') { const contactId = formString(formData, 'contactId').trim(); if (contactId.length === 0) { @@ -1215,7 +1298,9 @@ export const credentialsAction = async ( runtime.syncCredentialRegistries({ signal: request.signal }), runtime.syncCredentialInventory({ signal: request.signal }), ]); - await runtime.syncCredentialIpexActivity({ signal: request.signal }); + await runtime.syncCredentialIpexActivity({ + signal: request.signal, + }); return { intent, ok: true, diff --git a/src/app/runtime.ts b/src/app/runtime.ts index 95397988..54f566a5 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -6,6 +6,7 @@ import { AppEffectionScopes, type RuntimeScopeKind } from '../effects/scope'; import { aliasForOobiResolution } from '../features/contacts/contactHelpers'; import type { IdentifierCreateDraft, + IdentifierDelegationChainNode, IdentifierSummary, } from '../features/identifiers/identifierTypes'; import type { ResolveContactInput } from '../services/contacts.service'; @@ -53,11 +54,18 @@ import { sessionDisconnected, sessionStateRefreshed, } from '../state/session.slice'; -import { appStore, type AppStore } from '../state/store'; +import { appStore, type AppStore, type RootState } from '../state/store'; +import { + identifierDelegatorAid, + isDelegatedIdentifier, +} from '../features/identifiers/delegationHelpers'; import { createIdentifierOp, + createIdentifierBackgroundOp, + getIdentifierDelegationChainOp, getIdentifierOp, listIdentifiersOp, + rotateIdentifierBackgroundOp, rotateIdentifierOp, } from '../workflows/identifiers.op'; import { @@ -92,6 +100,10 @@ import { dismissExchangeNotificationOp, type DismissExchangeNotificationInput, } from '../workflows/notifications.op'; +import { + approveDelegationRequestOp, + type ApproveDelegationInput, +} from '../workflows/delegations.op'; import { admitCredentialGrantOp, createCredentialRegistryOp, @@ -296,8 +308,40 @@ const stringArray = (value: unknown): string[] => const detailId = (label: string, index: number): string => `${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${index}`; +const aliasForAid = (state: RootState, aid: string): string | null => { + const localAlias = state.identifiers.byPrefix[aid]?.name?.trim(); + if (localAlias !== undefined && localAlias.length > 0) { + return localAlias === aid ? null : localAlias; + } + + for (const contactId of state.contacts.ids) { + const contact = state.contacts.byId[contactId]; + if (contact?.aid === aid) { + const contactAlias = contact.alias.trim(); + return contactAlias.length > 0 && contactAlias !== aid + ? contactAlias + : null; + } + } + + return null; +}; + +const aidDisplayValue = ( + state: RootState | null, + aid: string +): string | undefined => { + if (state === null) { + return undefined; + } + + const alias = aliasForAid(state, aid); + return alias === null ? undefined : `${alias} (${aid})`; +}; + const payloadDetailsFromWorkflowResult = ( - result: unknown + result: unknown, + state: RootState | null = null ): PayloadDetailRecord[] => { if (!isRecord(result)) { return []; @@ -348,6 +392,63 @@ const payloadDetailsFromWorkflowResult = ( }); } + const delegation = isRecord(result.delegation) ? result.delegation : result; + if (isRecord(delegation)) { + const delegatorAid = stringValue(delegation.delegatorAid); + const delegateAid = stringValue(delegation.delegateAid); + const delegateEventSaid = stringValue(delegation.delegateEventSaid); + const sequence = stringValue(delegation.sequence); + const requestedAt = stringValue(delegation.requestedAt); + + if (delegatorAid !== null) { + details.push({ + id: detailId('delegator-aid', details.length), + label: 'Delegator AID', + value: delegatorAid, + displayValue: aidDisplayValue(state, delegatorAid), + kind: 'aid', + copyable: true, + }); + } + if (delegateAid !== null) { + details.push({ + id: detailId('delegate-aid', details.length), + label: 'Delegate AID', + value: delegateAid, + displayValue: aidDisplayValue(state, delegateAid), + kind: 'aid', + copyable: true, + }); + } + if (delegateEventSaid !== null) { + details.push({ + id: detailId('delegate-event-said', details.length), + label: 'Delegate Event SAID', + value: delegateEventSaid, + kind: 'text', + copyable: true, + }); + } + if (sequence !== null) { + details.push({ + id: detailId('delegation-sequence', details.length), + label: 'Delegation Sequence', + value: sequence, + kind: 'text', + copyable: true, + }); + } + if (requestedAt !== null) { + details.push({ + id: detailId('delegation-requested-at', details.length), + label: 'Request Time', + value: requestedAt, + kind: 'text', + copyable: true, + }); + } + } + const seen = new Set(); return details.filter((detail) => { const key = `${detail.label}:${detail.value}`; @@ -656,6 +757,20 @@ export class AppRuntime { track: options.track ?? false, }); + /** + * Resolve an identifier's delegation chain without recording history. + */ + getIdentifierDelegationChain = async ( + aid: string, + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => getIdentifierDelegationChainOp(aid), { + ...options, + label: options.label, + kind: options.kind ?? 'listIdentifiers', + track: options.track ?? false, + }); + /** * Fetch an OOBI for one managed identifier role without recording an * operation-history item by default. Agent OOBIs still authorize the agent @@ -803,26 +918,58 @@ export class AppRuntime { options: Pick = {} ): BackgroundWorkflowStartResult => { const name = draft.name.trim(); - return this.startBackgroundWorkflow(() => createIdentifierOp(draft), { - requestId: options.requestId, - label: `Creating identifier ${name}`, - title: `Create identifier ${name}`, - description: - 'Creates a managed identifier and waits for KERIA completion.', - kind: 'createIdentifier', - resourceKeys: [`identifier:name:${name}`], - resultRoute: { label: 'View identifiers', path: '/identifiers' }, - successNotification: { - title: `Identifier ${name} created`, - message: 'The identifier operation completed successfully.', - severity: 'success', - }, - failureNotification: { - title: `Identifier ${name} failed`, - message: 'The identifier operation failed.', - severity: 'error', - }, - }); + const delegated = draft.delegation.mode === 'delegated'; + const requestId = options.requestId ?? createRequestId(); + const delegatorAid = + draft.delegation.mode === 'delegated' + ? draft.delegation.delegatorAid.trim() + : null; + + return this.startBackgroundWorkflow( + () => createIdentifierBackgroundOp(draft, requestId), + { + requestId, + label: `Creating identifier ${name}`, + title: delegated + ? `Create delegated identifier ${name}` + : `Create identifier ${name}`, + description: + delegated && delegatorAid !== null + ? `Creates a delegated identifier and waits for manual approval from ${delegatorAid}.` + : 'Creates a managed identifier and waits for KERIA completion.', + kind: delegated + ? 'createDelegatedIdentifier' + : 'createIdentifier', + resourceKeys: [ + `identifier:name:${name}`, + ...(delegatorAid === null + ? [] + : [ + `delegation:delegator:${delegatorAid}:name:${name}`, + ]), + ], + resultRoute: { + label: 'View identifiers', + path: '/identifiers', + }, + successNotification: { + title: delegated + ? `Delegated identifier ${name} created` + : `Identifier ${name} created`, + message: delegated + ? 'The delegator approved the request and the identifier is available.' + : 'The identifier operation completed successfully.', + severity: 'success', + }, + failureNotification: { + title: delegated + ? `Delegated identifier ${name} failed` + : `Identifier ${name} failed`, + message: 'The identifier operation failed.', + severity: 'error', + }, + } + ); }; /** @@ -831,27 +978,66 @@ export class AppRuntime { startRotateIdentifier = ( aid: string, options: Pick = {} - ): BackgroundWorkflowStartResult => - this.startBackgroundWorkflow(() => rotateIdentifierOp(aid), { - requestId: options.requestId, - label: `Rotating identifier ${aid}`, - title: `Rotate identifier ${aid}`, - description: - 'Rotates a managed identifier and waits for KERIA completion.', - kind: 'rotateIdentifier', - resourceKeys: [`identifier:aid:${aid}`], - resultRoute: { label: 'View identifiers', path: '/identifiers' }, - successNotification: { - title: 'Identifier rotation complete', - message: `The rotation for ${aid} completed successfully.`, - severity: 'success', - }, - failureNotification: { - title: 'Identifier rotation failed', - message: `The rotation for ${aid} failed.`, - severity: 'error', - }, - }); + ): BackgroundWorkflowStartResult => { + const state = this.store.getState(); + const identifier = + state.identifiers.byPrefix[aid] ?? + state.identifiers.prefixes + .map((prefix) => state.identifiers.byPrefix[prefix]) + .find( + (candidate) => + candidate !== undefined && + (candidate.name === aid || candidate.prefix === aid) + ) ?? + null; + const delegated = isDelegatedIdentifier(identifier); + const delegatorAid = identifierDelegatorAid(identifier); + const requestId = options.requestId ?? createRequestId(); + + return this.startBackgroundWorkflow( + () => rotateIdentifierBackgroundOp(aid, requestId), + { + requestId, + label: `Rotating identifier ${aid}`, + title: delegated + ? `Rotate delegated identifier ${aid}` + : `Rotate identifier ${aid}`, + description: + delegated && delegatorAid !== null + ? `Rotates a delegated identifier and waits for manual approval from ${delegatorAid}.` + : 'Rotates a managed identifier and waits for KERIA completion.', + kind: delegated + ? 'rotateDelegatedIdentifier' + : 'rotateIdentifier', + resourceKeys: [ + `identifier:aid:${aid}`, + ...(delegatorAid === null + ? [] + : [`delegation:delegate:${aid}`]), + ], + resultRoute: { + label: 'View identifiers', + path: '/identifiers', + }, + successNotification: { + title: delegated + ? 'Delegated rotation complete' + : 'Identifier rotation complete', + message: delegated + ? `The delegator approved the rotation for ${aid}.` + : `The rotation for ${aid} completed successfully.`, + severity: 'success', + }, + failureNotification: { + title: delegated + ? 'Delegated rotation failed' + : 'Identifier rotation failed', + message: `The rotation for ${aid} failed.`, + severity: 'error', + }, + } + ); + }; /** * Launch OOBI generation/authorization without blocking navigation. @@ -1105,6 +1291,40 @@ export class AppRuntime { track: false, }); + /** + * Launch manual delegator approval as background work. + */ + startApproveDelegation = ( + input: ApproveDelegationInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => approveDelegationRequestOp(input), { + requestId: options.requestId, + label: `Approving delegation for ${input.request.delegateAid}`, + title: 'Approve delegation', + description: + 'Creates the delegator anchor event and refreshes protocol notifications.', + kind: 'approveDelegation', + resourceKeys: [ + `delegation:approval:${input.notificationId}`, + `delegation:delegate:${input.request.delegateAid}`, + ], + resultRoute: { + label: 'View notifications', + path: '/notifications', + }, + successNotification: { + title: 'Delegation approved', + message: `Approved delegation for ${input.request.delegateAid}.`, + severity: 'success', + }, + failureNotification: { + title: 'Delegation approval failed', + message: `Delegation approval for ${input.request.delegateAid} failed.`, + severity: 'error', + }, + }); + /** * Launch SEDI schema OOBI resolution as background work. */ @@ -1123,7 +1343,8 @@ export class AppRuntime { resultRoute: { label: 'View credentials', path: '/credentials' }, successNotification: { title: 'Credential type added', - message: 'The SEDI credential type is available to this wallet.', + message: + 'The SEDI credential type is available to this wallet.', severity: 'success', }, failureNotification: { @@ -1156,7 +1377,8 @@ export class AppRuntime { }, failureNotification: { title: 'Credential registry failed', - message: 'The issuer credential registry could not be prepared.', + message: + 'The issuer credential registry could not be prepared.', severity: 'error', }, }); @@ -1390,7 +1612,10 @@ export class AppRuntime { ): Promise => { try { const result = await task; - const payloadDetails = payloadDetailsFromWorkflowResult(result); + const payloadDetails = payloadDetailsFromWorkflowResult( + result, + this.store.getState() + ); if (payloadDetails.length > 0) { this.store.dispatch( operationPayloadDetailsRecorded({ diff --git a/src/config.ts b/src/config.ts index 2bf39743..83735463 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,8 @@ export interface KeriaConfig { export interface OperationConfig { /** Upper bound for a single KERIA operation wait. */ timeoutMs: number; + /** Upper bound for manual delegated identifier approval waits. */ + delegationApprovalTimeoutMs: number; /** Initial polling interval passed to Signify's operation waiter. */ minSleepMs: number; /** Maximum polling interval passed to Signify's operation waiter. */ @@ -294,6 +296,11 @@ export const buildAppConfig = (runtimeEnv: RuntimeEnv): AppConfig => { runtimeEnv.VITE_OPERATION_TIMEOUT_MS, 30000 ), + delegationApprovalTimeoutMs: numberFromEnv( + 'VITE_DELEGATION_APPROVAL_TIMEOUT_MS', + runtimeEnv.VITE_DELEGATION_APPROVAL_TIMEOUT_MS, + 300000 + ), minSleepMs: numberFromEnv( 'VITE_OPERATION_MIN_SLEEP_MS', runtimeEnv.VITE_OPERATION_MIN_SLEEP_MS, @@ -357,7 +364,9 @@ export const buildAppConfig = (runtimeEnv: RuntimeEnv): AppConfig => { optionalString( runtimeEnv.VITE_SEDI_VOTER_ID_SCHEMA_OOBI_URL ) ?? - optionalString(runtimeEnv.VITE_CREDENTIAL_SCHEMA_OOBI_URL) ?? + optionalString( + runtimeEnv.VITE_CREDENTIAL_SCHEMA_OOBI_URL + ) ?? `${DEFAULT_SCHEMA_SERVER_URL}/oobi/${DEFAULT_SEDI_VOTER_ID_SCHEMA_SAID}`, }, }, diff --git a/src/features/contacts/contactHelpers.ts b/src/features/contacts/contactHelpers.ts index c9c69fd1..7682c2b2 100644 --- a/src/features/contacts/contactHelpers.ts +++ b/src/features/contacts/contactHelpers.ts @@ -463,7 +463,8 @@ export const contactOobiRoleSummary = ( * Witness records are useful, but they should not crowd the main contact list. */ export const isWitnessContact = (contact: ContactRecord): boolean => - explicitOobiMetadataRoles(contact.oobi).includes('witness'); + explicitOobiMetadataRoles(contact.oobi).includes('witness') || + contact.componentTags.includes('witness'); /** * Shield state for compact contact cards and contact detail headers. diff --git a/src/features/identifiers/IdentifierCreateDialog.tsx b/src/features/identifiers/IdentifierCreateDialog.tsx index f20ac369..2df24799 100644 --- a/src/features/identifiers/IdentifierCreateDialog.tsx +++ b/src/features/identifiers/IdentifierCreateDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type MouseEvent } from 'react'; import { Accordion, AccordionDetails, @@ -16,6 +16,8 @@ import { Stack, Switch, TextField, + ToggleButton, + ToggleButtonGroup, Typography, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -25,7 +27,10 @@ import { defaultIdentifierCreateDraft, isIdentifierCreateDraft, } from './identifierHelpers'; -import type { IdentifierCreateDraft } from './identifierTypes'; +import type { + IdentifierCreateDraft, + IdentifierDelegatorOption, +} from './identifierTypes'; import { UI_SOUND_HOVER_VALUE } from '../../app/uiSound'; /** @@ -37,6 +42,7 @@ import { UI_SOUND_HOVER_VALUE } from '../../app/uiSound'; export interface IdentifierCreateDialogProps { open: boolean; actionRunning: boolean; + delegatorOptions: readonly IdentifierDelegatorOption[]; onClose: () => void; onCreate: (draft: IdentifierCreateDraft) => void; } @@ -54,6 +60,13 @@ const normalizedDraft = ( isith: draft.isith.trim(), nsith: draft.nsith.trim(), bran: draft.bran.trim(), + delegation: + draft.delegation.mode === 'delegated' + ? { + mode: 'delegated', + delegatorAid: draft.delegation.delegatorAid.trim(), + } + : { mode: 'none' }, }); /** @@ -66,6 +79,7 @@ const normalizedDraft = ( export const IdentifierCreateDialog = ({ open, actionRunning, + delegatorOptions, onClose, onCreate, }: IdentifierCreateDialogProps) => { @@ -87,6 +101,34 @@ export const IdentifierCreateDialog = ({ updateDraft({ algo }); }; + const handleDelegationModeChange = ( + _event: MouseEvent, + mode: 'none' | 'delegated' | null + ) => { + if (mode === null) { + return; + } + + updateDraft({ + delegation: + mode === 'delegated' + ? { + mode: 'delegated', + delegatorAid: delegatorOptions[0]?.aid ?? '', + } + : { mode: 'none' }, + }); + }; + + const handleDelegatorChange = (event: SelectChangeEvent) => { + updateDraft({ + delegation: { + mode: 'delegated', + delegatorAid: event.target.value, + }, + }); + }; + const handleComplete = () => { if (isIdentifierCreateDraft(createDraft)) { onCreate(createDraft); @@ -151,6 +193,47 @@ export const IdentifierCreateDialog = ({ } label="Use demo witnesses" /> + + Standard + Delegated + + {draft.delegation.mode === 'delegated' && ( + + + Delegator + + + + )} }> Advanced Options diff --git a/src/features/identifiers/IdentifierDetailsModal.tsx b/src/features/identifiers/IdentifierDetailsModal.tsx index 496fafc6..779e1088 100644 --- a/src/features/identifiers/IdentifierDetailsModal.tsx +++ b/src/features/identifiers/IdentifierDetailsModal.tsx @@ -20,7 +20,11 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import RotateRightIcon from '@mui/icons-material/RotateRight'; import type { GeneratedOobiRecord } from '../../state/contacts.slice'; -import type { IdentifierSummary } from './identifierTypes'; +import type { + IdentifierDelegationChainNode, + IdentifierDelegationChainState, + IdentifierSummary, +} from './identifierTypes'; import { formatIdentifierMetadata, identifierCurrentKey, @@ -42,6 +46,7 @@ export interface IdentifierDetailsModalProps { refreshStatus: 'idle' | 'loading' | 'success' | 'error'; refreshMessage: string | null; oobiState: IdentifierOobiDetailState; + delegationChain: IdentifierDelegationChainState; actionRunning: boolean; onClose: () => void; onRotate: (name: string) => void; @@ -229,6 +234,7 @@ export const IdentifierDetailsModal = ({ refreshStatus, refreshMessage, oobiState, + delegationChain, actionRunning, onClose, onRotate, @@ -370,6 +376,29 @@ export const IdentifierDetailsModal = ({ {refreshMessage} )} + {delegationChain.status === 'loading' || + delegationChain.status === 'error' || + delegationChain.nodes.length > 1 ? ( + + }> + + Delegation Chain + {delegationChain.status === 'loading' && ( + + )} + + + + + + + ) : null} }> { + if (source === 'local') { + return 'Local'; + } + + if (source === 'contact') { + return 'Contact'; + } + + if (source === 'keyState') { + return 'Key state'; + } + + return 'Unknown'; +}; + +const IdentifierDelegationChain = ({ + state, +}: { + state: IdentifierDelegationChainState; +}) => { + if (state.status === 'loading' || state.status === 'idle') { + return ( + + Loading delegation chain... + + ); + } + + if (state.status === 'error') { + return ( + + Unable to load delegation chain: {state.message} + + ); + } + + if (state.nodes.length <= 1) { + return ( + + This identifier is not delegated. + + ); + } + + return ( + + {state.nodes.map((node, index) => ( + + + + + + + {node.alias ?? nodeSourceLabel(node.source)} + + + + + + + {node.aid} + + + copyValue(node.aid)} + > + + + + + + + Sequence {node.sequence ?? 'unavailable'} + + + Event {node.eventSaid ?? 'unavailable'} + + + + + ))} + + ); +}; + const IdentifierOobis = ({ state }: { state: IdentifierOobiDetailState }) => { if (state.status === 'loading' || state.status === 'idle') { return ( @@ -483,10 +658,15 @@ const IdentifierOobis = ({ state }: { state: IdentifierOobiDetailState }) => { - + {record.oobis.length} URL {record.oobis.length === 1 ? '' : 's'} diff --git a/src/features/identifiers/IdentifiersView.tsx b/src/features/identifiers/IdentifiersView.tsx index da6afd58..38729b67 100644 --- a/src/features/identifiers/IdentifiersView.tsx +++ b/src/features/identifiers/IdentifiersView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Button, Fab, Typography } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import { useFetcher, useLoaderData } from 'react-router-dom'; @@ -23,12 +23,14 @@ import { idleIdentifierAction, type IdentifierActionState, type IdentifierCreateDraft, + type IdentifierDelegationChainState, type IdentifierSummary, } from './identifierTypes'; import type { GeneratedOobiRecord } from '../../state/contacts.slice'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { selectActiveOperations, + selectContacts, selectIdentifiers, } from '../../state/selectors'; import { walletAidSelected } from '../../state/walletSelection.slice'; @@ -36,6 +38,7 @@ import { identifierAvailableOobiRoles, type OobiGenerationRole, } from '../contacts/contactHelpers'; +import { identifierDelegatorOptions } from './identifierHelpers'; /** * Connected identifiers feature route. @@ -66,11 +69,18 @@ export const IdentifiersView = () => { message: null, records: [], }); + const [delegationChain, setDelegationChain] = + useState({ + status: 'idle', + message: null, + nodes: [], + }); const [agentOobiCopyStatus, setAgentOobiCopyStatus] = useState< Record >({}); const actionRunning = fetcher.state !== 'idle'; const liveIdentifiers = useAppSelector(selectIdentifiers); + const contacts = useAppSelector(selectContacts); const activeOperations = useAppSelector(selectActiveOperations); const activeResourceKeys = new Set( activeOperations.flatMap((operation) => operation.resourceKeys) @@ -87,6 +97,10 @@ export const IdentifiersView = () => { loaderData.status === 'blocked' ? [] : loaderData.identifiers; const identifiers = liveIdentifiers.length > 0 ? liveIdentifiers : loaderIdentifiers; + const delegatorOptions = useMemo( + () => identifierDelegatorOptions(identifiers, contacts), + [contacts, identifiers] + ); const selectedIdentifier = selectedIdentifierName === null ? null @@ -116,6 +130,21 @@ export const IdentifiersView = () => { setDetailRefresh({ status: 'success', message: null }); + const chain = await runtime.getIdentifierDelegationChain( + refreshed.name, + { + signal: controller.signal, + track: false, + } + ); + if (!controller.signal.aborted) { + setDelegationChain({ + status: 'success', + message: null, + nodes: chain, + }); + } + const roles = identifierAvailableOobiRoles(refreshed); const records = await runtime.listIdentifierOobis( refreshed.name, @@ -149,6 +178,11 @@ export const IdentifiersView = () => { message, records: [], }); + setDelegationChain({ + status: 'error', + message, + nodes: [], + }); } })(); @@ -228,6 +262,11 @@ export const IdentifiersView = () => { const handleSelectIdentifier = (identifier: IdentifierSummary) => { setDetailRefresh({ status: 'loading', message: null }); setDetailOobis({ status: 'loading', message: null, records: [] }); + setDelegationChain({ + status: 'loading', + message: null, + nodes: [], + }); setSelectedIdentifierName(identifier.name); dispatch(walletAidSelected({ aid: identifier.prefix })); }; @@ -392,6 +431,7 @@ export const IdentifiersView = () => { refreshStatus={detailRefresh.status} refreshMessage={detailRefresh.message} oobiState={detailOobis} + delegationChain={delegationChain} actionRunning={ selectedIdentifierName === null ? false @@ -405,6 +445,11 @@ export const IdentifiersView = () => { message: null, records: [], }); + setDelegationChain({ + status: 'idle', + message: null, + nodes: [], + }); }} onRotate={handleRotate} /> @@ -412,6 +457,7 @@ export const IdentifiersView = () => { { setCreateOpen(false); setActiveCreateRequestId(null); diff --git a/src/features/identifiers/delegationHelpers.ts b/src/features/identifiers/delegationHelpers.ts new file mode 100644 index 00000000..06754b1c --- /dev/null +++ b/src/features/identifiers/delegationHelpers.ts @@ -0,0 +1,148 @@ +import type { KeyState } from 'signify-ts'; +import type { + DelegationRequestNotification, + NotificationRecord, +} from '../../state/notifications.slice'; +import type { + IdentifierDelegationChainNode, + IdentifierSummary, +} from './identifierTypes'; + +/** + * Delegation anchor shape accepted by SignifyTS `delegations().approve`. + */ +export interface DelegationAnchor { + i: string; + s: string; + d: string; +} + +/** + * Runtime details recorded by delegated identifier workflows. + */ +export interface DelegationWorkflowDetails { + delegatorAid: string; + delegateAid: string; + delegateEventSaid: string; + sequence: string; + anchor: DelegationAnchor; + requestedAt: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const stringValue = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + +const numberValue = (value: unknown): string | null => + typeof value === 'number' && Number.isFinite(value) ? String(value) : null; + +const eventString = ( + event: Record, + key: string +): string | null => stringValue(event[key]) ?? numberValue(event[key]); + +/** + * Create the reusable delegation anchor from a delegated inception or rotation + * event. This helper intentionally does not approve anything; approval happens + * only when the delegator signs an anchor event. + */ +export const delegationAnchorFromEvent = (event: unknown): DelegationAnchor => { + if (!isRecord(event)) { + throw new Error('Delegation event is missing or malformed.'); + } + + const delegateAid = eventString(event, 'i'); + const sequence = eventString(event, 's'); + const delegateEventSaid = eventString(event, 'd'); + + if ( + delegateAid === null || + sequence === null || + delegateEventSaid === null + ) { + throw new Error( + 'Delegation event must include delegate AID, sequence, and SAID.' + ); + } + + return { + i: delegateAid, + s: sequence, + d: delegateEventSaid, + }; +}; + +/** + * Rebuild the anchor from a typed delegation request notification. + */ +export const delegationAnchorFromNotification = ( + request: DelegationRequestNotification +): DelegationAnchor => ({ + i: request.anchor.i, + s: request.anchor.s, + d: request.anchor.d, +}); + +/** + * Return the delegator AID recorded in an identifier key state. + */ +export const identifierDelegatorAid = ( + identifier: IdentifierSummary | null +): string | null => { + const delegatorAid = + identifier === null ? null : stringValue(identifier.state?.di); + + return delegatorAid; +}; + +/** + * Return true when a managed identifier is currently delegated. + */ +export const isDelegatedIdentifier = ( + identifier: IdentifierSummary | null +): boolean => identifierDelegatorAid(identifier) !== null; + +/** + * Build a delegation-chain display node from local identifier state. + */ +export const delegationChainNodeFromIdentifier = ( + identifier: IdentifierSummary, + source: IdentifierDelegationChainNode['source'] = 'local' +): IdentifierDelegationChainNode => ({ + aid: identifier.prefix, + alias: identifier.name, + source, + sequence: stringValue(identifier.state?.s), + eventSaid: stringValue(identifier.state?.d), + delegatorAid: identifierDelegatorAid(identifier), +}); + +/** + * Build a delegation-chain display node from a resolved key state. + */ +export const delegationChainNodeFromKeyState = ({ + state, + alias, + source, +}: { + state: KeyState; + alias: string | null; + source: IdentifierDelegationChainNode['source']; +}): IdentifierDelegationChainNode => ({ + aid: state.i, + alias, + source, + sequence: stringValue(state.s), + eventSaid: stringValue(state.d), + delegatorAid: stringValue(state.di), +}); + +/** + * Return the route-owned delegation request from a generic notification. + */ +export const delegationRequestFromNotificationRecord = ( + notification: NotificationRecord | null +): DelegationRequestNotification | null => + notification?.delegationRequest ?? null; diff --git a/src/features/identifiers/identifierHelpers.ts b/src/features/identifiers/identifierHelpers.ts index 7e18d4dc..1f321436 100644 --- a/src/features/identifiers/identifierHelpers.ts +++ b/src/features/identifiers/identifierHelpers.ts @@ -1,8 +1,11 @@ import { Algos } from 'signify-ts'; import type { AppConfig } from '../../config'; +import type { ContactRecord } from '../../state/contacts.slice'; +import { abbreviateMiddle, isWitnessContact } from '../contacts/contactHelpers'; import type { IdentifierCreateArgs, IdentifierCreateDraft, + IdentifierDelegatorOption, IdentifierSummary, } from './identifierTypes'; @@ -73,6 +76,54 @@ export const replaceIdentifierSummary = ( return replaced ? next : [...next, updated]; }; +/** + * Build selectable delegation candidates from local identifiers and resolved + * contacts. Witness contacts are excluded because witnesses are infrastructure + * components, not delegator identities. + */ +export const identifierDelegatorOptions = ( + identifiers: readonly IdentifierSummary[], + contacts: readonly ContactRecord[] +): IdentifierDelegatorOption[] => { + const seen = new Set(); + const options: IdentifierDelegatorOption[] = []; + + for (const identifier of identifiers) { + if (seen.has(identifier.prefix)) { + continue; + } + + seen.add(identifier.prefix); + options.push({ + aid: identifier.prefix, + label: `${identifier.name} / ${abbreviateMiddle( + identifier.prefix, + 20 + )} (local)`, + source: 'local', + }); + } + + for (const contact of contacts) { + const aid = contact.aid; + if (aid === null || seen.has(aid) || isWitnessContact(contact)) { + continue; + } + + seen.add(aid); + options.push({ + aid, + label: `${contact.alias} / ${abbreviateMiddle( + aid, + 20 + )} (contact)`, + source: 'contact', + }); + } + + return options; +}; + /** * Best-effort identifier algorithm/type label from Signify's tagged HabState. */ @@ -187,6 +238,7 @@ export const defaultIdentifierCreateDraft = (): IdentifierCreateDraft => ({ algo: Algos.salty, transferable: true, witnessMode: 'none', + delegation: { mode: 'none' }, count: 1, ncount: 1, isith: '1', @@ -204,6 +256,15 @@ const isIdentifierWitnessMode = ( ): value is IdentifierCreateDraft['witnessMode'] => value === 'none' || value === 'demo'; +const isIdentifierDelegationDraft = ( + value: unknown +): value is IdentifierCreateDraft['delegation'] => + isObjectRecord(value) && + (value.mode === 'none' || + (value.mode === 'delegated' && + typeof value.delegatorAid === 'string' && + value.delegatorAid.trim().length > 0)); + const isPositiveInteger = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0; @@ -219,6 +280,7 @@ export const isIdentifierCreateDraft = ( isIdentifierCreateAlgo(value.algo) && typeof value.transferable === 'boolean' && isIdentifierWitnessMode(value.witnessMode) && + isIdentifierDelegationDraft(value.delegation) && isPositiveInteger(value.count) && isPositiveInteger(value.ncount) && typeof value.isith === 'string' && @@ -252,5 +314,9 @@ export const identifierCreateDraftToArgs = ( args.toad = config.witnesses.toad; } + if (draft.delegation.mode === 'delegated') { + args.delpre = draft.delegation.delegatorAid.trim(); + } + return args; }; diff --git a/src/features/identifiers/identifierTypes.ts b/src/features/identifiers/identifierTypes.ts index 478acfcb..ebd857ec 100644 --- a/src/features/identifiers/identifierTypes.ts +++ b/src/features/identifiers/identifierTypes.ts @@ -36,6 +36,48 @@ export type IdentifierCreateArgs = CreateIdentiferArgs; */ export type IdentifierWitnessMode = 'none' | 'demo'; +/** + * Delegation mode selected by the identifier create form. + * + * `none` keeps the existing self-addressing inception behavior. `delegated` + * maps to Signify's `delpre` create argument and starts a long-running + * delegate workflow that waits for manual delegator approval. + */ +export type IdentifierDelegationDraft = + | { mode: 'none' } + | { mode: 'delegated'; delegatorAid: string }; + +/** + * One selectable delegator candidate in the create dialog. + */ +export interface IdentifierDelegatorOption { + aid: string; + label: string; + source: 'local' | 'contact'; +} + +/** + * One node in an identifier delegation chain, ordered from leaf/delegate to + * root delegator. + */ +export interface IdentifierDelegationChainNode { + aid: string; + alias: string | null; + source: 'local' | 'contact' | 'keyState' | 'unknown'; + sequence: string | null; + eventSaid: string | null; + delegatorAid: string | null; +} + +/** + * Async detail state for recursive delegation-chain loading. + */ +export interface IdentifierDelegationChainState { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; + nodes: IdentifierDelegationChainNode[]; +} + /** * User-intent draft for the single-sig identifier creator. * @@ -48,6 +90,7 @@ export interface IdentifierCreateDraft { algo: Algos.salty | Algos.randy; transferable: boolean; witnessMode: IdentifierWitnessMode; + delegation: IdentifierDelegationDraft; count: number; ncount: number; isith: string; diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index 10cf7918..e8c36649 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -280,6 +280,28 @@ export const AppNotificationsView = () => { } )} + {notification.delegationRequest !== + null && + notification.delegationRequest !== + undefined && ( + + Delegator{' '} + { + notification + .delegationRequest + .delegatorAid + }{' '} + / Delegate{' '} + { + notification + .delegationRequest + .delegateAid + } + + )} } /> diff --git a/src/features/notifications/NotificationDetailView.tsx b/src/features/notifications/NotificationDetailView.tsx index cc0bfdcd..5c7d4db9 100644 --- a/src/features/notifications/NotificationDetailView.tsx +++ b/src/features/notifications/NotificationDetailView.tsx @@ -37,10 +37,14 @@ import { useAppSelector } from '../../state/hooks'; import { selectChallengeRequestNotificationById, selectCredentialGrantNotificationById, + selectDelegationRequestNotificationById, selectIdentifiers, selectKeriaNotificationById, } from '../../state/selectors'; -import type { CredentialGrantNotification } from '../../state/notifications.slice'; +import type { + CredentialGrantNotification, + DelegationRequestNotification, +} from '../../state/notifications.slice'; import { ChallengeRequestResponseForm } from './ChallengeRequestResponseForm'; const timestampText = (value: string | null): string => @@ -64,6 +68,24 @@ const grantStatusTone = ( return 'info'; }; +const delegationStatusTone = ( + status: DelegationRequestNotification['status'] +): 'neutral' | 'success' | 'warning' | 'error' | 'info' => { + if (status === 'error') { + return 'error'; + } + + if (status === 'notForThisWallet') { + return 'warning'; + } + + if (status === 'approved') { + return 'success'; + } + + return 'info'; +}; + /** * Route view for one KERIA protocol notification or synthetic challenge item. */ @@ -73,6 +95,7 @@ export const NotificationDetailView = () => { const navigate = useNavigate(); const dismissFetcher = useFetcher(); const credentialFetcher = useFetcher(); + const delegationFetcher = useFetcher(); const notification = useAppSelector( selectKeriaNotificationById(notificationId) ); @@ -82,17 +105,32 @@ export const NotificationDetailView = () => { const credentialGrant = useAppSelector( selectCredentialGrantNotificationById(notificationId) ); + const delegationRequest = useAppSelector( + selectDelegationRequestNotificationById(notificationId) + ); const identifiers = useAppSelector(selectIdentifiers); const grantRecipient = credentialGrant === null ? undefined : identifiers.find( - (identifier) => identifier.prefix === credentialGrant.holderAid + (identifier) => + identifier.prefix === credentialGrant.holderAid ); const canAdmitGrant = credentialGrant?.status === 'actionable' && grantRecipient !== undefined && credentialFetcher.state === 'idle'; + const delegationApprover = + delegationRequest === null + ? undefined + : identifiers.find( + (identifier) => + identifier.prefix === delegationRequest.delegatorAid + ); + const canApproveDelegation = + delegationRequest?.status === 'actionable' && + delegationApprover !== undefined && + delegationFetcher.state === 'idle'; useEffect(() => { if ( @@ -150,6 +188,28 @@ export const NotificationDetailView = () => { }); }; + const approveDelegationRequest = () => { + if (delegationRequest === null || delegationApprover === undefined) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'approveDelegationRequest'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', delegationRequest.notificationId); + formData.set('delegatorName', delegationApprover.name); + formData.set('delegatorAid', delegationRequest.delegatorAid); + formData.set('delegateAid', delegationRequest.delegateAid); + formData.set('delegateEventSaid', delegationRequest.delegateEventSaid); + formData.set('sequence', delegationRequest.sequence); + formData.set('sourceAid', delegationRequest.sourceAid ?? ''); + formData.set('createdAt', delegationRequest.createdAt); + delegationFetcher.submit(formData, { + method: 'post', + action: `/notifications/${encodeURIComponent(notification.id)}`, + }); + }; + return ( { ? 'Challenge request' : credentialGrant !== null ? 'Credential grant' - : notification.route + : delegationRequest !== null + ? 'Delegation request' + : notification.route } summary={notification.id} actions={ @@ -389,6 +451,104 @@ export const NotificationDetailView = () => { )} + ) : delegationRequest !== null ? ( + + } + > + + + + + + + + + + + + + + + + + + {delegationApprover === undefined && ( + + )} + + ) : ( diff --git a/src/services/delegations.service.ts b/src/services/delegations.service.ts new file mode 100644 index 00000000..84fd8735 --- /dev/null +++ b/src/services/delegations.service.ts @@ -0,0 +1,97 @@ +import type { Operation as EffectionOperation } from 'effection'; +import type { SignifyClient } from 'signify-ts'; +import { callPromise } from '../effects/promise'; +import { delegationAnchorFromNotification } from '../features/identifiers/delegationHelpers'; +import type { DelegationRequestNotification } from '../state/notifications.slice'; +import type { OperationLogger } from '../signify/client'; +import { waitOperationService } from './signify.service'; + +/** + * Route/workflow input for manual delegator approval. + */ +export interface ApproveDelegationInput { + notificationId: string; + delegatorName: string; + request: DelegationRequestNotification; +} + +/** + * Background workflow result recorded on approval operation completion. + */ +export interface ApproveDelegationResult { + delegatorAid: string; + delegateAid: string; + delegateEventSaid: string; + sequence: string; + requestedAt: string; + approvedAt: string; +} + +/** + * Approve a delegation request by creating the delegator's anchor event. + */ +export function* approveDelegationRequestService({ + client, + input, + logger, +}: { + client: SignifyClient; + input: ApproveDelegationInput; + logger?: OperationLogger; +}): EffectionOperation { + const delegatorName = input.delegatorName.trim(); + if (delegatorName.length === 0) { + throw new Error('Delegator identifier name is required.'); + } + + const anchor = delegationAnchorFromNotification(input.request); + if (anchor.i !== input.request.delegateAid) { + throw new Error( + 'Delegation request anchor does not match delegate AID.' + ); + } + + const delegator = yield* callPromise(() => + client.identifiers().get(delegatorName) + ); + if (delegator.prefix !== input.request.delegatorAid) { + throw new Error( + 'Delegation request delegator does not match the selected local identifier.' + ); + } + + const result = yield* callPromise(() => + client.delegations().approve(delegatorName, anchor) + ); + const operation = yield* callPromise(() => result.op()); + + yield* waitOperationService({ + client, + operation, + label: `approving delegation for ${input.request.delegateAid}`, + logger, + }); + + try { + yield* callPromise(() => + client.notifications().delete(input.notificationId) + ); + } catch { + try { + yield* callPromise(() => + client.notifications().mark(input.notificationId) + ); + } catch { + // Approval is authoritative; notification cleanup is best effort. + } + } + + return { + delegatorAid: input.request.delegatorAid, + delegateAid: input.request.delegateAid, + delegateEventSaid: input.request.delegateEventSaid, + sequence: input.request.sequence, + requestedAt: input.request.createdAt, + approvedAt: new Date().toISOString(), + }; +} diff --git a/src/services/identifiers.service.ts b/src/services/identifiers.service.ts index ce23ae77..f01a610c 100644 --- a/src/services/identifiers.service.ts +++ b/src/services/identifiers.service.ts @@ -1,7 +1,18 @@ -import type { SignifyClient } from 'signify-ts'; +import type { + KeyState, + Operation as KeriaOperation, + SignifyClient, +} from 'signify-ts'; import type { Operation as EffectionOperation } from 'effection'; import type { AppConfig } from '../config'; import { callPromise } from '../effects/promise'; +import { + delegationAnchorFromEvent, + delegationChainNodeFromIdentifier, + delegationChainNodeFromKeyState, + identifierDelegatorAid, + type DelegationWorkflowDetails, +} from '../features/identifiers/delegationHelpers'; import { identifierCreateDraftToArgs, identifiersFromResponse, @@ -9,11 +20,94 @@ import { } from '../features/identifiers/identifierHelpers'; import type { IdentifierCreateDraft, + IdentifierDelegationChainNode, IdentifierSummary, } from '../features/identifiers/identifierTypes'; +import type { ContactRecord } from '../state/contacts.slice'; import type { OperationLogger } from '../signify/client'; import { waitOperationService } from './signify.service'; +/** + * Phase callback used by background workflows to update operation records while + * services remain decoupled from Redux. + */ +export type IdentifierOperationPhaseReporter = ( + phase: string, + keriaOperationName?: string | null +) => void; + +/** + * Rich result used by background identifier workflows. Foreground route calls + * project this back to `identifiers` to preserve the public route contract. + */ +export interface IdentifierMutationResult { + identifiers: IdentifierSummary[]; + refreshed: IdentifierSummary | null; + delegation: DelegationWorkflowDetails | null; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const operationName = (operation: KeriaOperation): string | null => { + const candidate = (operation as { name?: unknown }).name; + return typeof candidate === 'string' ? candidate : null; +}; + +const eventFromResult = (result: unknown): unknown => { + if (!isRecord(result)) { + return null; + } + + const serder = result.serder; + if (!isRecord(serder)) { + return null; + } + + return serder.sad; +}; + +function* waitDelegationApproval({ + client, + delegatorAid, + event, + logger, + onPhase, + timeoutMs, +}: { + client: SignifyClient; + delegatorAid: string; + event: unknown; + logger?: OperationLogger; + onPhase?: IdentifierOperationPhaseReporter; + timeoutMs: number; +}): EffectionOperation { + const anchor = delegationAnchorFromEvent(event); + onPhase?.('waiting for manual delegator approval'); + + const queryOperation = yield* callPromise(() => + client.keyStates().query(delegatorAid, undefined, anchor) + ); + onPhase?.('querying delegator key state', operationName(queryOperation)); + + yield* waitOperationService({ + client, + operation: queryOperation, + label: `querying delegator ${delegatorAid} for delegation approval`, + logger, + timeoutMs, + }); + + return { + delegatorAid, + delegateAid: anchor.i, + delegateEventSaid: anchor.d, + sequence: anchor.s, + anchor, + requestedAt: new Date().toISOString(), + }; +} + /** * Load and normalize every managed identifier visible to the connected client. */ @@ -51,26 +145,64 @@ export function* createIdentifierService({ config, draft, logger, + onPhase, }: { client: SignifyClient; config: AppConfig; draft: IdentifierCreateDraft; logger?: OperationLogger; -}): EffectionOperation { + onPhase?: IdentifierOperationPhaseReporter; +}): EffectionOperation { const name = draft.name.trim(); const args = identifierCreateDraftToArgs(draft, config); const identifierClient = client.identifiers(); - const result = yield* callPromise(() => identifierClient.create(name, args)); + onPhase?.( + draft.delegation.mode === 'delegated' + ? 'creating delegated event' + : 'creating identifier' + ); + const result = yield* callPromise(() => + identifierClient.create(name, args) + ); const operation = yield* callPromise(() => result.op()); + let delegation: DelegationWorkflowDetails | null = null; + if (draft.delegation.mode === 'delegated') { + delegation = yield* waitDelegationApproval({ + client, + delegatorAid: draft.delegation.delegatorAid.trim(), + event: eventFromResult(result), + logger, + onPhase, + timeoutMs: config.operations.delegationApprovalTimeoutMs, + }); + onPhase?.( + 'waiting for delegated identifier operation', + operationName(operation) + ); + } else { + onPhase?.('waiting for identifier operation', operationName(operation)); + } + yield* waitOperationService({ client, operation, - label: `creating identifier ${name}`, + label: + draft.delegation.mode === 'delegated' + ? `waiting for delegation approval for identifier ${name}` + : `creating identifier ${name}`, logger, }); - return yield* listIdentifiersService({ client }); + onPhase?.('refreshing identifiers'); + const identifiers = yield* listIdentifiersService({ client }); + const refreshed = yield* getIdentifierService({ client, aid: name }); + + return { + identifiers: replaceIdentifierSummary(identifiers, refreshed), + refreshed, + delegation, + }; } /** @@ -78,25 +210,148 @@ export function* createIdentifierService({ */ export function* rotateIdentifierService({ client, + config, aid, logger, + onPhase, }: { client: SignifyClient; + config: AppConfig; aid: string; logger?: OperationLogger; -}): EffectionOperation { - const result = yield* callPromise(() => client.identifiers().rotate(aid, {})); + onPhase?: IdentifierOperationPhaseReporter; +}): EffectionOperation { + const current = yield* getIdentifierService({ client, aid }); + const delegatorAid = identifierDelegatorAid(current); + onPhase?.( + delegatorAid === null + ? 'rotating identifier' + : 'creating delegated rotation event' + ); + const result = yield* callPromise(() => + client.identifiers().rotate(aid, {}) + ); const operation = yield* callPromise(() => result.op()); + let delegation: DelegationWorkflowDetails | null = null; + if (delegatorAid !== null) { + delegation = yield* waitDelegationApproval({ + client, + delegatorAid, + event: eventFromResult(result), + logger, + onPhase, + timeoutMs: config.operations.delegationApprovalTimeoutMs, + }); + onPhase?.( + 'waiting for delegated rotation operation', + operationName(operation) + ); + } else { + onPhase?.('waiting for rotation operation', operationName(operation)); + } + yield* waitOperationService({ client, operation, - label: `rotating identifier ${aid}`, + label: + delegatorAid === null + ? `rotating identifier ${aid}` + : `waiting for delegation approval for rotation ${aid}`, logger, }); + onPhase?.('refreshing identifiers'); const identifiers = yield* listIdentifiersService({ client }); const refreshed = yield* getIdentifierService({ client, aid }); - return replaceIdentifierSummary(identifiers, refreshed); + return { + identifiers: replaceIdentifierSummary(identifiers, refreshed), + refreshed, + delegation, + }; +} + +const firstKeyState = (states: KeyState[]): KeyState | null => + states.length > 0 ? states[0] : null; + +/** + * Resolve an identifier's delegation chain from delegate to root. + */ +export function* getIdentifierDelegationChainService({ + client, + aid, + localIdentifiers, + contacts, + depthLimit = 16, +}: { + client: SignifyClient; + aid: string; + localIdentifiers: readonly IdentifierSummary[]; + contacts: readonly ContactRecord[]; + depthLimit?: number; +}): EffectionOperation { + const localByAid = new Map( + localIdentifiers.map((identifier) => [identifier.prefix, identifier]) + ); + const contactByAid = new Map( + contacts.flatMap((contact) => + contact.aid === null ? [] : [[contact.aid, contact]] + ) + ); + const start = yield* getIdentifierService({ client, aid }); + const nodes: IdentifierDelegationChainNode[] = [ + delegationChainNodeFromIdentifier(start), + ]; + const seen = new Set([start.prefix]); + let nextDelegatorAid = nodes[0]?.delegatorAid ?? null; + + while (nextDelegatorAid !== null && nodes.length < depthLimit) { + const currentDelegatorAid = nextDelegatorAid; + if (seen.has(currentDelegatorAid)) { + break; + } + + seen.add(currentDelegatorAid); + const localIdentifier = localByAid.get(currentDelegatorAid); + if (localIdentifier !== undefined) { + const node = delegationChainNodeFromIdentifier(localIdentifier); + nodes.push(node); + nextDelegatorAid = node.delegatorAid; + continue; + } + + const contact = contactByAid.get(currentDelegatorAid) ?? null; + let state: KeyState | null; + try { + const states = yield* callPromise(() => + client.keyStates().get(currentDelegatorAid) + ); + state = firstKeyState(states); + } catch { + state = null; + } + + if (state === null) { + nodes.push({ + aid: currentDelegatorAid, + alias: contact?.alias ?? null, + source: contact === null ? 'unknown' : 'contact', + sequence: null, + eventSaid: null, + delegatorAid: null, + }); + break; + } + + const node = delegationChainNodeFromKeyState({ + state, + alias: contact?.alias ?? null, + source: contact === null ? 'keyState' : 'contact', + }); + nodes.push(node); + nextDelegatorAid = node.delegatorAid; + } + + return nodes; } diff --git a/src/services/notifications.service.ts b/src/services/notifications.service.ts index 8b39de13..c2498cc4 100644 --- a/src/services/notifications.service.ts +++ b/src/services/notifications.service.ts @@ -1,6 +1,7 @@ import type { Operation as EffectionOperation } from 'effection'; import type { SignifyClient } from 'signify-ts'; import { callPromise } from '../effects/promise'; +import { delegationAnchorFromEvent } from '../features/identifiers/delegationHelpers'; import { CHALLENGE_REQUEST_ROUTE } from './challenges.service'; import { credentialAdmitFromExchange, @@ -12,9 +13,12 @@ import type { ContactRecord } from '../state/contacts.slice'; import type { ChallengeRequestNotification, ChallengeRequestNotificationStatus, + DelegationRequestNotification, NotificationRecord, } from '../state/notifications.slice'; +export const DELEGATION_REQUEST_NOTIFICATION_ROUTE = '/delegate/request'; + /** * Normalized KERIA notification inventory plus app-derived challenge notices. */ @@ -101,6 +105,8 @@ const notificationItemsFromResponse = (raw: unknown): unknown[] => { return []; }; +const notificationRawAttrs = new Map>(); + /** * Project KERIA's loose notification response into serializable app records. */ @@ -108,6 +114,8 @@ export const notificationRecordsFromResponse = ( raw: unknown, loadedAt: string ): NotificationRecord[] => { + notificationRawAttrs.clear(); + return notificationItemsFromResponse(raw).flatMap((item) => { if (!isRecord(item)) { return []; @@ -119,6 +127,7 @@ export const notificationRecordsFromResponse = ( } const attrs = isRecord(item.a) ? item.a : {}; + notificationRawAttrs.set(id, attrs); const route = stringValue(attrs.r) ?? 'unknown'; const dt = stringValue(item.dt); const read = item.r === true; @@ -134,6 +143,9 @@ export const notificationRecordsFromResponse = ( status: read ? 'processed' : 'unread', message: stringValue(attrs.m), challengeRequest: null, + credentialGrant: null, + credentialAdmit: null, + delegationRequest: null, updatedAt: dt ?? loadedAt, }, ]; @@ -173,6 +185,138 @@ const exchangeRecipientAid = (exchange: unknown): string | null => { return stringValue(exn.rp) ?? stringValue(attrs.i); }; +const embeddedDelegationEvent = ( + value: Record +): Record | null => { + for (const key of ['ked', 'event', 'evt', 'icp', 'dip', 'rot', 'drt']) { + const candidate = value[key]; + if (isRecord(candidate)) { + return candidate; + } + } + + const embedded = value.e; + if (isRecord(embedded)) { + for (const key of ['ked', 'event', 'evt', 'icp', 'dip', 'rot', 'drt']) { + const candidate = embedded[key]; + if (isRecord(candidate)) { + return candidate; + } + } + } + + return null; +}; + +const delegationRequestFromPayload = ({ + notification, + payload, + sourceAid, + loadedAt, +}: { + notification: NotificationRecord; + payload: Record; + sourceAid: string | null; + loadedAt: string; +}): DelegationRequestNotification => { + const event = embeddedDelegationEvent(payload) ?? payload; + const anchor = delegationAnchorFromEvent(event); + const delegatorAid = + stringValue(payload.delpre) ?? + stringValue(payload.delegatorAid) ?? + stringValue(event.di); + const createdAt = + stringValue(payload.dt) ?? + notification.dt ?? + notification.updatedAt ?? + loadedAt; + + if (delegatorAid === null) { + throw new Error('Delegation request is missing the delegator AID.'); + } + + return { + notificationId: notification.id, + delegatorAid, + delegateAid: anchor.i, + delegateEventSaid: anchor.d, + sequence: anchor.s, + anchor, + sourceAid, + createdAt, + status: 'actionable', + }; +}; + +function* hydrateDelegationRequestNotification({ + client, + notification, + localAids, + loadedAt, +}: { + client: SignifyClient; + notification: NotificationRecord; + localAids: ReadonlySet; + loadedAt: string; +}): EffectionOperation { + if (notification.route !== DELEGATION_REQUEST_NOTIFICATION_ROUTE) { + return notification; + } + + try { + const rawAttrs = notificationRawAttrs.get(notification.id); + const attrs = rawAttrs ?? {}; + const request = delegationRequestFromPayload({ + notification, + payload: attrs, + sourceAid: stringValue(attrs.src) ?? stringValue(attrs.i), + loadedAt, + }); + + if (localAids.size > 0 && !localAids.has(request.delegatorAid)) { + if (!notification.read) { + yield* callPromise(() => + client.notifications().mark(notification.id) + ); + } + + return { + ...notification, + read: true, + status: 'processed', + message: + 'Delegation request is not addressed to a local delegator AID.', + delegationRequest: { + ...request, + status: 'notForThisWallet', + }, + }; + } + + return { + ...notification, + status: notification.read ? 'processed' : 'unread', + message: + notification.message ?? + `Delegation request for ${request.delegatorAid}`, + delegationRequest: { + ...request, + status: notification.read ? 'approved' : 'actionable', + }, + }; + } catch (error) { + return { + ...notification, + status: 'error', + message: + error instanceof Error + ? error.message + : 'Unable to hydrate delegation request notification.', + delegationRequest: null, + }; + } +} + const isOutboundOrUnrelatedChallengeRequest = ({ contacts, localAids, @@ -548,9 +692,7 @@ function* hydrateCredentialIpexNotification({ return { ...notification, status: - credentialAdmit.status === 'received' - ? 'unread' - : 'processed', + credentialAdmit.status === 'received' ? 'unread' : 'processed', message: credentialAdmit.status === 'received' ? `Credential admit from ${credentialAdmit.holderAid}` @@ -596,6 +738,33 @@ function* hydrateCredentialIpexNotifications({ return hydrated; } +function* hydrateDelegationRequestNotifications({ + client, + notifications, + localAids, + loadedAt, +}: { + client: SignifyClient; + notifications: NotificationRecord[]; + localAids: ReadonlySet; + loadedAt: string; +}): EffectionOperation { + const hydrated: NotificationRecord[] = []; + + for (const notification of notifications) { + hydrated.push( + yield* hydrateDelegationRequestNotification({ + client, + notification, + localAids, + loadedAt, + }) + ); + } + + return hydrated; +} + const challengeRequestExchangesFromResponse = (raw: unknown): unknown[] => Array.isArray(raw) ? raw.filter((item) => { @@ -808,9 +977,15 @@ export function* listNotificationsService({ localAids: localAidSet, loadedAt, }); - const hydrated = yield* hydrateChallengeRequestNotifications({ + const delegationHydrated = yield* hydrateDelegationRequestNotifications({ client, notifications: ipexHydrated, + localAids: localAidSet, + loadedAt, + }); + const hydrated = yield* hydrateChallengeRequestNotifications({ + client, + notifications: delegationHydrated, contacts, localAids: localAidSet, tombstonedExnSaids: tombstoneSet, diff --git a/src/state/notifications.slice.ts b/src/state/notifications.slice.ts index d59fde8a..462a9d8d 100644 --- a/src/state/notifications.slice.ts +++ b/src/state/notifications.slice.ts @@ -4,6 +4,7 @@ import { sessionConnecting, sessionDisconnected, } from './session.slice'; +import type { DelegationAnchor } from '../features/identifiers/delegationHelpers'; /** Local handling status for a KERIA notification route. */ export type NotificationStatus = @@ -32,6 +33,13 @@ export type CredentialAdmitNotificationStatus = | 'notForThisWallet' | 'error'; +/** Delegator-facing state for inbound delegated identifier requests. */ +export type DelegationRequestNotificationStatus = + | 'actionable' + | 'approved' + | 'notForThisWallet' + | 'error'; + /** * Actionable challenge request metadata hydrated from a KERIA notification EXN. * @@ -79,6 +87,23 @@ export interface CredentialAdmitNotification { status: CredentialAdmitNotificationStatus; } +/** + * Delegation request metadata hydrated from a KERIA `/delegate/request` + * notification. The anchor is created from the delegate event and reused when + * the delegator manually approves the request. + */ +export interface DelegationRequestNotification { + notificationId: string; + delegatorAid: string; + delegateAid: string; + delegateEventSaid: string; + sequence: string; + anchor: DelegationAnchor; + sourceAid: string | null; + createdAt: string; + status: DelegationRequestNotificationStatus; +} + /** * Durable notification summary used by polling and future processing workflows. */ @@ -93,6 +118,7 @@ export interface NotificationRecord { challengeRequest?: ChallengeRequestNotification | null; credentialGrant?: CredentialGrantNotification | null; credentialAdmit?: CredentialAdmitNotification | null; + delegationRequest?: DelegationRequestNotification | null; updatedAt: string; } @@ -194,6 +220,33 @@ export const notificationsSlice = createSlice({ } } }, + delegationRequestNotificationApproved( + state, + { + payload, + }: PayloadAction<{ + id: string; + updatedAt: string; + message?: string | null; + }> + ) { + const notification = state.byId[payload.id]; + if (notification !== undefined) { + notification.read = true; + notification.status = 'processed'; + notification.updatedAt = payload.updatedAt; + notification.message = payload.message ?? notification.message; + if ( + notification.delegationRequest !== null && + notification.delegationRequest !== undefined + ) { + notification.delegationRequest = { + ...notification.delegationRequest, + status: 'approved', + }; + } + } + }, }, extraReducers: (builder) => { builder @@ -209,6 +262,7 @@ export const { notificationRecorded, notificationStatusChanged, challengeRequestNotificationResponded, + delegationRequestNotificationApproved, } = notificationsSlice.actions; /** Reducer mounted at `state.notifications`. */ diff --git a/src/state/operations.slice.ts b/src/state/operations.slice.ts index c5c84ce8..cdcc8cc2 100644 --- a/src/state/operations.slice.ts +++ b/src/state/operations.slice.ts @@ -22,6 +22,9 @@ export type OperationKind = | 'listIdentifiers' | 'createIdentifier' | 'rotateIdentifier' + | 'createDelegatedIdentifier' + | 'rotateDelegatedIdentifier' + | 'approveDelegation' | 'generateOobi' | 'generateChallenge' | 'sendChallengeRequest' diff --git a/src/state/payloadDetails.ts b/src/state/payloadDetails.ts index a9c0f33b..ca65167d 100644 --- a/src/state/payloadDetails.ts +++ b/src/state/payloadDetails.ts @@ -6,6 +6,7 @@ export interface PayloadDetailRecord { id: string; label: string; value: string; + displayValue?: string; kind: PayloadDetailKind; copyable: boolean; } diff --git a/src/state/selectors.ts b/src/state/selectors.ts index dfe6c215..7a87b9be 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -5,6 +5,7 @@ import type { ChallengeRequestNotification, CredentialAdmitNotification, CredentialGrantNotification, + DelegationRequestNotification, NotificationRecord, } from './notifications.slice'; import type { ContactRecord } from './contacts.slice'; @@ -142,6 +143,7 @@ const notificationExnSaid = (notification: NotificationRecord): string | null => notification.challengeRequest?.exnSaid ?? notification.credentialGrant?.grantSaid ?? notification.credentialAdmit?.admitSaid ?? + notification.delegationRequest?.delegateEventSaid ?? notification.anchorSaid; const isExchangeTombstoned = ( @@ -391,6 +393,11 @@ const byNewestCredentialAdmitTimestamp = ( right: CredentialAdmitNotification ): number => right.createdAt.localeCompare(left.createdAt); +const byNewestDelegationRequestTimestamp = ( + left: DelegationRequestNotification, + right: DelegationRequestNotification +): number => right.createdAt.localeCompare(left.createdAt); + /** Select credential grant notifications newest first. */ export const selectCredentialGrantNotifications = (state: RootState) => selectKeriaNotifications(state) @@ -428,6 +435,32 @@ export const selectCredentialAdmitNotifications = (state: RootState) => ) .sort(byNewestCredentialAdmitTimestamp); +/** Select delegator-side delegation request notifications newest first. */ +export const selectDelegationRequestNotifications = (state: RootState) => + selectKeriaNotifications(state) + .flatMap((notification) => + notification.delegationRequest === null || + notification.delegationRequest === undefined + ? [] + : [notification.delegationRequest] + ) + .sort(byNewestDelegationRequestTimestamp); + +/** Select delegation requests that still need manual approval. */ +export const selectActionableDelegationRequestNotifications = ( + state: RootState +) => + selectDelegationRequestNotifications(state).filter( + (notification) => notification.status === 'actionable' + ); + +/** Select one delegation request notification by KERIA notification id. */ +export const selectDelegationRequestNotificationById = + (notificationId: string) => + (state: RootState): DelegationRequestNotification | null => + selectKeriaNotificationById(notificationId)(state)?.delegationRequest ?? + null; + /** Select challenge-response records newest first. */ export const selectChallenges = (state: RootState) => state.challenges.ids diff --git a/src/workflows/delegations.op.ts b/src/workflows/delegations.op.ts new file mode 100644 index 00000000..bd04f00a --- /dev/null +++ b/src/workflows/delegations.op.ts @@ -0,0 +1,42 @@ +import type { Operation as EffectionOperation } from 'effection'; +import { AppServicesContext } from '../effects/contexts'; +import { + approveDelegationRequestService, + type ApproveDelegationInput, + type ApproveDelegationResult, +} from '../services/delegations.service'; +import { delegationRequestNotificationApproved } from '../state/notifications.slice'; +import { syncSessionInventoryOp } from './contacts.op'; + +export type { ApproveDelegationInput, ApproveDelegationResult }; + +/** + * Workflow command for manually approving one inbound delegation request. + */ +export function* approveDelegationRequestOp( + input: ApproveDelegationInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const result = yield* approveDelegationRequestService({ + client: services.runtime.requireConnectedClient(), + input, + logger: services.logger, + }); + const updatedAt = result.approvedAt; + + services.store.dispatch( + delegationRequestNotificationApproved({ + id: input.notificationId, + updatedAt, + message: 'Delegation request approved.', + }) + ); + + try { + yield* syncSessionInventoryOp(); + } catch { + // Live sync will retry; local approved state was already recorded. + } + + return result; +} diff --git a/src/workflows/identifiers.op.ts b/src/workflows/identifiers.op.ts index 0bfb0ef0..38ee523b 100644 --- a/src/workflows/identifiers.op.ts +++ b/src/workflows/identifiers.op.ts @@ -2,11 +2,13 @@ import type { Operation as EffectionOperation } from 'effection'; import { AppServicesContext } from '../effects/contexts'; import { createIdentifierService, + getIdentifierDelegationChainService, getIdentifierService, listIdentifiersService, rotateIdentifierService, } from '../services/identifiers.service'; import type { + IdentifierDelegationChainNode, IdentifierCreateDraft, IdentifierSummary, } from '../features/identifiers/identifierTypes'; @@ -16,6 +18,8 @@ import { identifierListLoaded, identifierRotated, } from '../state/identifiers.slice'; +import { operationPhaseChanged } from '../state/operations.slice'; +import type { IdentifierMutationResult } from '../services/identifiers.service'; /* * Application operations are the unit-of-work layer. They may read Effection @@ -63,6 +67,32 @@ export function* getIdentifierOp( return identifier; } +/** + * Resolve a delegation chain from a local identifier to the root delegator. + */ +export function* getIdentifierDelegationChainOp( + aid: string +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const state = services.store.getState(); + const localIdentifiers = state.identifiers.prefixes + .map((prefix) => state.identifiers.byPrefix[prefix]) + .filter( + (identifier): identifier is IdentifierSummary => + identifier !== undefined + ); + const contacts = state.contacts.ids + .map((id) => state.contacts.byId[id]) + .filter((contact) => contact !== undefined); + + return yield* getIdentifierDelegationChainService({ + client: services.runtime.requireConnectedClient(), + aid, + localIdentifiers, + contacts, + }); +} + /** * Create one identifier from a route draft, wait for KERIA completion, and * publish the refreshed identifier list. @@ -71,7 +101,7 @@ export function* createIdentifierOp( draft: IdentifierCreateDraft ): EffectionOperation { const services = yield* AppServicesContext.expect(); - const identifiers = yield* createIdentifierService({ + const result = yield* createIdentifierService({ client: services.runtime.requireConnectedClient(), config: services.config, draft, @@ -81,12 +111,48 @@ export function* createIdentifierOp( services.store.dispatch( identifierCreated({ name: draft.name.trim(), - identifiers, + identifiers: result.identifiers, updatedAt: new Date().toISOString(), }) ); - return identifiers; + return result.identifiers; +} + +/** + * Create one identifier as background work and return delegation details for + * the operation payload panel when the draft is delegated. + */ +export function* createIdentifierBackgroundOp( + draft: IdentifierCreateDraft, + requestId: string +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const result = yield* createIdentifierService({ + client: services.runtime.requireConnectedClient(), + config: services.config, + draft, + logger: services.logger, + onPhase: (phase, keriaOperationName) => { + services.store.dispatch( + operationPhaseChanged({ + requestId, + phase, + keriaOperationName, + }) + ); + }, + }); + + services.store.dispatch( + identifierCreated({ + name: draft.name.trim(), + identifiers: result.identifiers, + updatedAt: new Date().toISOString(), + }) + ); + + return result; } /** @@ -97,8 +163,9 @@ export function* rotateIdentifierOp( aid: string ): EffectionOperation { const services = yield* AppServicesContext.expect(); - const identifiers = yield* rotateIdentifierService({ + const result = yield* rotateIdentifierService({ client: services.runtime.requireConnectedClient(), + config: services.config, aid, logger: services.logger, }); @@ -106,10 +173,46 @@ export function* rotateIdentifierOp( services.store.dispatch( identifierRotated({ aid, - identifiers, + identifiers: result.identifiers, updatedAt: new Date().toISOString(), }) ); - return identifiers; + return result.identifiers; +} + +/** + * Rotate one identifier as background work and return delegation details when + * the identifier is delegated. + */ +export function* rotateIdentifierBackgroundOp( + aid: string, + requestId: string +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const result = yield* rotateIdentifierService({ + client: services.runtime.requireConnectedClient(), + config: services.config, + aid, + logger: services.logger, + onPhase: (phase, keriaOperationName) => { + services.store.dispatch( + operationPhaseChanged({ + requestId, + phase, + keriaOperationName, + }) + ); + }, + }); + + services.store.dispatch( + identifierRotated({ + aid, + identifiers: result.identifiers, + updatedAt: new Date().toISOString(), + }) + ); + + return result; } diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs index 5a8a457c..1018f0f4 100644 --- a/tests/browser-smoke.mjs +++ b/tests/browser-smoke.mjs @@ -167,7 +167,7 @@ try { hidden: true, timeout: 10000, }); - await page.waitForSelector('[data-testid="known-components"]', { + await page.waitForSelector('[data-testid="dashboard-view"]', { timeout: 10000, }); if (!page.url().endsWith('/dashboard')) { diff --git a/tests/contact-challenge-smoke.ts b/tests/contact-challenge-smoke.ts index 98241ab5..b46919ed 100644 --- a/tests/contact-challenge-smoke.ts +++ b/tests/contact-challenge-smoke.ts @@ -154,7 +154,7 @@ const connectBrowserAgent = async (page: Page): Promise => { hidden: true, timeout: 30_000, }); - await page.waitForSelector('[data-testid="known-components"]', { + await page.waitForSelector('[data-testid="dashboard-view"]', { timeout: 30_000, }); diff --git a/tests/contact-oobi-smoke.ts b/tests/contact-oobi-smoke.ts index 9d0819d4..4033ff2f 100644 --- a/tests/contact-oobi-smoke.ts +++ b/tests/contact-oobi-smoke.ts @@ -161,7 +161,7 @@ const connectBrowserAgent = async (page: Page): Promise => { hidden: true, timeout: 30_000, }); - await page.waitForSelector('[data-testid="known-components"]', { + await page.waitForSelector('[data-testid="dashboard-view"]', { timeout: 30_000, }); @@ -306,9 +306,8 @@ try { await navigateInApp( page, 'nav-dashboard', - '[data-testid="known-components"]' + '[data-testid="dashboard-view"]' ); - await waitForText(page, '[data-testid="known-components"]', 'Wan'); console.log( JSON.stringify( diff --git a/tests/scenarios/optional/delegation.test.ts b/tests/scenarios/optional/delegation.test.ts index f69570d0..6e35869f 100644 --- a/tests/scenarios/optional/delegation.test.ts +++ b/tests/scenarios/optional/delegation.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from 'vitest'; import { testConfig } from '../../support/config'; import { + addAgentEndRole, + createIdentifier, createRole, + type Role, resolveOobi, serderFromOperation, uniqueAlias, } from '../../support/keria'; +import { delegationAnchorFromEvent } from '../../../src/features/identifiers/delegationHelpers'; +import type { DelegationAnchor } from '../../../src/features/identifiers/delegationHelpers'; +import { DELEGATION_REQUEST_NOTIFICATION_ROUTE } from '../../../src/services/notifications.service'; /* * Optional delegation fixture scenario. @@ -29,6 +35,148 @@ const requireFixtureValue = (value: string | null, message: string): string => { return value; }; +const notificationItemsFromResponse = ( + raw: unknown +): Record[] => { + if (Array.isArray(raw)) { + return raw.filter( + (item): item is Record => + typeof item === 'object' && item !== null + ); + } + + if ( + typeof raw === 'object' && + raw !== null && + Array.isArray((raw as { notes?: unknown }).notes) + ) { + return (raw as { notes: unknown[] }).notes.filter( + (item): item is Record => + typeof item === 'object' && item !== null + ); + } + + return []; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const stringValue = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + +const embeddedDelegationEvent = ( + value: Record +): Record | null => { + for (const key of ['ked', 'event', 'evt', 'icp', 'dip', 'rot', 'drt']) { + const candidate = value[key]; + if (isRecord(candidate)) { + return candidate; + } + } + + const embeds = value.e; + if (isRecord(embeds)) { + for (const key of ['ked', 'event', 'evt', 'icp', 'dip', 'rot', 'drt']) { + const candidate = embeds[key]; + if (isRecord(candidate)) { + return candidate; + } + } + } + + return null; +}; + +interface ObservedDelegationRequest { + notificationId: string; + delegatorAid: string; + delegateAid: string; + delegateEventSaid: string; + sequence: string; + anchor: DelegationAnchor; + sourceAid: string | null; + createdAt: string; +} + +const requestFromPayload = ({ + notification, + payload, + sourceAid, +}: { + notification: Record; + payload: Record; + sourceAid: string | null; +}): ObservedDelegationRequest | null => { + const event = embeddedDelegationEvent(payload); + if (event === null) { + return null; + } + + const anchor = delegationAnchorFromEvent(event); + const delegatorAid = stringValue(payload.delpre) ?? stringValue(event.di); + if (delegatorAid === null) { + return null; + } + + return { + notificationId: stringValue(notification.i) ?? anchor.d, + delegatorAid, + delegateAid: anchor.i, + delegateEventSaid: anchor.d, + sequence: anchor.s, + anchor, + sourceAid, + createdAt: + stringValue(payload.dt) ?? + stringValue(notification.dt) ?? + new Date().toISOString(), + }; +}; + +const requestFromNotification = ( + note: Record +): ObservedDelegationRequest | null => { + const attrs = isRecord(note.a) ? note.a : {}; + const route = stringValue(attrs.r); + + if (route !== DELEGATION_REQUEST_NOTIFICATION_ROUTE) { + return null; + } + + return requestFromPayload({ + notification: note, + payload: attrs, + sourceAid: stringValue(attrs.src) ?? stringValue(attrs.i), + }); +}; + +const waitForDelegationRequestNotification = async ( + role: Role, + delegatorAid: string, + delegateEventSaid: string, + timeoutMs = 30_000 +): Promise => { + const timeoutAt = Date.now() + timeoutMs; + + while (Date.now() < timeoutAt) { + const response = await role.client.notifications().list(); + for (const note of notificationItemsFromResponse(response)) { + const request = requestFromNotification(note); + if ( + request?.delegatorAid === delegatorAid && + request.delegateEventSaid === delegateEventSaid + ) { + return request; + } + } + + await new Promise((resolve) => globalThis.setTimeout(resolve, 1000)); + } + + throw new Error('Delegation request notification was not delivered.'); +}; + describe.sequential('delegation fixture', () => { it.skipIf(!hasDelegationConfig)( 'creates a delegated AID when a delegator fixture is configured', @@ -57,4 +205,76 @@ describe.sequential('delegation fixture', () => { }, 180_000 ); + + it.skipIf(!delegationConfig.autoApprove)( + 'auto-approves delegation only when the explicit test flag is enabled', + async () => { + const delegator = await createRole('delegator'); + const delegate = await createRole('delegate'); + const delegatorAlias = uniqueAlias('delegator'); + const delegatorAid = await createIdentifier( + delegator, + delegatorAlias + ); + const delegatorOobi = await addAgentEndRole( + delegator, + delegatorAlias + ); + await resolveOobi(delegate, delegatorOobi, delegatorAlias); + + const delegateContactAlias = uniqueAlias('delegate-contact'); + await createIdentifier(delegate, delegateContactAlias); + const delegateOobi = await addAgentEndRole( + delegate, + delegateContactAlias + ); + await resolveOobi(delegator, delegateOobi, delegateContactAlias); + + const delegateAlias = uniqueAlias('delegated'); + const result = await delegate.client + .identifiers() + .create(delegateAlias, { delpre: delegatorAid.prefix }); + const operation = await result.op(); + const expectedAnchor = delegationAnchorFromEvent(result.serder.sad); + const request = await waitForDelegationRequestNotification( + delegator, + delegatorAid.prefix, + expectedAnchor.d + ); + const anchor = request.anchor; + + expect(request.delegatorAid).toBe(delegatorAid.prefix); + expect(request.delegateAid).toBe(anchor.i); + expect(request.delegateEventSaid).toBe(anchor.d); + expect(request.sequence).toBe(anchor.s); + + const queryOperation = await delegate.client + .keyStates() + .query(delegatorAid.prefix, undefined, anchor); + const approval = await delegator.client + .delegations() + .approve(delegatorAlias, anchor); + + await Promise.all([ + delegate.waitOperation( + queryOperation, + `queries approval for ${delegateAlias}` + ), + delegator.waitEvent( + approval, + `approves delegation for ${delegateAlias}` + ), + ]); + await delegate.waitOperation( + operation, + `completes delegated ${delegateAlias}` + ); + + const delegated = await delegate.client + .identifiers() + .get(delegateAlias); + expect(delegated.state.di).toBe(delegatorAid.prefix); + }, + 180_000 + ); }); diff --git a/tests/support/config.ts b/tests/support/config.ts index c18ed6ae..0ff65bac 100644 --- a/tests/support/config.ts +++ b/tests/support/config.ts @@ -16,6 +16,7 @@ export type TestRuntimeEnv = Record; export interface DelegationFixtureConfig { delegatorPre: string | null; delegatorOobi: string | null; + autoApprove: boolean; } /** @@ -75,6 +76,8 @@ export const buildTestConfig = (runtimeEnv: TestRuntimeEnv): TestConfig => ({ delegation: { delegatorPre: optionalString(runtimeEnv.VITE_DELEGATOR_PRE), delegatorOobi: optionalString(runtimeEnv.VITE_DELEGATOR_OOBI), + autoApprove: + runtimeEnv.VITE_TEST_AUTO_APPROVE_DELEGATIONS === 'true', }, multisig: { memberOobis: csvFromEnv(runtimeEnv.VITE_MULTISIG_MEMBER_OOBIS), diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index b300e815..e43d880f 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -19,6 +19,7 @@ describe('buildAppConfig', () => { ]); expect(config.operations).toEqual({ timeoutMs: 30000, + delegationApprovalTimeoutMs: 300000, minSleepMs: 1000, maxSleepMs: 5000, liveRefreshMs: 3000, @@ -52,6 +53,7 @@ describe('buildAppConfig', () => { VITE_CLOUD_KERIA_BOOT_URL: 'https://cloud.example.test/boot', VITE_CLOUD_KERIA_CONNECTION_LABEL: 'Cloud Demo', VITE_OPERATION_TIMEOUT_MS: '45000', + VITE_DELEGATION_APPROVAL_TIMEOUT_MS: '180000', VITE_OPERATION_MIN_SLEEP_MS: '250', VITE_OPERATION_MAX_SLEEP_MS: '2000', VITE_LIVE_REFRESH_MS: '750', @@ -85,6 +87,7 @@ describe('buildAppConfig', () => { }, ]); expect(config.operations.timeoutMs).toBe(45000); + expect(config.operations.delegationApprovalTimeoutMs).toBe(180000); expect(config.operations.minSleepMs).toBe(250); expect(config.operations.maxSleepMs).toBe(2000); expect(config.operations.liveRefreshMs).toBe(750); diff --git a/tests/unit/delegationsService.test.ts b/tests/unit/delegationsService.test.ts new file mode 100644 index 00000000..3c4fded1 --- /dev/null +++ b/tests/unit/delegationsService.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SignifyClient } from 'signify-ts'; +import { createAppRuntime } from '../../src/app/runtime'; +import { + approveDelegationRequestService, + type ApproveDelegationInput, +} from '../../src/services/delegations.service'; + +const request = { + notificationId: 'delegate-note-1', + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-event', + sequence: '0', + anchor: { + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + }, + sourceAid: 'Edelegate', + createdAt: '2026-04-22T00:00:00.000Z', + status: 'actionable' as const, +}; + +const input: ApproveDelegationInput = { + notificationId: request.notificationId, + delegatorName: 'delegator', + request, +}; + +const makeClient = ({ prefix = 'Edelegator' }: { prefix?: string } = {}) => { + const approve = vi.fn(async () => ({ + op: vi.fn(async () => ({ + name: 'delegation.Edelegate-event', + })), + })); + const get = vi.fn(async () => ({ name: 'delegator', prefix })); + const wait = vi.fn(async () => ({ + name: 'delegation.Edelegate-event', + done: true, + })); + const deleteNotification = vi.fn(async () => undefined); + const mark = vi.fn(async () => undefined); + const client = { + identifiers: () => ({ get }), + delegations: () => ({ approve }), + operations: () => ({ wait }), + notifications: () => ({ + delete: deleteNotification, + mark, + }), + } as unknown as SignifyClient; + + return { client, approve, get, wait, deleteNotification, mark }; +}; + +const runApprove = async (client: SignifyClient) => { + const runtime = createAppRuntime({ storage: null }); + try { + return await runtime.runWorkflow( + () => approveDelegationRequestService({ client, input }), + { scope: 'app', track: false } + ); + } finally { + await runtime.destroy(); + } +}; + +describe('approveDelegationRequestService', () => { + it('validates local delegator ownership and approves with the reusable anchor', async () => { + const { client, approve, get, wait, deleteNotification } = makeClient(); + + await expect(runApprove(client)).resolves.toMatchObject({ + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-event', + sequence: '0', + requestedAt: request.createdAt, + }); + expect(get).toHaveBeenCalledWith('delegator'); + expect(approve).toHaveBeenCalledWith('delegator', request.anchor); + expect(wait).toHaveBeenCalledOnce(); + expect(deleteNotification).toHaveBeenCalledWith('delegate-note-1'); + }); + + it('rejects approvals when the selected identifier is not the delegator', async () => { + const { client, approve } = makeClient({ prefix: 'Eother' }); + + await expect(runApprove(client)).rejects.toThrow( + 'Delegation request delegator does not match' + ); + expect(approve).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/identifierHelpers.test.ts b/tests/unit/identifierHelpers.test.ts index 6ba057e8..8c48ad8a 100644 --- a/tests/unit/identifierHelpers.test.ts +++ b/tests/unit/identifierHelpers.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from 'vitest'; import { Algos, type HabState, type KeyState } from 'signify-ts'; import { buildAppConfig } from '../../src/config'; +import { + delegationAnchorFromEvent, + delegationAnchorFromNotification, +} from '../../src/features/identifiers/delegationHelpers'; import { defaultIdentifierCreateDraft, formatIdentifierMetadata, identifierCurrentKey, identifierCurrentKeys, identifierCreateDraftToArgs, + identifierDelegatorOptions, identifierIdentifierIndex, identifierJson, identifierKeyIndex, @@ -18,6 +23,7 @@ import { truncateMiddle, } from '../../src/features/identifiers/identifierHelpers'; import type { IdentifierCreateDraft } from '../../src/features/identifiers/identifierTypes'; +import type { ContactRecord } from '../../src/state/contacts.slice'; const keyState = (prefix: string): KeyState => ({ i: prefix, @@ -77,6 +83,27 @@ const randyIdentifier = (name: string, prefix: string): HabState => ({ }, }); +const contact = ( + id: string, + alias: string, + aid: string | null, + overrides: Partial = {} +): ContactRecord => ({ + id, + alias, + aid, + oobi: null, + endpoints: [], + wellKnowns: [], + componentTags: [], + challengeCount: 0, + authenticatedChallengeCount: 0, + resolutionStatus: 'resolved', + error: null, + updatedAt: '2026-04-21T00:00:00.000Z', + ...overrides, +}); + describe('identifiersFromResponse', () => { it('normalizes direct identifier arrays', () => { const identifiers = [identifier('alice', 'Ealice')]; @@ -95,11 +122,13 @@ describe('identifiersFromResponse', () => { it('filters malformed entries from recognized response containers', () => { const valid = identifier('carol', 'Ecarol'); - expect(identifiersFromResponse([valid, { name: 'missing-prefix' }])).toEqual([ - valid, - ]); expect( - identifiersFromResponse({ aids: [{ prefix: 'missing-name' }, valid] }) + identifiersFromResponse([valid, { name: 'missing-prefix' }]) + ).toEqual([valid]); + expect( + identifiersFromResponse({ + aids: [{ prefix: 'missing-name' }, valid], + }) ).toEqual([valid]); }); @@ -189,6 +218,55 @@ describe('replaceIdentifierSummary', () => { }); }); +describe('identifierDelegatorOptions', () => { + it('includes local identifiers and non-witness contacts only', () => { + const options = identifierDelegatorOptions( + [identifier('root', 'Eroot')], + [ + contact('Eroot-contact', 'Duplicate root', 'Eroot'), + contact('Edelegator', 'Delegator', 'Edelegator'), + contact('Ewitness', 'Witness', 'Ewitness', { + oobi: 'http://127.0.0.1:5642/oobi/Ewitness/controller?tag=witness', + }), + contact('Etagged-witness', 'Tagged witness', 'Etagged', { + componentTags: ['witness'], + }), + contact('Ewitnessed', 'Witnessed identifier', 'Ewitnessed', { + componentTags: ['agent'], + endpoints: [ + { + role: 'witness', + eid: 'Bwitness', + scheme: 'http', + url: 'http://127.0.0.1:5642', + }, + ], + }), + ] + ); + + expect(options.map((option) => option.aid)).toEqual([ + 'Eroot', + 'Edelegator', + 'Ewitnessed', + ]); + expect(options).toEqual([ + expect.objectContaining({ + aid: 'Eroot', + source: 'local', + }), + expect.objectContaining({ + aid: 'Edelegator', + source: 'contact', + }), + expect.objectContaining({ + aid: 'Ewitnessed', + source: 'contact', + }), + ]); + }); +}); + describe('identifier create drafts', () => { const config = buildAppConfig({}); @@ -257,15 +335,31 @@ describe('identifier create drafts', () => { }); }); + it('maps delegated drafts to Signify delpre', () => { + const draft: IdentifierCreateDraft = { + ...defaultIdentifierCreateDraft(), + name: 'delegated', + delegation: { + mode: 'delegated', + delegatorAid: ' Edelegator ', + }, + }; + + expect(isIdentifierCreateDraft(draft)).toBe(true); + expect(identifierCreateDraftToArgs(draft, config)).toMatchObject({ + delpre: 'Edelegator', + }); + }); + it('omits empty branch material and preserves non-empty salty branch material', () => { const baseDraft: IdentifierCreateDraft = { ...defaultIdentifierCreateDraft(), name: 'branch', }; - expect(identifierCreateDraftToArgs(baseDraft, config)).not.toHaveProperty( - 'bran' - ); + expect( + identifierCreateDraftToArgs(baseDraft, config) + ).not.toHaveProperty('bran'); expect( identifierCreateDraftToArgs( { ...baseDraft, bran: ' branch-code ' }, @@ -287,10 +381,70 @@ describe('identifier create drafts', () => { expect(isIdentifierCreateDraft({ ...draft, algo: Algos.group })).toBe( false ); - expect(isIdentifierCreateDraft({ ...draft, witnessMode: 'custom' })).toBe( - false - ); + expect( + isIdentifierCreateDraft({ ...draft, witnessMode: 'custom' }) + ).toBe(false); expect(isIdentifierCreateDraft({ ...draft, count: 0 })).toBe(false); expect(isIdentifierCreateDraft({ ...draft, isith: '' })).toBe(false); + expect( + isIdentifierCreateDraft({ + ...draft, + delegation: { mode: 'delegated', delegatorAid: '' }, + }) + ).toBe(false); + }); +}); + +describe('delegation helpers', () => { + it('creates anchors from delegated inception and rotation events', () => { + expect( + delegationAnchorFromEvent({ + t: 'dip', + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + di: 'Edelegator', + }) + ).toEqual({ + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + }); + expect( + delegationAnchorFromEvent({ + t: 'drt', + i: 'Edelegate', + s: '1', + d: 'Edelegate-rotation', + }) + ).toEqual({ + i: 'Edelegate', + s: '1', + d: 'Edelegate-rotation', + }); + }); + + it('creates anchors from delegation request notifications', () => { + expect( + delegationAnchorFromNotification({ + notificationId: 'note-1', + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Eevent', + sequence: '0', + anchor: { + i: 'Edelegate', + s: '0', + d: 'Eevent', + }, + sourceAid: 'Edelegate', + createdAt: '2026-04-22T00:00:00.000Z', + status: 'actionable', + }) + ).toEqual({ + i: 'Edelegate', + s: '0', + d: 'Eevent', + }); }); }); diff --git a/tests/unit/notificationsService.test.ts b/tests/unit/notificationsService.test.ts index c29b864e..0b8fb29e 100644 --- a/tests/unit/notificationsService.test.ts +++ b/tests/unit/notificationsService.test.ts @@ -7,6 +7,7 @@ import { } from '../../src/services/challenges.service'; import { challengeRequestFromExchange, + DELEGATION_REQUEST_NOTIFICATION_ROUTE, listNotificationsService, notificationRecordsFromResponse, } from '../../src/services/notifications.service'; @@ -384,4 +385,176 @@ describe('notification service helpers', () => { }, ]); }); + + it('hydrates direct delegation requests as actionable for local delegators', async () => { + const { client } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'delegate-note-1', + dt: loadedAt, + r: false, + a: { + r: DELEGATION_REQUEST_NOTIFICATION_ROUTE, + delpre: 'Edelegator', + src: 'Edelegate', + ked: { + t: 'dip', + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + di: 'Edelegator', + }, + }, + }, + ], + }, + }); + + const snapshot = await runListNotifications(client, [], ['Edelegator']); + + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'delegate-note-1', + route: DELEGATION_REQUEST_NOTIFICATION_ROUTE, + status: 'unread', + delegationRequest: { + notificationId: 'delegate-note-1', + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-event', + sequence: '0', + anchor: { + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + }, + sourceAid: 'Edelegate', + createdAt: loadedAt, + status: 'actionable', + }, + }), + ]); + }); + + it('does not hydrate delegation requests from generic exchange notifications', async () => { + const { client } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'delegate-exn-note', + dt: loadedAt, + r: false, + a: { + r: '/exn', + d: 'Edelegate-exn', + }, + }, + ], + }, + exchange: { + exn: { + d: 'Edelegate-exn', + i: 'Edelegate', + dt: loadedAt, + r: DELEGATION_REQUEST_NOTIFICATION_ROUTE, + a: { + delpre: 'Edelegator', + }, + e: { + evt: { + t: 'dip', + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + di: 'Edelegator', + }, + }, + }, + }, + }); + + const snapshot = await runListNotifications(client, [], ['Edelegator']); + + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'delegate-exn-note', + route: '/exn', + status: 'unread', + delegationRequest: null, + }), + ]); + }); + + it('marks delegation requests read when the delegator is not local', async () => { + const { client, notifications } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'delegate-note-2', + dt: loadedAt, + r: false, + a: { + r: DELEGATION_REQUEST_NOTIFICATION_ROUTE, + delpre: 'Edelegator', + ked: { + t: 'drt', + i: 'Edelegate', + s: '1', + d: 'Edelegate-rotation', + di: 'Edelegator', + }, + }, + }, + ], + }, + }); + + const snapshot = await runListNotifications(client, [], ['Eother']); + + expect(notifications.mark).toHaveBeenCalledWith('delegate-note-2'); + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'delegate-note-2', + read: true, + status: 'processed', + delegationRequest: expect.objectContaining({ + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-rotation', + sequence: '1', + status: 'notForThisWallet', + }), + }), + ]); + }); + + it('reports malformed delegation request payloads', async () => { + const { client } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'delegate-note-3', + dt: loadedAt, + r: false, + a: { + r: DELEGATION_REQUEST_NOTIFICATION_ROUTE, + delpre: 'Edelegator', + }, + }, + ], + }, + }); + + const snapshot = await runListNotifications(client, [], ['Edelegator']); + + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'delegate-note-3', + status: 'error', + delegationRequest: null, + message: expect.stringContaining('Delegation event'), + }), + ]); + }); }); diff --git a/tests/unit/routeData.test.ts b/tests/unit/routeData.test.ts index 72698c37..5385dca5 100644 --- a/tests/unit/routeData.test.ts +++ b/tests/unit/routeData.test.ts @@ -124,6 +124,11 @@ const makeRuntime = ( operationRoute: '/operations/verify-challenge-request-1', })), dismissExchangeNotification: vi.fn(async () => undefined), + startApproveDelegation: vi.fn(() => ({ + status: 'accepted', + requestId: 'approve-delegation-request-1', + operationRoute: '/operations/approve-delegation-request-1', + })), startResolveCredentialSchema: vi.fn(() => ({ status: 'accepted', requestId: 'resolve-schema-request-1', @@ -606,6 +611,54 @@ describe('route actions', () => { ); }); + it('starts delegation approval through the notifications action', async () => { + const runtime = makeRuntime(); + + await expect( + notificationsAction( + runtime, + makeRequest('/notifications/delegate-note-1', { + intent: 'approveDelegationRequest', + requestId: 'approve-delegation-request-1', + notificationId: 'delegate-note-1', + delegatorName: 'delegator', + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-event', + sequence: '0', + sourceAid: 'Edelegate', + createdAt: '2026-04-22T00:00:00.000Z', + }) + ) + ).resolves.toEqual({ + intent: 'approveDelegationRequest', + ok: true, + message: 'Approving delegation for Edelegate', + requestId: 'approve-delegation-request-1', + operationRoute: '/operations/approve-delegation-request-1', + }); + expect(runtime.startApproveDelegation).toHaveBeenCalledWith( + { + notificationId: 'delegate-note-1', + delegatorName: 'delegator', + request: expect.objectContaining({ + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Edelegate-event', + sequence: '0', + anchor: { + i: 'Edelegate', + s: '0', + d: 'Edelegate-event', + }, + }), + }, + expect.objectContaining({ + requestId: 'approve-delegation-request-1', + }) + ); + }); + it('passes challenge notification metadata through responses', async () => { const runtime = makeRuntime(); const words = Array.from({ length: 12 }, (_, index) => `word${index}`); diff --git a/tests/unit/runtimeWorkflow.test.ts b/tests/unit/runtimeWorkflow.test.ts index 68444128..949e2193 100644 --- a/tests/unit/runtimeWorkflow.test.ts +++ b/tests/unit/runtimeWorkflow.test.ts @@ -2,9 +2,12 @@ import { sleep } from 'effection'; import { describe, expect, it, vi } from 'vitest'; import type { SignifyClient } from 'signify-ts'; import { createAppRuntime, type AppRuntime } from '../../src/app/runtime'; +import type { IdentifierSummary } from '../../src/features/identifiers/identifierTypes'; import { appNotificationRecorded } from '../../src/state/appNotifications.slice'; import { storedChallengeWordsRecorded } from '../../src/state/challenges.slice'; +import { contactResolved } from '../../src/state/contacts.slice'; import { exchangeTombstoneRecorded } from '../../src/state/exchangeTombstones.slice'; +import { identifierListLoaded } from '../../src/state/identifiers.slice'; import { operationStarted } from '../../src/state/operations.slice'; import { persistedAppStateKey, @@ -390,6 +393,99 @@ describe('AppRuntime workflow bridge', () => { await runtime.destroy(); }); + it('shows known aliases before delegated operation AID payload values', async () => { + const store = createAppStore(); + const runtime = createAppRuntime({ store, storage: null }); + + store.dispatch( + contactResolved({ + id: 'delegator', + alias: 'Root Delegator', + aid: 'Edelegator', + oobi: null, + updatedAt: '2026-04-21T00:00:00.000Z', + }) + ); + store.dispatch( + identifierListLoaded({ + identifiers: [ + { + name: 'Leaf Delegate', + prefix: 'Edelegate', + }, + ] as IdentifierSummary[], + loadedAt: '2026-04-21T00:00:00.000Z', + }) + ); + + runtime.startBackgroundWorkflow( + function* () { + yield* sleep(0); + return { + delegation: { + delegatorAid: 'Edelegator', + delegateAid: 'Edelegate', + delegateEventSaid: 'Eevent', + sequence: '0', + requestedAt: '2026-04-21T00:00:01.000Z', + }, + }; + }, + { + requestId: 'delegation-success', + label: 'Creating delegated identifier...', + title: 'Create delegated identifier', + kind: 'createDelegatedIdentifier', + resourceKeys: ['delegation:delegate:Edelegate'], + successNotification: { + title: 'Delegated identifier created', + message: 'Delegation completed.', + }, + } + ); + + await vi.waitFor(() => { + expect( + store.getState().operations.byId['delegation-success'] + ).toMatchObject({ + status: 'success', + payloadDetails: expect.arrayContaining([ + expect.objectContaining({ + label: 'Delegator AID', + value: 'Edelegator', + displayValue: 'Root Delegator (Edelegator)', + }), + expect.objectContaining({ + label: 'Delegate AID', + value: 'Edelegate', + displayValue: 'Leaf Delegate (Edelegate)', + }), + ]), + }); + }); + + const notificationId = store.getState().appNotifications.ids[0]; + expect(notificationId).toBeDefined(); + expect( + store.getState().appNotifications.byId[notificationId] + ).toMatchObject({ + payloadDetails: expect.arrayContaining([ + expect.objectContaining({ + label: 'Delegator AID', + value: 'Edelegator', + displayValue: 'Root Delegator (Edelegator)', + }), + expect.objectContaining({ + label: 'Delegate AID', + value: 'Edelegate', + displayValue: 'Leaf Delegate (Edelegate)', + }), + ]), + }); + + await runtime.destroy(); + }); + it('rejects background workflows with active resource conflicts', async () => { const store = createAppStore(); const runtime = createAppRuntime({ store, storage: null }); diff --git a/tests/unit/testConfig.test.ts b/tests/unit/testConfig.test.ts index 0725bb79..3074b26e 100644 --- a/tests/unit/testConfig.test.ts +++ b/tests/unit/testConfig.test.ts @@ -8,6 +8,7 @@ describe('buildTestConfig', () => { expect(config.fixtures.delegation).toEqual({ delegatorPre: null, delegatorOobi: null, + autoApprove: false, }); expect(config.fixtures.multisig.memberOobis).toEqual([]); }); @@ -16,6 +17,7 @@ describe('buildTestConfig', () => { const config = buildTestConfig({ VITE_DELEGATOR_PRE: 'delegator-aid', VITE_DELEGATOR_OOBI: 'http://delegator.example.test/oobi', + VITE_TEST_AUTO_APPROVE_DELEGATIONS: 'true', VITE_MULTISIG_MEMBER_OOBIS: 'http://one.example/oobi, http://two.example/oobi', }); @@ -23,6 +25,7 @@ describe('buildTestConfig', () => { expect(config.fixtures.delegation).toEqual({ delegatorPre: 'delegator-aid', delegatorOobi: 'http://delegator.example.test/oobi', + autoApprove: true, }); expect(config.fixtures.multisig.memberOobis).toEqual([ 'http://one.example/oobi',