diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 6b64a8b0..e359d783 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -21,6 +21,7 @@ import { selectAppNotifications, selectIdentifiers, selectUnreadAppNotifications, + selectUnreadW3CVcGrantNotifications, } from '../state/selectors'; /** @@ -61,6 +62,7 @@ const RootLayoutContent = () => { const multisigRequests = useAppSelector( selectActionableMultisigRequestNotifications ); + const w3cVcGrants = useAppSelector(selectUnreadW3CVcGrantNotifications); const identifiers = useAppSelector(selectIdentifiers); const connectDialogOpen = connectOpen && connection.status !== 'connected'; const pending = derivePendingState({ @@ -84,6 +86,7 @@ const RootLayoutContent = () => { recentNotifications={appNotifications} challengeRequests={challengeRequests} credentialGrants={credentialGrants} + w3cVcGrants={w3cVcGrants} delegationRequests={delegationRequests} multisigRequests={multisigRequests} identifiers={identifiers} @@ -91,6 +94,7 @@ const RootLayoutContent = () => { unreadAppNotifications.length + challengeRequests.length + credentialGrants.length + + w3cVcGrants.length + delegationRequests.length + multisigRequests.length } diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx index 3d696cd6..669b13c5 100644 --- a/src/app/TopBar.tsx +++ b/src/app/TopBar.tsx @@ -12,7 +12,10 @@ import type { DelegationRequestNotification, MultisigRequestNotification, } from '../state/notifications.slice'; -import type { CredentialGrantNotification } from '../domain/credentials/credentialTypes'; +import type { + CredentialGrantNotification, + W3CVcGrantNotification, +} from '../domain/credentials/credentialTypes'; import type { OperationRecord } from '../state/operations.slice'; import type { IdentifierSummary } from '../domain/identifiers/identifierTypes'; import { allAppNotificationsRead } from '../state/appNotifications.slice'; @@ -58,6 +61,8 @@ export interface TopBarProps { challengeRequests: readonly ChallengeRequestNotification[]; /** Actionable credential grants discovered from KERIA notifications. */ credentialGrants: readonly CredentialGrantNotification[]; + /** Informational W3C VC-JWT grants discovered from KERIA notifications. */ + w3cVcGrants: readonly W3CVcGrantNotification[]; /** Actionable delegation requests discovered from KERIA notifications. */ delegationRequests: readonly DelegationRequestNotification[]; /** Actionable multisig requests discovered from KERIA notifications. */ @@ -85,6 +90,7 @@ export const TopBar = ({ recentNotifications, challengeRequests, credentialGrants, + w3cVcGrants, delegationRequests, multisigRequests, identifiers, @@ -117,6 +123,7 @@ export const TopBar = ({ [recentNotifications] ); const credentialFetcher = useFetcher(); + const markReadFetcher = useFetcher(); const delegationFetcher = useFetcher(); const multisigFetcher = useFetcher(); const visibleChallengeRequests = useMemo( @@ -127,6 +134,10 @@ export const TopBar = ({ () => credentialGrants.slice(0, 3), [credentialGrants] ); + const visibleW3CVcGrants = useMemo( + () => w3cVcGrants.slice(0, 3), + [w3cVcGrants] + ); const visibleDelegationRequests = useMemo( () => delegationRequests.slice(0, 3), [delegationRequests] @@ -273,6 +284,18 @@ export const TopBar = ({ closeNotifications(); }; + const markW3CGrantRead = (grant: W3CVcGrantNotification) => { + const formData = new FormData(); + formData.set('intent', 'markNotificationRead'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', grant.notificationId); + markReadFetcher.submit(formData, { + method: 'post', + action: '/notifications', + }); + closeNotifications(); + }; + const submitMultisigRequest = ( request: MultisigRequestNotification, groupAlias: string, @@ -326,6 +349,7 @@ export const TopBar = ({ visibleNotifications={visibleNotifications} visibleChallengeRequests={visibleChallengeRequests} visibleCredentialGrants={visibleCredentialGrants} + visibleW3CVcGrants={visibleW3CVcGrants} visibleDelegationRequests={visibleDelegationRequests} visibleMultisigRequests={visibleMultisigRequests} identifiers={identifiers} @@ -334,10 +358,12 @@ export const TopBar = ({ multisigLocalMemberOptions={multisigLocalMemberOptions} canDismissChallengeRequests={dismissFetcher.state === 'idle'} canSubmitCredentialGrants={credentialFetcher.state === 'idle'} + canMarkW3CGrantsRead={markReadFetcher.state === 'idle'} canSubmitDelegationRequests={delegationFetcher.state === 'idle'} canSubmitMultisigRequests={multisigFetcher.state === 'idle'} dismissChallengeRequest={dismissChallengeRequest} admitCredentialGrant={admitCredentialGrant} + markW3CGrantRead={markW3CGrantRead} approveDelegationRequest={approveDelegationRequest} submitMultisigRequest={submitMultisigRequest} onClose={closeNotifications} diff --git a/src/app/TopBarNotificationsPopover.tsx b/src/app/TopBarNotificationsPopover.tsx index 44cf5975..3da853ae 100644 --- a/src/app/TopBarNotificationsPopover.tsx +++ b/src/app/TopBarNotificationsPopover.tsx @@ -29,7 +29,10 @@ import type { DelegationRequestNotification, MultisigRequestNotification, } from '../state/notifications.slice'; -import type { CredentialGrantNotification } from '../domain/credentials/credentialTypes'; +import type { + CredentialGrantNotification, + W3CVcGrantNotification, +} from '../domain/credentials/credentialTypes'; import type { IdentifierSummary } from '../domain/identifiers/identifierTypes'; import { ChallengeRequestResponseForm } from '../features/notifications/ChallengeRequestResponseForm'; import { abbreviateMiddle } from '../domain/contacts/contactHelpers'; @@ -65,6 +68,7 @@ interface TopBarNotificationsPopoverProps { visibleNotifications: readonly AppNotificationRecord[]; visibleChallengeRequests: readonly ChallengeRequestNotification[]; visibleCredentialGrants: readonly CredentialGrantNotification[]; + visibleW3CVcGrants: readonly W3CVcGrantNotification[]; visibleDelegationRequests: readonly DelegationRequestNotification[]; visibleMultisigRequests: readonly MultisigRequestNotification[]; identifiers: readonly IdentifierSummary[]; @@ -74,10 +78,12 @@ interface TopBarNotificationsPopoverProps { multisigLocalMemberOptions: ReturnType; canDismissChallengeRequests: boolean; canSubmitCredentialGrants: boolean; + canMarkW3CGrantsRead: boolean; canSubmitDelegationRequests: boolean; canSubmitMultisigRequests: boolean; dismissChallengeRequest: (request: ChallengeRequestNotification) => void; admitCredentialGrant: (grant: CredentialGrantNotification) => void; + markW3CGrantRead: (grant: W3CVcGrantNotification) => void; approveDelegationRequest: (request: DelegationRequestNotification) => void; submitMultisigRequest: ( request: MultisigRequestNotification, @@ -97,6 +103,7 @@ export const TopBarNotificationsPopover = ({ visibleNotifications, visibleChallengeRequests, visibleCredentialGrants, + visibleW3CVcGrants, visibleDelegationRequests, visibleMultisigRequests, identifiers, @@ -105,10 +112,12 @@ export const TopBarNotificationsPopover = ({ multisigLocalMemberOptions, canDismissChallengeRequests, canSubmitCredentialGrants, + canMarkW3CGrantsRead, canSubmitDelegationRequests, canSubmitMultisigRequests, dismissChallengeRequest, admitCredentialGrant, + markW3CGrantRead, approveDelegationRequest, submitMultisigRequest, onClose, @@ -124,6 +133,7 @@ export const TopBarNotificationsPopover = ({ {recentNotifications.length === 0 && visibleChallengeRequests.length === 0 && visibleCredentialGrants.length === 0 && + visibleW3CVcGrants.length === 0 && visibleDelegationRequests.length === 0 && visibleMultisigRequests.length === 0 ? ( ); })} + {visibleW3CVcGrants.map((grant) => { + const holder = identifiers.find( + (identifier) => identifier.prefix === grant.holderAid + ); + const holderLabel = + holder === undefined + ? abbreviateMiddle(grant.holderAid, 28) + : `${holder.name} / ${abbreviateMiddle( + grant.holderAid, + 20 + )}`; + + return ( + + + + + + W3C VC-JWT grant + + + From{' '} + {abbreviateMiddle( + grant.issuerAid, + 28 + )} + + + Holder {holderLabel} + + + Status {grant.status} + + + Source{' '} + {abbreviateMiddle( + grant.sourceCredentialSaid, + 28 + )} + + {formatTimestamp( + grant.createdAt + ) !== null && ( + + {formatTimestamp( + grant.createdAt + )} + + )} + + + + + + + + + + ); + })} {(visibleChallengeRequests.length > 0 || visibleDelegationRequests.length > 0 || visibleCredentialGrants.length > 0 || + visibleW3CVcGrants.length > 0 || visibleMultisigRequests.length > 0) && visibleNotifications.length > 0 && ( @@ -869,6 +1071,9 @@ export const TopBarNotificationsPopover = ({ > {formatTimestamp( diff --git a/src/app/routeData.contacts.ts b/src/app/routeData.contacts.ts index ece8f9ba..00dafdb7 100644 --- a/src/app/routeData.contacts.ts +++ b/src/app/routeData.contacts.ts @@ -40,6 +40,7 @@ const contactIntentFromString = (value: string): ContactIntent => value === 'respondChallenge' || value === 'verifyChallenge' || value === 'dismissExchangeNotification' || + value === 'markNotificationRead' || value === 'approveDelegationRequest' || value === 'delete' || value === 'updateAlias' @@ -442,6 +443,37 @@ const dismissExchangeNotificationAction = async ({ }; }; +const markNotificationReadAction = async ({ + runtime, + request, + formData, + requestId, +}: ContactActionContext): Promise => { + const intent = 'markNotificationRead'; + const notificationId = formString(formData, 'notificationId').trim(); + if (notificationId.length === 0) { + return { + intent, + ok: false, + message: 'Notification id is required.', + requestId, + }; + } + + await runtime.notifications.markRead( + { notificationId }, + { ...requestIdOption(requestId), signal: request.signal } + ); + + return { + intent, + ok: true, + message: 'Notification marked read.', + requestId, + operationRoute: '/notifications', + }; +}; + /** * Converts an actionable delegation notification into the approve-delegation * workflow input. The handler rebuilds the anchor from submitted fields so the @@ -588,6 +620,8 @@ const runContactIntentAction = ( return verifyChallengeAction(context); case 'dismissExchangeNotification': return dismissExchangeNotificationAction(context); + case 'markNotificationRead': + return markNotificationReadAction(context); case 'approveDelegationRequest': return approveDelegationRequestAction(context); case 'delete': diff --git a/src/app/runtime.ts b/src/app/runtime.ts index 67401408..125ce7c5 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -297,8 +297,7 @@ export class AppRuntime { this.identifiers = createIdentifierRuntimeCommands(commandContext); this.contacts = createContactRuntimeCommands(commandContext); this.challenges = createChallengeRuntimeCommands(commandContext); - this.notifications = - createNotificationRuntimeCommands(commandContext); + this.notifications = createNotificationRuntimeCommands(commandContext); this.delegations = createDelegationRuntimeCommands(commandContext); this.credentials = createCredentialRuntimeCommands(commandContext); this.multisig = createMultisigRuntimeCommands(commandContext); @@ -752,6 +751,11 @@ export class AppRuntime { label: 'View operation', path: operationRoute(requestId), }, + { + rel: 'notification', + label: 'View notification', + path: `/notifications/${encodeURIComponent(id)}`, + }, ]; if (options.resultRoute !== null && options.resultRoute !== undefined) { links.push({ @@ -890,32 +894,37 @@ export class AppRuntime { const task = this.scopes.run(this.contacts.liveInventory, 'session'); this.liveSyncTask = task; - void (async () => { - try { - await task; - } catch (error) { - if (!isHaltedOrAborted(error)) { - this.store.dispatch( - appNotificationRecorded({ - id: `live-sync-failed-${Date.now()}`, - severity: 'warning', - status: 'unread', - title: 'Live inventory sync stopped', - message: toErrorText(error), - createdAt: new Date().toISOString(), - readAt: null, - operationId: null, - links: [], - payloadDetails: [], - }) - ); - } - } finally { - if (this.liveSyncTask === task) { - this.liveSyncTask = null; - } + void this.watchLiveSyncTask(task); + }; + + /** + * Record unexpected live-inventory failures and clear the retained handle. + */ + private watchLiveSyncTask = async (task: Task): Promise => { + try { + await task; + } catch (error) { + if (!isHaltedOrAborted(error)) { + this.store.dispatch( + appNotificationRecorded({ + id: `live-sync-failed-${Date.now()}`, + severity: 'warning', + status: 'unread', + title: 'Live inventory sync stopped', + message: toErrorText(error), + createdAt: new Date().toISOString(), + readAt: null, + operationId: null, + links: [], + payloadDetails: [], + }) + ); + } + } finally { + if (this.liveSyncTask === task) { + this.liveSyncTask = null; } - })(); + } }; /** diff --git a/src/app/runtimeCommands/notifications.ts b/src/app/runtimeCommands/notifications.ts index 4116f7dd..5e42b8a1 100644 --- a/src/app/runtimeCommands/notifications.ts +++ b/src/app/runtimeCommands/notifications.ts @@ -1,6 +1,8 @@ import { dismissExchangeNotificationOp, type DismissExchangeNotificationInput, + markNotificationReadOp, + type MarkNotificationReadInput, } from '../../workflows/notifications.op'; import type { RequestSignalOptions, @@ -12,6 +14,10 @@ export interface NotificationRuntimeCommands { input: DismissExchangeNotificationInput, options?: RequestSignalOptions ): Promise; + markRead( + input: MarkNotificationReadInput, + options?: RequestSignalOptions + ): Promise; } /** @@ -21,6 +27,7 @@ export const createNotificationRuntimeCommands = ( context: RuntimeCommandContext ): NotificationRuntimeCommands => ({ dismissExchange: dismissExchangeNotification(context), + markRead: markNotificationRead(context), }); const dismissExchangeNotification = @@ -34,3 +41,15 @@ const dismissExchangeNotification = kind: 'workflow', track: false, }); + +const markNotificationRead = + (context: RuntimeCommandContext) => + ( + input: MarkNotificationReadInput, + options: RequestSignalOptions = {} + ): Promise => + context.runWorkflow(() => markNotificationReadOp(input), { + ...options, + kind: 'workflow', + track: false, + }); diff --git a/src/domain/credentials/credentialTypes.ts b/src/domain/credentials/credentialTypes.ts index 7e8b46ee..08d2182e 100644 --- a/src/domain/credentials/credentialTypes.ts +++ b/src/domain/credentials/credentialTypes.ts @@ -176,6 +176,13 @@ export type CredentialAdmitNotificationStatus = | 'notForThisWallet' | 'error'; +/** Holder-facing state for inbound W3C VC-JWT grant notifications. */ +export type W3CVcGrantNotificationStatus = + | 'received' + | 'materialized' + | 'notForThisWallet' + | 'error'; + /** * Credential grant metadata hydrated from an IPEX grant EXN. */ @@ -204,6 +211,31 @@ export interface CredentialAdmitNotification { status: CredentialAdmitNotificationStatus; } +/** + * W3C VC-JWT grant metadata hydrated from a `/w3c/vc/grant` EXN. + * + * These grants are informational for the holder: KERIA materializes the held + * W3C credential automatically after validating the grant payload. + */ +export interface W3CVcGrantNotification { + notificationId: string; + grantSaid: string; + issuerAid: string; + issuerDid: string; + holderAid: string; + holderDid: string; + sourceCredentialSaid: string; + schemaSaid: string; + issuanceId: string; + profile: string; + statusUrl: string; + vcJwt: string; + heldCredentialId: string | null; + createdAt: string; + status: W3CVcGrantNotificationStatus; + error: string | null; +} + /** * Minimal notification data required to project credential IPEX exchange * payloads without coupling domain helpers to the notification Redux slice. diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index be6687c0..df1d1aab 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -9,6 +9,7 @@ import { Stack, Typography, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import { Link as RouterLink, useLoaderData } from 'react-router-dom'; import { ConsolePanel, @@ -73,7 +74,8 @@ export const AppNotificationsView = () => { border: 1, borderColor: 'warning.main', borderRadius: 1, - bgcolor: 'rgba(255, 196, 87, 0.08)', + bgcolor: (theme) => + alpha(theme.palette.warning.main, 0.08), px: 2, py: 1.25, }} @@ -135,6 +137,9 @@ export const AppNotificationsView = () => { /> } + slotProps={{ + secondary: { component: 'div' }, + }} secondary={ {formatTimestamp( @@ -160,6 +165,7 @@ export const AppNotificationsView = () => { details={ notification.payloadDetails } + showStructured={false} /> { borderRadius: 1, mb: 1, alignItems: 'flex-start', - bgcolor: 'rgba(5, 9, 13, 0.4)', + bgcolor: 'background.paper', }} > { /> } + slotProps={{ + secondary: { component: 'div' }, + }} secondary={ {formatTimestamp( @@ -303,6 +312,27 @@ export const AppNotificationsView = () => { } )} + {notification.w3cVcGrant !== null && + notification.w3cVcGrant !== + undefined && ( + + W3C source{' '} + { + notification + .w3cVcGrant + .sourceCredentialSaid + }{' '} + / status{' '} + { + notification + .w3cVcGrant + .status + } + + )} } /> diff --git a/src/features/notifications/NotificationDetailView.tsx b/src/features/notifications/NotificationDetailView.tsx index 4b21254e..afc806e5 100644 --- a/src/features/notifications/NotificationDetailView.tsx +++ b/src/features/notifications/NotificationDetailView.tsx @@ -3,10 +3,12 @@ import { Box, Button, IconButton, + Link, Stack, Tooltip, Typography, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import DeleteIcon from '@mui/icons-material/Delete'; import { @@ -18,9 +20,11 @@ import { } from 'react-router-dom'; import { ConnectionRequired } from '../../app/ConnectionRequired'; import { + ConsolePanel, EmptyState, PageHeader, StatusPill, + TelemetryRow, } from '../../app/Console'; import { UI_SOUND_HOVER_VALUE } from '../../app/uiSound'; import type { @@ -31,13 +35,18 @@ import type { } from '../../app/routeData'; import { useAppSelector } from '../../state/hooks'; import { + selectAppNotificationById, selectChallengeRequestNotificationById, selectCredentialGrantNotificationById, selectDelegationRequestNotificationById, selectIdentifiers, selectKeriaNotificationById, selectMultisigRequestNotificationById, + selectOperationById, + selectW3CVcGrantNotificationById, } from '../../state/selectors'; +import { PayloadDetails } from '../../app/PayloadDetails'; +import { formatTimestamp } from '../../app/timeFormat'; import { defaultMultisigRequestGroupAlias, defaultMultisigRequestLocalMember, @@ -47,6 +56,9 @@ import { } from '../multisig/multisigRequestUi'; import { NotificationProtocolPanels } from './NotificationProtocolPanels'; +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + /** * Route view for one KERIA protocol notification or synthetic challenge item. */ @@ -58,9 +70,16 @@ export const NotificationDetailView = () => { const credentialFetcher = useFetcher(); const delegationFetcher = useFetcher(); const multisigFetcher = useFetcher(); + const markReadFetcher = useFetcher(); const [multisigAliasDrafts, setMultisigAliasDrafts] = useState< Record >({}); + const appNotification = useAppSelector( + selectAppNotificationById(notificationId) + ); + const appNotificationOperation = useAppSelector( + selectOperationById(appNotification?.operationId ?? '') + ); const notification = useAppSelector( selectKeriaNotificationById(notificationId) ); @@ -70,6 +89,9 @@ export const NotificationDetailView = () => { const credentialGrant = useAppSelector( selectCredentialGrantNotificationById(notificationId) ); + const w3cVcGrant = useAppSelector( + selectW3CVcGrantNotificationById(notificationId) + ); const delegationRequest = useAppSelector( selectDelegationRequestNotificationById(notificationId) ); @@ -84,6 +106,12 @@ export const NotificationDetailView = () => { (identifier) => identifier.prefix === credentialGrant.holderAid ); + const w3cGrantHolder = + w3cVcGrant === null + ? undefined + : identifiers.find( + (identifier) => identifier.prefix === w3cVcGrant.holderAid + ); const canAdmitGrant = credentialGrant?.status === 'actionable' && grantRecipient !== undefined && @@ -136,6 +164,109 @@ export const NotificationDetailView = () => { } }, [dismissFetcher.data, navigate]); + if (appNotification !== null) { + return ( + + } + data-ui-sound={UI_SOUND_HOVER_VALUE} + > + Back to notifications + + } + /> + + } + > + + + + + + + + + + {appNotification.links + .filter((link) => link.rel !== 'notification') + .map((link) => ( + + ))} + {appNotificationOperation !== null && ( + + Open operation telemetry + + )} + + + + + ); + } + if (loaderData.status === 'blocked') { return ; } @@ -223,6 +354,17 @@ export const NotificationDetailView = () => { }); }; + const markNotificationRead = () => { + const formData = new FormData(); + formData.set('intent', 'markNotificationRead'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', notification.id); + markReadFetcher.submit(formData, { + method: 'post', + action: `/notifications/${encodeURIComponent(notification.id)}`, + }); + }; + return ( { ? 'Challenge request' : credentialGrant !== null ? 'Credential grant' + : w3cVcGrant !== null + ? 'W3C VC-JWT grant' : delegationRequest !== null ? 'Delegation request' : multisigRequest !== null ? 'Multisig request' - : notification.route + : notification.route } summary={notification.id} actions={ @@ -304,7 +448,8 @@ export const NotificationDetailView = () => { border: 1, borderColor: 'warning.main', borderRadius: 1, - bgcolor: 'rgba(255, 196, 87, 0.08)', + bgcolor: (theme) => + alpha(theme.palette.warning.main, 0.08), px: 2, py: 1.25, }} @@ -319,11 +464,16 @@ export const NotificationDetailView = () => { notification={notification} challengeRequest={challengeRequest} credentialGrant={credentialGrant} + w3cVcGrant={w3cVcGrant} delegationRequest={delegationRequest} multisigRequest={multisigRequest} identifiers={identifiers} grantRecipient={grantRecipient} + w3cGrantHolder={w3cGrantHolder} canAdmitGrant={canAdmitGrant} + canMarkRead={ + !notification.read && markReadFetcher.state === 'idle' + } delegationApprover={delegationApprover} canApproveDelegation={canApproveDelegation} multisigMember={multisigMember} @@ -333,6 +483,7 @@ export const NotificationDetailView = () => { canApproveMultisig={canApproveMultisig} setMultisigAliasDrafts={setMultisigAliasDrafts} admitCredentialGrant={admitCredentialGrant} + markNotificationRead={markNotificationRead} approveDelegationRequest={approveDelegationRequest} approveMultisigRequest={approveMultisigRequest} /> diff --git a/src/features/notifications/NotificationProtocolPanels.tsx b/src/features/notifications/NotificationProtocolPanels.tsx index 15e609be..83b9e726 100644 --- a/src/features/notifications/NotificationProtocolPanels.tsx +++ b/src/features/notifications/NotificationProtocolPanels.tsx @@ -12,13 +12,17 @@ import type { NotificationRecord, } from '../../state/notifications.slice'; import type { IdentifierSummary } from '../../domain/identifiers/identifierTypes'; -import type { CredentialGrantNotification } from '../../domain/credentials/credentialTypes'; +import type { + CredentialGrantNotification, + W3CVcGrantNotification, +} from '../../domain/credentials/credentialTypes'; import { ChallengeRequestResponseForm } from './ChallengeRequestResponseForm'; import { sithSummary } from '../../domain/multisig/multisigThresholds'; import { multisigRequestActionLabel, multisigRequestTitle, } from '../multisig/multisigRequestUi'; +import { W3CJwtArtifactDetails } from '../../app/W3CArtifactDetails'; const timestampText = (value: string | null): string => value === null ? 'Not available' : (formatTimestamp(value) ?? value); @@ -41,6 +45,21 @@ const grantStatusTone = ( return 'info'; }; +const w3cGrantStatusTone = ( + status: W3CVcGrantNotification['status'] +): 'neutral' | 'success' | 'warning' | 'error' | 'info' => { + if (status === 'error') { + return 'error'; + } + if (status === 'notForThisWallet') { + return 'warning'; + } + if (status === 'materialized') { + return 'success'; + } + return 'info'; +}; + /** * Map delegation approval readiness to the notification detail status tone. */ @@ -89,6 +108,8 @@ interface NotificationProtocolPanelsProps { challengeRequest: ChallengeRequestNotification | null; /** Matched credential grant/admit request for this notification, when present. */ credentialGrant: CredentialGrantNotification | null; + /** Matched W3C VC-JWT grant notification, when present. */ + w3cVcGrant: W3CVcGrantNotification | null; /** Matched delegation approval request for this notification, when present. */ delegationRequest: DelegationRequestNotification | null; /** Matched multisig protocol request for this notification, when present. */ @@ -96,7 +117,10 @@ interface NotificationProtocolPanelsProps { identifiers: readonly IdentifierSummary[]; /** Local holder AID that can admit the grant, if the wallet owns it. */ grantRecipient: IdentifierSummary | undefined; + /** Local holder AID named by a W3C VC-JWT grant, if loaded. */ + w3cGrantHolder: IdentifierSummary | undefined; canAdmitGrant: boolean; + canMarkRead: boolean; /** Local delegator AID that can approve the delegation, if available. */ delegationApprover: IdentifierSummary | undefined; canApproveDelegation: boolean; @@ -110,6 +134,7 @@ interface NotificationProtocolPanelsProps { setMultisigAliasDrafts: Dispatch>>; /** Route-owned protocol commands that preserve notification action semantics. */ admitCredentialGrant: () => void; + markNotificationRead: () => void; approveDelegationRequest: () => void; approveMultisigRequest: () => void; } @@ -121,11 +146,14 @@ export const NotificationProtocolPanels = ({ notification, challengeRequest, credentialGrant, + w3cVcGrant, delegationRequest, multisigRequest, identifiers, grantRecipient, + w3cGrantHolder, canAdmitGrant, + canMarkRead, delegationApprover, canApproveDelegation, multisigMember, @@ -137,6 +165,7 @@ export const NotificationProtocolPanels = ({ admitCredentialGrant, approveDelegationRequest, approveMultisigRequest, + markNotificationRead, }: NotificationProtocolPanelsProps) => ( <> {challengeRequest !== null ? ( @@ -290,6 +319,135 @@ export const NotificationProtocolPanels = ({ )} + ) : w3cVcGrant !== null ? ( + + } + > + + + + + + + + + + + + + + + + {w3cVcGrant.error !== null && ( + + )} + + + + + + + {w3cGrantHolder === undefined && ( + + )} + {w3cVcGrant.vcJwt.length > 0 && ( + <> + + + + )} + + ) : delegationRequest !== null ? ( , + loadedAt: string +): string => stringValue(exn.dt) ?? notification.dt ?? loadedAt; + +const holderIdentifierForAid = ( + localIdentifiers: readonly IdentifierSummary[], + holderAid: string +): IdentifierSummary | null => + localIdentifiers.find((identifier) => identifier.prefix === holderAid) ?? + null; + +const matchesGrantCredential = ( + credential: W3CHeldCredential, + sourceCredentialSaid: string +): boolean => { + const credentialId = stringValue(credential.credentialId); + const credentialSourceSaid = stringValue(credential.sourceCredentialSaid); + return ( + credentialId === sourceCredentialSaid || + credentialSourceSaid === sourceCredentialSaid + ); +}; + +const heldCredentialIdForGrant = ( + credentials: readonly W3CHeldCredential[], + sourceCredentialSaid: string +): string | null => { + const credential = + credentials.find((candidate) => + matchesGrantCredential(candidate, sourceCredentialSaid) + ) ?? null; + return credential === null ? null : stringValue(credential.credentialId); +}; + +const w3cGrantFromExchange = ({ + notification, + exchange, + heldCredentialId, + loadedAt, + status, + error = null, +}: { + notification: NotificationRecord; + exchange: unknown; + heldCredentialId: string | null; + loadedAt: string; + status: W3CVcGrantNotification['status']; + error?: string | null; +}): W3CVcGrantNotification => { + const exn = exchangeExn(exchange); + const attrs = requireRecord(exn.a, 'W3C VC-JWT grant payload'); + const grantSaid = stringValue(exn.d) ?? notification.anchorSaid; + const issuerAid = stringValue(attrs.issuerAid); + const issuerDid = stringValue(attrs.issuerDid); + const holderAid = stringValue(attrs.holderAid); + const holderDid = stringValue(attrs.holderDid); + const sourceCredentialSaid = stringValue(attrs.sourceCredentialSaid); + const schemaSaid = stringValue(attrs.schemaSaid); + const issuanceId = stringValue(attrs.issuanceId); + const profile = stringValue(attrs.profile); + const statusUrl = stringValue(attrs.statusUrl); + const vcJwt = stringValue(attrs.vcJwt); + + if ( + grantSaid === null || + issuerAid === null || + issuerDid === null || + holderAid === null || + holderDid === null || + sourceCredentialSaid === null || + schemaSaid === null || + issuanceId === null || + profile === null || + statusUrl === null || + vcJwt === null + ) { + throw new Error('W3C VC-JWT grant payload is missing required fields.'); + } + + return { + notificationId: notification.id, + grantSaid, + issuerAid, + issuerDid, + holderAid, + holderDid, + sourceCredentialSaid, + schemaSaid, + issuanceId, + profile, + statusUrl, + vcJwt, + heldCredentialId, + createdAt: timestampText(notification, exn, loadedAt), + status, + error, + }; +}; + +function* hydrateW3CVcGrantNotification({ + client, + notification, + localAids, + localIdentifiers, + loadedAt, +}: { + client: SignifyClient; + notification: NotificationRecord; + localAids: ReadonlySet; + localIdentifiers: readonly IdentifierSummary[]; + loadedAt: string; +}): EffectionOperation { + if (notification.route !== W3C_VC_GRANT_NOTIFICATION_ROUTE) { + return notification; + } + + if (notification.anchorSaid === null) { + return { + ...notification, + status: 'error', + message: 'W3C VC-JWT grant notification is missing its EXN SAID.', + }; + } + + try { + const exchange = yield* callPromise(() => + client.exchanges().get(notification.anchorSaid as string) + ); + const exn = exchangeExn(exchange); + if (stringValue(exn.r) !== W3C_VC_GRANT_NOTIFICATION_ROUTE) { + return { + ...notification, + status: 'error', + message: + 'W3C VC-JWT grant notification referenced a non-W3C grant EXN.', + }; + } + + const attrs = requireRecord(exn.a, 'W3C VC-JWT grant payload'); + const holderAid = stringValue(attrs.holderAid); + const sourceCredentialSaid = stringValue(attrs.sourceCredentialSaid); + if (holderAid === null || sourceCredentialSaid === null) { + throw new Error( + 'W3C VC-JWT grant payload is missing holder or credential identifiers.' + ); + } + + if (!localAids.has(holderAid)) { + if (!notification.read) { + yield* callPromise(() => + client.notifications().mark(notification.id) + ); + } + + return { + ...notification, + read: true, + status: 'processed', + message: 'W3C VC-JWT grant is not addressed to this wallet.', + w3cVcGrant: w3cGrantFromExchange({ + notification, + exchange, + heldCredentialId: null, + loadedAt, + status: 'notForThisWallet', + }), + }; + } + + const holderIdentifier = holderIdentifierForAid( + localIdentifiers, + holderAid + ); + const heldCredentials = + holderIdentifier === null + ? [] + : yield* callPromise(() => + new W3CKeriaClient(client).credentials( + holderIdentifier.name + ) + ); + const heldCredentialId = heldCredentialIdForGrant( + heldCredentials, + sourceCredentialSaid + ); + const grantStatus = + heldCredentialId === null ? 'received' : 'materialized'; + const grant = w3cGrantFromExchange({ + notification, + exchange, + heldCredentialId, + loadedAt, + status: grantStatus, + }); + + return { + ...notification, + status: notification.read ? 'processed' : 'unread', + message: + grantStatus === 'materialized' + ? `W3C VC-JWT grant materialized from ${grant.issuerAid}` + : `W3C VC-JWT grant received from ${grant.issuerAid}`, + w3cVcGrant: grant, + }; + } catch (error) { + return { + ...notification, + status: 'error', + message: + error instanceof Error + ? error.message + : 'Unable to hydrate W3C VC-JWT grant notification.', + w3cVcGrant: + notification.anchorSaid === null + ? null + : { + notificationId: notification.id, + grantSaid: notification.anchorSaid, + issuerAid: 'unknown', + issuerDid: 'unknown', + holderAid: 'unknown', + holderDid: 'unknown', + sourceCredentialSaid: 'unknown', + schemaSaid: 'unknown', + issuanceId: 'unknown', + profile: 'unknown', + statusUrl: 'unknown', + vcJwt: '', + heldCredentialId: null, + createdAt: notification.dt ?? loadedAt, + status: 'error', + error: toErrorText(error), + }, + }; + } +} + +export function* hydrateW3CVcGrantNotifications({ + client, + notifications, + localAids, + localIdentifiers = [], + loadedAt, +}: { + client: SignifyClient; + notifications: NotificationRecord[]; + localAids: ReadonlySet; + localIdentifiers?: readonly IdentifierSummary[]; + loadedAt: string; +}): EffectionOperation { + const hydrated: NotificationRecord[] = []; + + for (const notification of notifications) { + hydrated.push( + yield* hydrateW3CVcGrantNotification({ + client, + notification, + localAids, + localIdentifiers, + loadedAt, + }) + ); + } + + return hydrated; +} diff --git a/src/state/appNotifications.slice.ts b/src/state/appNotifications.slice.ts index b2726296..7ae929fe 100644 --- a/src/state/appNotifications.slice.ts +++ b/src/state/appNotifications.slice.ts @@ -17,7 +17,7 @@ export type AppNotificationSeverity = 'info' | 'success' | 'warning' | 'error'; export type AppNotificationStatus = 'unread' | 'read'; /** Link relationships supported by app notification cards and popovers. */ -export type AppNotificationLinkRel = 'operation' | 'result'; +export type AppNotificationLinkRel = 'notification' | 'operation' | 'result'; /** Serializable app route link attached to a user notification. */ export interface AppNotificationLink { diff --git a/src/state/notifications.slice.ts b/src/state/notifications.slice.ts index b60f78f3..f8e9e337 100644 --- a/src/state/notifications.slice.ts +++ b/src/state/notifications.slice.ts @@ -3,6 +3,7 @@ import type { MultisigThresholdSith } from '../domain/multisig/multisigThreshold import type { CredentialAdmitNotification, CredentialGrantNotification, + W3CVcGrantNotification, } from '../domain/credentials/credentialTypes'; import { sessionConnectionFailed, @@ -130,6 +131,7 @@ export interface NotificationRecord { challengeRequest?: ChallengeRequestNotification | null; credentialGrant?: CredentialGrantNotification | null; credentialAdmit?: CredentialAdmitNotification | null; + w3cVcGrant?: W3CVcGrantNotification | null; delegationRequest?: DelegationRequestNotification | null; multisigRequest?: MultisigRequestNotification | null; updatedAt: string; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 0e6f00e1..e12a7577 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -16,6 +16,7 @@ import type { CredentialAdmitNotification, CredentialGrantNotification, CredentialIpexActivityRecord, + W3CVcGrantNotification, } from '../domain/credentials/credentialTypes'; import { knownComponentsFromContacts, @@ -160,6 +161,7 @@ const notificationExnSaid = (notification: NotificationRecord): string | null => notification.challengeRequest?.exnSaid ?? notification.credentialGrant?.grantSaid ?? notification.credentialAdmit?.admitSaid ?? + notification.w3cVcGrant?.grantSaid ?? notification.delegationRequest?.delegateEventSaid ?? notification.multisigRequest?.exnSaid ?? notification.anchorSaid; @@ -229,10 +231,8 @@ export const selectCredentialStatus = (said: string) => (state: RootState) => state.credentials.bySaid[said]?.status ?? null; /** Select one generic raw ACDC detail record by credential SAID. */ -export const selectCredentialAcdc = - (said: string) => - (state: RootState) => - state.credentials.acdcBySaid[said] ?? null; +export const selectCredentialAcdc = (said: string) => (state: RootState) => + state.credentials.acdcBySaid[said] ?? null; /** Select all generic raw ACDC detail records keyed by credential SAID. */ export const selectCredentialAcdcsBySaid = (state: RootState) => @@ -240,8 +240,7 @@ export const selectCredentialAcdcsBySaid = (state: RootState) => /** Select the normalized chained ACDC DAG for one root credential SAID. */ export const selectCredentialChainGraph = - (rootSaid: string) => - (state: RootState) => + (rootSaid: string) => (state: RootState) => state.credentials.chainGraphByRootSaid[rootSaid] ?? null; /** Select credential records newest first. */ @@ -427,6 +426,11 @@ const byNewestCredentialAdmitTimestamp = ( right: CredentialAdmitNotification ): number => right.createdAt.localeCompare(left.createdAt); +const byNewestW3CVcGrantTimestamp = ( + left: W3CVcGrantNotification, + right: W3CVcGrantNotification +): number => right.createdAt.localeCompare(left.createdAt); + const byNewestDelegationRequestTimestamp = ( left: DelegationRequestNotification, right: DelegationRequestNotification @@ -463,6 +467,36 @@ export const selectCredentialGrantNotificationById = selectKeriaNotificationById(notificationId)(state)?.credentialGrant ?? null; +/** Select W3C VC-JWT grant notifications newest first. */ +export const selectW3CVcGrantNotifications = (state: RootState) => + selectKeriaNotifications(state) + .flatMap((notification) => + notification.w3cVcGrant === null || + notification.w3cVcGrant === undefined + ? [] + : [notification.w3cVcGrant] + ) + .sort(byNewestW3CVcGrantTimestamp); + +/** Select unread holder-side W3C VC-JWT grant notifications. */ +export const selectUnreadW3CVcGrantNotifications = (state: RootState) => + selectKeriaNotifications(state) + .flatMap((notification) => + notification.read || + notification.w3cVcGrant === null || + notification.w3cVcGrant === undefined || + notification.w3cVcGrant.status === 'notForThisWallet' + ? [] + : [notification.w3cVcGrant] + ) + .sort(byNewestW3CVcGrantTimestamp); + +/** Select one W3C VC-JWT grant notification by KERIA notification id. */ +export const selectW3CVcGrantNotificationById = + (notificationId: string) => + (state: RootState): W3CVcGrantNotification | null => + selectKeriaNotificationById(notificationId)(state)?.w3cVcGrant ?? null; + /** Select issuer-side credential admit receipts newest first. */ export const selectCredentialAdmitNotifications = (state: RootState) => selectKeriaNotifications(state) diff --git a/src/workflows/contacts.op.ts b/src/workflows/contacts.op.ts index 50dedcca..3414d41d 100644 --- a/src/workflows/contacts.op.ts +++ b/src/workflows/contacts.op.ts @@ -34,6 +34,7 @@ import { import { notificationInventoryLoaded } from '../state/notifications.slice'; import type { AppDispatch, AppStore } from '../state/store'; import type { ChallengeRecord } from '../domain/challenges/challengeTypes'; +import type { IdentifierSummary } from '../domain/identifiers/identifierTypes'; /** * Workflow command for creating one identifier OOBI by role. @@ -163,6 +164,18 @@ export const localIdentifierAids = ( return [...new Set(aids)]; }; +export const localIdentifierSummaries = ( + store: Pick +): IdentifierSummary[] => { + const { identifiers } = store.getState(); + return identifiers.prefixes + .map((prefix) => identifiers.byPrefix[prefix]) + .filter( + (identifier): identifier is IdentifierSummary => + identifier !== undefined + ); +}; + /** * Return app-local EXN tombstones that should be hidden from future inventory. */ @@ -210,6 +223,7 @@ export function* syncSessionInventoryOp(): EffectionOperation { const normalized = value.trim(); if (normalized.length === 0) { @@ -67,3 +71,26 @@ export function* dismissExchangeNotificationOp( // immediate local suppression even when the refresh is unavailable. } } + +/** + * Mark a real KERIA notification read without hiding its exchange history. + */ +export function* markNotificationReadOp( + input: MarkNotificationReadInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const client = services.runtime.requireConnectedClient(); + const notificationId = requireNonEmpty( + input.notificationId, + 'Notification id' + ); + + yield* callPromise(() => client.notifications().mark(notificationId)); + + try { + yield* syncSessionInventoryOp(); + } catch { + // The live sync loop will retry. Marking the KERIA note is the + // authoritative effect. + } +} diff --git a/tests/unit/notifications/w3cGrant.test.ts b/tests/unit/notifications/w3cGrant.test.ts new file mode 100644 index 00000000..bd565883 --- /dev/null +++ b/tests/unit/notifications/w3cGrant.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SignifyClient } from 'signify-ts'; +import { createAppRuntime } from '../../../src/app/runtime'; +import { + listNotificationsService, + W3C_VC_GRANT_NOTIFICATION_ROUTE, +} from '../../../src/services/notifications.service'; +import type { IdentifierSummary } from '../../../src/domain/identifiers/identifierTypes'; +import { loadedAt } from './helpers'; + +const w3cGrantExchange = { + exn: { + d: 'Ew3cgrant', + i: 'Eissuer', + rp: 'Eholder', + dt: loadedAt, + r: W3C_VC_GRANT_NOTIFICATION_ROUTE, + a: { + holderAid: 'Eholder', + holderDid: 'did:webs:example.com:dws:Eholder', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + sourceCredentialSaid: 'Ecredential', + schemaSaid: 'Eschema', + issuanceId: 'issuance-1', + vcJwt: 'vc.jwt.token', + statusUrl: 'http://127.0.0.1:3901/status/Ecredential', + profile: 'gleif-vrd-isomer-v1', + }, + }, +}; + +const holderIdentifier = { + name: 'holder', + prefix: 'Eholder', +} as IdentifierSummary; + +const makeW3CClient = ({ + rawNotifications, + exchange = w3cGrantExchange, + credentials = [], +}: { + rawNotifications: unknown; + exchange?: unknown; + credentials?: unknown[]; +}) => { + const notifications = { + list: vi.fn(async () => rawNotifications), + mark: vi.fn(async () => ''), + delete: vi.fn(async () => undefined), + }; + const exchanges = { + get: vi.fn(async () => exchange), + }; + const client = { + notifications: () => notifications, + exchanges: () => exchanges, + groups: () => ({ + getRequest: vi.fn(async () => []), + }), + fetch: vi.fn(async (path: string) => ({ + json: async () => + path.includes('/w3c/credentials') ? { credentials } : [], + })), + } as unknown as SignifyClient; + + return { client, notifications, exchanges }; +}; + +const runW3CNotifications = async ({ + client, + localAids = ['Eholder'], + localIdentifiers = [holderIdentifier], +}: { + client: SignifyClient; + localAids?: string[]; + localIdentifiers?: IdentifierSummary[]; +}) => { + const runtime = createAppRuntime({ storage: null }); + try { + return await runtime.runWorkflow( + () => + listNotificationsService({ + client, + localAids, + localIdentifiers, + }), + { scope: 'app', track: false } + ); + } finally { + await runtime.destroy(); + } +}; + +const note = { + i: 'w3c-note-1', + dt: loadedAt, + r: false, + a: { + r: W3C_VC_GRANT_NOTIFICATION_ROUTE, + d: 'Ew3cgrant', + }, +}; + +describe('W3C VC-JWT grant notification hydration', () => { + it('hydrates local holder grants before materialization completes', async () => { + const { client } = makeW3CClient({ + rawNotifications: { notes: [note] }, + credentials: [], + }); + + const snapshot = await runW3CNotifications({ client }); + + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'w3c-note-1', + read: false, + status: 'unread', + message: 'W3C VC-JWT grant received from Eissuer', + w3cVcGrant: expect.objectContaining({ + notificationId: 'w3c-note-1', + grantSaid: 'Ew3cgrant', + issuerAid: 'Eissuer', + holderAid: 'Eholder', + sourceCredentialSaid: 'Ecredential', + heldCredentialId: null, + vcJwt: 'vc.jwt.token', + status: 'received', + }), + }), + ]); + }); + + it('marks local holder grants materialized when the held W3C credential exists', async () => { + const { client } = makeW3CClient({ + rawNotifications: { notes: [note] }, + credentials: [ + { + credentialId: 'Ecredential', + sourceCredentialSaid: 'Ecredential', + }, + ], + }); + + const snapshot = await runW3CNotifications({ client }); + + expect(snapshot.notifications[0]).toEqual( + expect.objectContaining({ + message: 'W3C VC-JWT grant materialized from Eissuer', + w3cVcGrant: expect.objectContaining({ + heldCredentialId: 'Ecredential', + status: 'materialized', + }), + }) + ); + }); + + it('marks non-local holder grants read', async () => { + const { client, notifications } = makeW3CClient({ + rawNotifications: { notes: [note] }, + }); + + const snapshot = await runW3CNotifications({ + client, + localAids: ['Eother'], + localIdentifiers: [], + }); + + expect(notifications.mark).toHaveBeenCalledWith('w3c-note-1'); + expect(snapshot.notifications[0]).toEqual( + expect.objectContaining({ + read: true, + status: 'processed', + message: 'W3C VC-JWT grant is not addressed to this wallet.', + w3cVcGrant: expect.objectContaining({ + status: 'notForThisWallet', + }), + }) + ); + }); + + it('reports an error when the notification has no anchor SAID', async () => { + const { client } = makeW3CClient({ + rawNotifications: { + notes: [ + { + i: 'w3c-note-missing-anchor', + dt: loadedAt, + r: false, + a: { r: W3C_VC_GRANT_NOTIFICATION_ROUTE }, + }, + ], + }, + }); + + const snapshot = await runW3CNotifications({ client }); + + expect(snapshot.notifications[0]).toEqual( + expect.objectContaining({ + status: 'error', + message: + 'W3C VC-JWT grant notification is missing its EXN SAID.', + }) + ); + }); + + it('reports malformed grant payloads as errors', async () => { + const { client } = makeW3CClient({ + rawNotifications: { notes: [note] }, + exchange: { + exn: { + d: 'Ew3cgrant', + r: W3C_VC_GRANT_NOTIFICATION_ROUTE, + a: { + holderAid: 'Eholder', + }, + }, + }, + }); + + const snapshot = await runW3CNotifications({ client }); + + expect(snapshot.notifications[0]).toEqual( + expect.objectContaining({ + status: 'error', + w3cVcGrant: expect.objectContaining({ + status: 'error', + grantSaid: 'Ew3cgrant', + }), + }) + ); + }); +}); diff --git a/tests/unit/runtimeCommands.test.ts b/tests/unit/runtimeCommands.test.ts index b0123756..68ac425d 100644 --- a/tests/unit/runtimeCommands.test.ts +++ b/tests/unit/runtimeCommands.test.ts @@ -67,6 +67,9 @@ describe('runtime command adapters', () => { exnSaid: 'Eexn', route: '/challenge/request', }); + await createNotificationRuntimeCommands(context).markRead({ + notificationId: 'note-2', + }); expect(workflowOptions).toMatchObject([ { @@ -91,6 +94,10 @@ describe('runtime command adapters', () => { kind: 'workflow', track: false, }, + { + kind: 'workflow', + track: false, + }, ]); }); diff --git a/tests/unit/runtimeWorkflow.test.ts b/tests/unit/runtimeWorkflow.test.ts index 3a85ede1..40b140e0 100644 --- a/tests/unit/runtimeWorkflow.test.ts +++ b/tests/unit/runtimeWorkflow.test.ts @@ -224,6 +224,27 @@ describe('AppRuntime workflow bridge', () => { }); }); expect(store.getState().appNotifications.ids).toHaveLength(1); + const notificationId = store.getState().appNotifications.ids[0]; + expect(notificationId).toBeDefined(); + expect( + store.getState().appNotifications.byId[notificationId].links + ).toEqual([ + { + rel: 'operation', + label: 'View operation', + path: '/operations/background-success', + }, + { + rel: 'notification', + label: 'View notification', + path: `/notifications/${notificationId}`, + }, + { + rel: 'result', + label: 'Contacts', + path: '/credentials', + }, + ]); await runtime.destroy(); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index fc4ce5e8..f6df7c95 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -31,6 +31,8 @@ import { selectKeriaNotifications, selectResolvedCredentialSchemas, selectStoredChallengeWordsForContact, + selectUnreadW3CVcGrantNotifications, + selectW3CVcGrantNotificationById, } from '../../src/state/selectors'; import { sessionConnected, @@ -239,6 +241,86 @@ describe('RTK state foundation', () => { ]); }); + it('selects unread W3C VC-JWT grant notifications separately from actionable IPEX grants', () => { + const store = createAppStore(); + + store.dispatch( + notificationRecorded({ + id: 'w3c-note-1', + dt: '2026-04-21T00:00:00.000Z', + read: false, + route: '/w3c/vc/grant', + anchorSaid: 'Ew3cgrant', + status: 'unread', + message: 'W3C VC-JWT grant materialized from Eissuer', + updatedAt: '2026-04-21T00:00:00.000Z', + w3cVcGrant: { + notificationId: 'w3c-note-1', + grantSaid: 'Ew3cgrant', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + holderAid: 'Eholder', + holderDid: 'did:webs:example.com:dws:Eholder', + sourceCredentialSaid: 'Ecredential', + schemaSaid: 'Eschema', + issuanceId: 'issuance-1', + profile: 'gleif-vrd-isomer-v1', + statusUrl: 'http://127.0.0.1:3901/status/Ecredential', + vcJwt: 'vc.jwt.token', + heldCredentialId: 'Ecredential', + createdAt: '2026-04-21T00:00:00.000Z', + status: 'materialized', + error: null, + }, + }) + ); + store.dispatch( + notificationRecorded({ + id: 'w3c-note-read', + dt: '2026-04-22T00:00:00.000Z', + read: true, + route: '/w3c/vc/grant', + anchorSaid: 'Ew3cgrant-read', + status: 'processed', + message: 'W3C VC-JWT grant materialized from Eissuer', + updatedAt: '2026-04-22T00:00:00.000Z', + w3cVcGrant: { + notificationId: 'w3c-note-read', + grantSaid: 'Ew3cgrant-read', + issuerAid: 'Eissuer', + issuerDid: 'did:webs:example.com:dws:Eissuer', + holderAid: 'Eholder', + holderDid: 'did:webs:example.com:dws:Eholder', + sourceCredentialSaid: 'Ecredential-read', + schemaSaid: 'Eschema', + issuanceId: 'issuance-read', + profile: 'gleif-vrd-isomer-v1', + statusUrl: 'http://127.0.0.1:3901/status/Ecredential-read', + vcJwt: 'vc.jwt.token', + heldCredentialId: 'Ecredential-read', + createdAt: '2026-04-22T00:00:00.000Z', + status: 'materialized', + error: null, + }, + }) + ); + + expect(selectUnreadW3CVcGrantNotifications(store.getState())).toEqual([ + expect.objectContaining({ + notificationId: 'w3c-note-1', + sourceCredentialSaid: 'Ecredential', + }), + ]); + expect( + selectW3CVcGrantNotificationById('w3c-note-1')(store.getState()) + ).toEqual( + expect.objectContaining({ + heldCredentialId: 'Ecredential', + status: 'materialized', + }) + ); + }); + it('filters locally tombstoned EXN notifications from selectors', () => { const store = createAppStore();