From b6c706335fc87ec4afe17c993a6944384982dc30 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 21 Apr 2026 23:42:08 -0600 Subject: [PATCH 1/3] feat: copyable OOBIs and fixed OOBI filtering; UX on OOBIs --- package.json | 5 +- src/app/ConnectDialog.tsx | 42 +- src/app/Console.tsx | 11 +- src/app/PayloadDetails.tsx | 120 +++ src/app/TopBar.tsx | 8 + src/app/consoleStyles.ts | 19 + src/app/routeData.ts | 309 +++++++- src/app/router.tsx | 71 +- src/app/runtime.ts | 349 ++++++++- src/config.ts | 7 + src/features/contacts/ContactDetailView.tsx | 533 +++++++++++++ src/features/contacts/ContactsView.tsx | 711 ++++++++++++++++++ src/features/contacts/contactHelpers.ts | 607 +++++++++++++++ src/features/dashboard/DashboardView.tsx | 470 ++++++++++++ .../identifiers/IdentifierDetailsModal.tsx | 130 ++++ src/features/identifiers/IdentifierTable.tsx | 83 ++ src/features/identifiers/IdentifiersView.tsx | 147 +++- .../notifications/AppNotificationsView.tsx | 22 +- .../operations/OperationDetailView.tsx | 6 + src/services/contacts.service.ts | 303 ++++++++ src/services/notifications.service.ts | 99 +++ src/state/appNotifications.slice.ts | 7 +- src/state/challenges.slice.ts | 40 +- src/state/contacts.slice.ts | 117 ++- src/state/identifiers.slice.ts | 17 +- src/state/notifications.slice.ts | 48 +- src/state/operations.slice.ts | 36 +- src/state/payloadDetails.ts | 11 + src/state/selectors.ts | 115 ++- src/workflows/contacts.op.ts | 217 ++++++ tests/browser-smoke.mjs | 16 +- tests/contact-oobi-smoke.ts | 313 ++++++++ tests/scenarios/oobi-contacts.test.ts | 80 ++ tests/unit/config.test.ts | 3 + tests/unit/contactHelpers.test.ts | 293 ++++++++ tests/unit/routeData.test.ts | 104 ++- tests/unit/router.test.ts | 51 +- tests/unit/runtimeWorkflow.test.ts | 117 +++ tests/unit/state.test.ts | 182 +++++ 39 files changed, 5757 insertions(+), 62 deletions(-) create mode 100644 src/app/PayloadDetails.tsx create mode 100644 src/features/contacts/ContactDetailView.tsx create mode 100644 src/features/contacts/ContactsView.tsx create mode 100644 src/features/contacts/contactHelpers.ts create mode 100644 src/features/dashboard/DashboardView.tsx create mode 100644 src/services/contacts.service.ts create mode 100644 src/services/notifications.service.ts create mode 100644 src/state/payloadDetails.ts create mode 100644 src/workflows/contacts.op.ts create mode 100644 tests/contact-oobi-smoke.ts create mode 100644 tests/scenarios/oobi-contacts.test.ts create mode 100644 tests/unit/contactHelpers.test.ts diff --git a/package.json b/package.json index 13060f4e..3c7646ac 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "lint": "eslint src tests scripts eslint.config.mjs vite.config.ts vitest.config.ts --max-warnings 0", "preview": "vite preview", "keria:smoke": "tsx scripts/keria-smoke.ts", + "contact:ui-smoke": "tsx tests/contact-oobi-smoke.ts", "browser:smoke": "node tests/browser-smoke.mjs", "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", + "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", "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 browser:smoke" + "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" }, "engines": { "node": ">=20.19.0" diff --git a/src/app/ConnectDialog.tsx b/src/app/ConnectDialog.tsx index aa391b4f..bec0363f 100644 --- a/src/app/ConnectDialog.tsx +++ b/src/app/ConnectDialog.tsx @@ -8,6 +8,7 @@ import { DialogContent, DialogTitle, IconButton, + InputAdornment, Stack, TextField, Tooltip, @@ -15,6 +16,8 @@ import { } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import RefreshIcon from '@mui/icons-material/Refresh'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { useFetcher } from 'react-router-dom'; import { appConfig, type ConnectionOption } from '../config'; import { monoValueSx } from './consoleStyles'; @@ -54,6 +57,7 @@ export const ConnectDialog = ({ useState(appConfig.connectionOptions[0]); const [draftPasscode, setDraftPasscode] = useState(null); const [copiedPasscode, setCopiedPasscode] = useState(false); + const [passcodeVisible, setPasscodeVisible] = useState(false); const isSubmitting = connection.status === 'connecting' || connectFetcher.state !== 'idle'; const isGenerating = passcodeFetcher.state !== 'idle'; @@ -187,7 +191,7 @@ export const ConnectDialog = ({ + + { + setPasscodeVisible( + (visible) => + !visible + ); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + data-testid="toggle-passcode-visibility" + > + {passcodeVisible ? ( + + ) : ( + + )} + + + + ), }, inputLabel: { shrink: true, diff --git a/src/app/Console.tsx b/src/app/Console.tsx index a818fa52..dc6adf10 100644 --- a/src/app/Console.tsx +++ b/src/app/Console.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; import { Box, Button, Stack, Typography } from '@mui/material'; import type { ButtonProps, SxProps, Theme } from '@mui/material'; -import { monoValueSx } from './consoleStyles'; +import { Link as RouterLink } from 'react-router-dom'; +import { clickablePanelSx, monoValueSx } from './consoleStyles'; export interface PageHeaderProps { eyebrow?: string; @@ -61,6 +62,8 @@ export interface ConsolePanelProps { eyebrow?: string; actions?: ReactNode; sx?: SxProps; + to?: string; + testId?: string; } export const ConsolePanel = ({ @@ -69,8 +72,13 @@ export const ConsolePanel = ({ eyebrow, actions, sx, + to, + testId, }: ConsolePanelProps) => ( diff --git a/src/app/PayloadDetails.tsx b/src/app/PayloadDetails.tsx new file mode 100644 index 00000000..55ba4d29 --- /dev/null +++ b/src/app/PayloadDetails.tsx @@ -0,0 +1,120 @@ +import { Box, Stack, Tooltip, Typography } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { monoValueSx } from './consoleStyles'; +import type { PayloadDetailRecord } from '../state/payloadDetails'; + +const abbreviate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + const edgeLength = Math.max(8, Math.floor((maxLength - 3) / 2)); + return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; +}; + +const copyValue = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +export interface PayloadDetailsProps { + details: readonly PayloadDetailRecord[]; + dense?: boolean; + maxLength?: number; +} + +/** + * Render meaningful operation/notification payloads as compact copy targets. + */ +export const PayloadDetails = ({ + details, + dense = false, + maxLength = dense ? 52 : 84, +}: PayloadDetailsProps) => { + if (details.length === 0) { + return null; + } + + return ( + + {details.map((detail) => ( + + { + if (!detail.copyable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + copyValue(detail.value); + }} + onKeyDown={(event) => { + if ( + !detail.copyable || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + copyValue(detail.value); + }} + data-testid="payload-detail" + data-payload-kind={detail.kind} + sx={{ + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + alignItems: 'center', + gap: 0.75, + border: 1, + borderColor: 'divider', + borderRadius: 1, + px: dense ? 0.75 : 1, + py: dense ? 0.5 : 0.75, + bgcolor: 'rgba(39, 215, 255, 0.06)', + cursor: detail.copyable ? 'copy' : 'default', + minWidth: 0, + }} + > + + {detail.label} + + + {abbreviate(detail.value, maxLength)} + + {detail.copyable && ( + + )} + + + ))} + + ); +}; diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx index 66dda477..bf368a31 100644 --- a/src/app/TopBar.tsx +++ b/src/app/TopBar.tsx @@ -19,6 +19,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import NotificationsIcon from '@mui/icons-material/Notifications'; import { Link as RouterLink } from 'react-router-dom'; import { StatusPill } from './Console'; +import { PayloadDetails } from './PayloadDetails'; import { formatOperationWindow, formatTimestamp } from './timeFormat'; import type { AppNotificationRecord } from '../state/appNotifications.slice'; import type { OperationRecord } from '../state/operations.slice'; @@ -292,6 +293,7 @@ export const TopBar = ({ visibleNotifications.map((notification) => ( {notification.message} + } /> diff --git a/src/app/consoleStyles.ts b/src/app/consoleStyles.ts index 5382e395..4c110e38 100644 --- a/src/app/consoleStyles.ts +++ b/src/app/consoleStyles.ts @@ -4,3 +4,22 @@ export const monoValueSx = { overflowWrap: 'anywhere', wordBreak: 'break-word', } as const; + +export const clickablePanelSx = { + textDecoration: 'none', + color: 'inherit', + cursor: 'pointer', + transition: + 'border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease', + '&:hover': { + borderColor: 'primary.main', + bgcolor: 'rgba(39, 215, 255, 0.14)', + boxShadow: + '0 0 0 1px rgba(39, 215, 255, 0.36), 0 18px 44px rgba(39, 215, 255, 0.14)', + transform: 'translateY(-1px)', + }, + '&:focus-visible': { + outline: '2px solid rgba(39, 215, 255, 0.85)', + outlineOffset: 2, + }, +} as const; diff --git a/src/app/routeData.ts b/src/app/routeData.ts index 29af6462..f269126c 100644 --- a/src/app/routeData.ts +++ b/src/app/routeData.ts @@ -5,6 +5,10 @@ import type { IdentifierSummary, } from '../features/identifiers/identifierTypes'; import { isIdentifierCreateDraft } from '../features/identifiers/identifierHelpers'; +import type { + OobiRole, + ResolveContactInput, +} from '../services/contacts.service'; import type { ConnectedSignifyClient, SignifyClientConfig, @@ -16,7 +20,7 @@ import type { BackgroundWorkflowStartResult } from './runtime'; * Canonical route used for startup redirects, unknown paths, and successful * KERIA connection submissions. */ -export const DEFAULT_APP_PATH = '/identifiers'; +export const DEFAULT_APP_PATH = '/dashboard'; /** * Loader result used when a connected Signify client is required. @@ -35,6 +39,22 @@ export type IdentifiersLoaderData = | { status: 'error'; identifiers: IdentifierSummary[]; message: string } | BlockedRouteData; +/** + * Loader data for `/dashboard`. + */ +export type DashboardLoaderData = + | { status: 'ready' } + | { status: 'error'; message: string } + | BlockedRouteData; + +/** + * Loader data for `/contacts`. + */ +export type ContactsLoaderData = + | { status: 'ready' } + | { status: 'error'; message: string } + | BlockedRouteData; + /** * Loader data for the client summary route. */ @@ -83,6 +103,30 @@ export type IdentifierActionData = operationRoute?: string; }; +/** + * Typed action result for contact/OOBI mutations. + */ +export type ContactActionData = + | { + intent: 'resolve' | 'generateOobi' | 'delete' | 'updateAlias'; + ok: true; + message: string; + requestId: string; + operationRoute: string; + } + | { + intent: + | 'resolve' + | 'generateOobi' + | 'delete' + | 'updateAlias' + | 'unsupported'; + ok: false; + message: string; + requestId?: string; + operationRoute?: string; + }; + /** * Minimal connected-client shape route data needs for diagnostics. */ @@ -114,6 +158,8 @@ export interface RouteDataRuntime { refreshState(options?: { signal?: AbortSignal }): Promise; /** Load and normalize identifiers through the connected client. */ listIdentifiers(options?: { signal?: AbortSignal }): Promise; + /** Load live contact, challenge, and protocol notification facts. */ + syncSessionInventory(options?: { signal?: AbortSignal }): Promise; /** Create an identifier and wait for its KERIA operation to complete. */ createIdentifier( draft: IdentifierCreateDraft, @@ -134,6 +180,26 @@ export interface RouteDataRuntime { aid: string, options?: { requestId?: string } ): BackgroundWorkflowStartResult; + /** Start OOBI generation in the background. */ + startGenerateOobi( + input: { identifier: string; role: OobiRole }, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact OOBI resolution in the background. */ + startResolveContact( + input: ResolveContactInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact deletion in the background. */ + startDeleteContact( + contactId: string, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact alias update in the background. */ + startUpdateContactAlias( + input: { contactId: string; alias: string }, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; } /** @@ -168,6 +234,59 @@ const parseIdentifierCreateDraft = ( } }; +const parseOobiRole = (value: string): OobiRole | null => + value === 'agent' || value === 'witness' ? value : null; + +/** + * Loader for `/dashboard`. + */ +export const loadDashboard = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready' }; + } catch (error) { + return { + status: 'error', + message: `Unable to refresh dashboard inventory: ${toRouteError(error).message}`, + }; + } +}; + +/** + * Loader for `/contacts`. + */ +export const loadContacts = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready' }; + } catch (error) { + return { + status: 'error', + message: `Unable to refresh contact inventory: ${toRouteError(error).message}`, + }; + } +}; + /** * Loader for `/identifiers`. * @@ -400,3 +519,191 @@ export const identifiersAction = async ( message: `Unsupported identifier action: ${intent || 'missing intent'}`, }; }; + +/** + * Route action for contact/OOBI mutations. + */ +export const contactsAction = async ( + runtime: RouteDataRuntime, + request: Request +): Promise => { + const formData = await request.formData(); + const intent = formString(formData, 'intent'); + const requestId = formString(formData, 'requestId'); + + if (runtime.getClient() === null) { + return { + intent: + intent === 'generateOobi' || + intent === 'delete' || + intent === 'updateAlias' + ? intent + : 'resolve', + ok: false, + message: 'Connect to KERIA before changing contacts.', + requestId, + }; + } + + try { + if (intent === 'resolve') { + const oobi = formString(formData, 'oobi').trim(); + const alias = formString(formData, 'alias').trim(); + if (oobi.length === 0) { + return { + intent, + ok: false, + message: 'OOBI URL is required.', + requestId, + }; + } + + const started = runtime.startResolveContact( + { + oobi, + alias: alias.length > 0 ? alias : null, + }, + { 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: 'Resolving contact OOBI', + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'generateOobi') { + const identifier = formString(formData, 'identifier').trim(); + const role = parseOobiRole(formString(formData, 'role')); + if (identifier.length === 0 || role === null) { + return { + intent, + ok: false, + message: 'Identifier and OOBI role are required.', + requestId, + }; + } + + const started = runtime.startGenerateOobi( + { identifier, role }, + { 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: `Generating ${role} OOBI for ${identifier}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'delete') { + const contactId = formString(formData, 'contactId').trim(); + if (contactId.length === 0) { + return { + intent, + ok: false, + message: 'Contact id is required.', + requestId, + }; + } + + const started = runtime.startDeleteContact(contactId, { + 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: `Deleting contact ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'updateAlias') { + const contactId = formString(formData, 'contactId').trim(); + const alias = formString(formData, 'alias').trim(); + if (contactId.length === 0 || alias.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and alias are required.', + requestId, + }; + } + + const started = runtime.startUpdateContactAlias( + { contactId, alias }, + { 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: `Updating contact ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + } catch (error) { + return { + intent: + intent === 'generateOobi' || + intent === 'delete' || + intent === 'updateAlias' + ? intent + : 'resolve', + ok: false, + message: toRouteError(error).message, + requestId, + }; + } + + return { + intent: 'unsupported', + ok: false, + message: `Unsupported contact action: ${intent || 'missing intent'}`, + requestId, + }; +}; diff --git a/src/app/router.tsx b/src/app/router.tsx index b410437a..34b67cdc 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -4,7 +4,10 @@ import { type RouteObject, } from 'react-router-dom'; import { ClientView } from '../features/client/ClientView'; +import { ContactDetailView } from '../features/contacts/ContactDetailView'; +import { ContactsView } from '../features/contacts/ContactsView'; import { CredentialsView } from '../features/credentials/CredentialsView'; +import { DashboardView } from '../features/dashboard/DashboardView'; import { IdentifiersView } from '../features/identifiers/IdentifiersView'; import { AppNotificationsView } from '../features/notifications/AppNotificationsView'; import { OperationDetailView } from '../features/operations/OperationDetailView'; @@ -12,7 +15,10 @@ import { OperationsView } from '../features/operations/OperationsView'; import type { AppRuntime } from './runtime'; import { DEFAULT_APP_PATH, + contactsAction, identifiersAction, + loadContacts, + loadDashboard, loadClient, loadCredentials, loadIdentifiers, @@ -25,6 +31,8 @@ import { RouteErrorBoundary } from './RouteErrorBoundary'; * Stable IDs for feature routes that appear in the app shell. */ export type AppRouteId = + | 'dashboard' + | 'contacts' | 'identifiers' | 'credentials' | 'client' @@ -85,6 +93,28 @@ interface AppFeatureRouteDescriptor { * Ordered feature route registry used only to build native route objects. */ const APP_FEATURE_ROUTES: readonly AppFeatureRouteDescriptor[] = [ + { + id: 'dashboard', + path: 'dashboard', + handle: { + routeId: 'dashboard', + label: 'Dashboard', + gate: 'client', + nav: true, + testId: 'nav-dashboard', + }, + }, + { + id: 'contacts', + path: 'contacts', + handle: { + routeId: 'contacts', + label: 'Contacts', + gate: 'client', + nav: true, + testId: 'nav-contacts', + }, + }, { id: 'identifiers', path: 'identifiers', @@ -172,10 +202,41 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ index: true, loader: () => redirect(DEFAULT_APP_PATH), }, + { + id: 'dashboard', + path: 'dashboard', + handle: APP_FEATURE_ROUTES[0].handle, + loader: ({ request }) => loadDashboard(runtime, request), + element: , + errorElement: ( + + ), + }, + { + id: 'contacts', + path: 'contacts', + handle: APP_FEATURE_ROUTES[1].handle, + loader: ({ request }) => loadContacts(runtime, request), + action: ({ request }) => contactsAction(runtime, request), + element: , + errorElement: ( + + ), + }, + { + id: 'contactDetail', + path: 'contacts/:contactId', + loader: ({ request }) => loadContacts(runtime, request), + action: ({ request }) => contactsAction(runtime, request), + element: , + errorElement: ( + + ), + }, { id: 'identifiers', path: 'identifiers', - handle: APP_FEATURE_ROUTES[0].handle, + handle: APP_FEATURE_ROUTES[2].handle, loader: ({ request }) => loadIdentifiers(runtime, request), action: ({ request }) => identifiersAction(runtime, request), element: , @@ -186,7 +247,7 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'credentials', path: 'credentials', - handle: APP_FEATURE_ROUTES[1].handle, + handle: APP_FEATURE_ROUTES[3].handle, loader: () => loadCredentials(runtime), element: , errorElement: ( @@ -196,7 +257,7 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'client', path: 'client', - handle: APP_FEATURE_ROUTES[2].handle, + handle: APP_FEATURE_ROUTES[4].handle, loader: ({ request }) => loadClient(runtime, request), element: , errorElement: , @@ -204,7 +265,7 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'operations', path: 'operations', - handle: APP_FEATURE_ROUTES[3].handle, + handle: APP_FEATURE_ROUTES[5].handle, element: , errorElement: ( @@ -221,7 +282,7 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'appNotifications', path: 'notifications', - handle: APP_FEATURE_ROUTES[4].handle, + handle: APP_FEATURE_ROUTES[6].handle, element: , errorElement: ( diff --git a/src/app/runtime.ts b/src/app/runtime.ts index f2f122f3..ba4f1b55 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -3,10 +3,13 @@ import type { SignifyClient } from 'signify-ts'; import { appConfig, type AppConfig } from '../config'; import { toErrorText } from '../effects/promise'; import { AppEffectionScopes, type RuntimeScopeKind } from '../effects/scope'; +import { aliasForOobiResolution } from '../features/contacts/contactHelpers'; import type { IdentifierCreateDraft, IdentifierSummary, } from '../features/identifiers/identifierTypes'; +import type { ResolveContactInput } from '../services/contacts.service'; +import type { GeneratedOobiRecord } from '../state/contacts.slice'; import { toError, type ConnectedSignifyClient, @@ -26,10 +29,12 @@ import { type OperationRouteLink, operationCanceled, operationFailed, + operationPayloadDetailsRecorded, operationResultLinked, operationStarted, operationSucceeded, } from '../state/operations.slice'; +import type { PayloadDetailRecord } from '../state/payloadDetails'; import { flushPersistedAppState, installAppStatePersistence, @@ -44,6 +49,17 @@ import { listIdentifiersOp, rotateIdentifierOp, } from '../workflows/identifiers.op'; +import { + deleteContactOp, + generateOobiOp, + liveSessionInventoryOp, + resolveContactOobiOp, + syncSessionInventoryOp, + updateContactAliasOp, + type GenerateOobiInput, + type SessionInventorySnapshot, + type UpdateContactAliasInput, +} from '../workflows/contacts.op'; import { bootOrConnectOp, getSignifyStateOp, @@ -205,6 +221,89 @@ const operationRoute = (requestId: string): string => const notificationId = (requestId: string): string => `notification-${requestId}-${Date.now()}`; +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 stringArray = (value: unknown): string[] => + Array.isArray(value) + ? value.flatMap((item) => { + const text = stringValue(item); + return text === null ? [] : [text]; + }) + : []; + +const detailId = (label: string, index: number): string => + `${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${index}`; + +const payloadDetailsFromWorkflowResult = ( + result: unknown +): PayloadDetailRecord[] => { + if (!isRecord(result)) { + return []; + } + + const details: PayloadDetailRecord[] = []; + const generatedOobis = stringArray(result.oobis); + generatedOobis.forEach((oobi, index) => { + details.push({ + id: detailId('generated-oobi', index), + label: generatedOobis.length === 1 ? 'OOBI' : `OOBI ${index + 1}`, + value: oobi, + kind: 'oobi', + copyable: true, + }); + }); + + const sourceOobi = stringValue(result.sourceOobi); + if (sourceOobi !== null) { + details.push({ + id: detailId('source-oobi', details.length), + label: 'OOBI', + value: sourceOobi, + kind: 'oobi', + copyable: true, + }); + } + + const resolutionOobi = stringValue(result.resolutionOobi); + if (resolutionOobi !== null && resolutionOobi !== sourceOobi) { + details.push({ + id: detailId('resolution-oobi', details.length), + label: 'Resolved URL', + value: resolutionOobi, + kind: 'oobi', + copyable: true, + }); + } + + const resolvedAid = stringValue(result.resolvedAid); + if (resolvedAid !== null) { + details.push({ + id: detailId('resolved-aid', details.length), + label: 'AID', + value: resolvedAid, + kind: 'aid', + copyable: true, + }); + } + + const seen = new Set(); + return details.filter((detail) => { + const key = `${detail.label}:${detail.value}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +}; + /** * Data-router-safe Signify session and command boundary. * @@ -222,6 +321,8 @@ export class AppRuntime { private readonly activeTasks = new Map>(); + private liveSyncTask: Task | null = null; + private readonly storage: AppStateStorage | null | undefined; private currentControllerAid: string | null = null; @@ -339,6 +440,7 @@ export class AppRuntime { error: null, booted: connected.booted, }); + this.startLiveSync(); return connected; } catch (error) { const normalized = toError(error); @@ -362,6 +464,7 @@ export class AppRuntime { reason: 'Session disconnected.', }) ); + void this.stopLiveSync(); this.flushPersistence(); void this.scopes.haltSession(); this.store.dispatch(sessionDisconnected()); @@ -435,6 +538,47 @@ export class AppRuntime { 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 + * endpoint role through the shared OOBI workflow when needed. + */ + getIdentifierOobi = async ( + input: GenerateOobiInput, + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => generateOobiOp(input), { + ...options, + label: options.label, + kind: options.kind ?? 'generateOobi', + track: options.track ?? false, + }); + + /** + * Fetch all requested OOBI roles for one managed identifier. + */ + listIdentifierOobis = async ( + identifier: string, + roles: readonly GenerateOobiInput['role'][], + options: WorkflowRunOptions = {} + ): Promise => { + const records: GeneratedOobiRecord[] = []; + for (const role of roles) { + records.push( + await this.getIdentifierOobi( + { identifier, role }, + { + ...options, + label: options.label, + track: options.track ?? false, + } + ) + ); + } + + return records; + }; + /** * Create an identifier, wait for the resulting KERIA operation, then return * a freshly loaded identifier list for router revalidation callers. @@ -465,6 +609,19 @@ export class AppRuntime { }); }; + /** + * Refresh live dashboard/contact facts without recording operation history. + */ + syncSessionInventory = async ( + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => syncSessionInventoryOp(), { + ...options, + label: options.label, + kind: options.kind ?? 'syncInventory', + track: options.track ?? false, + }); + startCreateIdentifier = ( draft: IdentifierCreateDraft, options: Pick = {} @@ -517,6 +674,128 @@ export class AppRuntime { }, }); + startGenerateOobi = ( + input: GenerateOobiInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => { + const identifier = input.identifier.trim(); + return this.startBackgroundWorkflow( + () => generateOobiOp({ identifier, role: input.role }), + { + requestId: options.requestId, + label: `Generating ${input.role} OOBI for ${identifier}`, + title: `Generate ${input.role} OOBI`, + description: + input.role === 'agent' + ? 'Authorizes the agent endpoint role if needed, then fetches an identifier OOBI.' + : 'Fetches witnessed identifier OOBIs from KERIA.', + kind: 'generateOobi', + resourceKeys: [`oobi:${identifier}:${input.role}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'OOBI generated', + message: `Generated a ${input.role} OOBI for ${identifier}.`, + severity: 'success', + }, + failureNotification: { + title: 'OOBI generation failed', + message: `The ${input.role} OOBI generation for ${identifier} failed.`, + severity: 'error', + }, + } + ); + }; + + startResolveContact = ( + input: ResolveContactInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => { + const oobi = input.oobi.trim(); + const alias = aliasForOobiResolution(oobi, input.alias); + const resourceKeys = [`contact:oobi:${oobi}`]; + if (alias !== null) { + resourceKeys.push(`contact:alias:${alias}`); + } + + return this.startBackgroundWorkflow( + () => resolveContactOobiOp({ oobi, alias }), + { + requestId: options.requestId, + label: + alias === null + ? 'Resolving contact OOBI' + : `Resolving contact ${alias}`, + title: 'Resolve contact OOBI', + description: + 'Submits an OOBI to KERIA and refreshes contact inventory after the operation completes.', + kind: 'resolveContact', + resourceKeys, + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact resolved', + message: + alias === null + ? 'The contact OOBI resolved successfully.' + : `${alias} resolved successfully.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact resolution failed', + message: 'The OOBI resolution failed.', + severity: 'error', + }, + } + ); + }; + + startDeleteContact = ( + contactId: string, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => deleteContactOp(contactId), { + requestId: options.requestId, + label: `Deleting contact ${contactId}`, + title: 'Delete contact', + description: 'Deletes a KERIA contact and refreshes inventory.', + kind: 'deleteContact', + resourceKeys: [`contact:${contactId}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact deleted', + message: `${contactId} was deleted.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact deletion failed', + message: `${contactId} could not be deleted.`, + severity: 'error', + }, + }); + + startUpdateContactAlias = ( + input: UpdateContactAliasInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => updateContactAliasOp(input), { + requestId: options.requestId, + label: `Updating contact ${input.contactId}`, + title: 'Update contact alias', + description: 'Updates local KERIA contact metadata.', + kind: 'updateContact', + resourceKeys: [`contact:${input.contactId}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact updated', + message: `${input.contactId} was updated.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact update failed', + message: `${input.contactId} could not be updated.`, + severity: 'error', + }, + }); + /** * Start a non-blocking workflow and return accepted/conflict metadata. * @@ -656,9 +935,24 @@ export class AppRuntime { options: BackgroundWorkflowRunOptions ): Promise => { try { - await task; + const result = await task; + const payloadDetails = payloadDetailsFromWorkflowResult(result); + if (payloadDetails.length > 0) { + this.store.dispatch( + operationPayloadDetailsRecorded({ + requestId, + payloadDetails, + }) + ); + } this.store.dispatch(operationSucceeded({ requestId })); - this.recordCompletionNotification(requestId, options, 'success'); + this.recordCompletionNotification( + requestId, + options, + 'success', + undefined, + payloadDetails + ); } catch (error) { if (isHaltedOrAborted(error)) { this.store.dispatch( @@ -696,7 +990,8 @@ export class AppRuntime { requestId: string, options: BackgroundWorkflowRunOptions, outcome: 'success' | 'error', - error?: string + error?: string, + payloadDetails: PayloadDetailRecord[] = [] ): void => { const template = outcome === 'success' @@ -737,6 +1032,7 @@ export class AppRuntime { readAt: null, operationId: requestId, links, + payloadDetails, }; this.store.dispatch(appNotificationRecorded(notification)); @@ -793,6 +1089,7 @@ export class AppRuntime { ); this.flushPersistence(); + await this.stopLiveSync(); for (const task of this.activeTasks.values()) { await task.halt(); } @@ -834,6 +1131,52 @@ export class AppRuntime { } }; + private startLiveSync = (): void => { + if (this.liveSyncTask !== null) { + void this.liveSyncTask.halt(); + } + + const task = this.scopes.run(() => liveSessionInventoryOp(), '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; + } + } + })(); + }; + + private stopLiveSync = async (): Promise => { + const task = this.liveSyncTask; + if (task === null) { + return; + } + + this.liveSyncTask = null; + await task.halt(); + }; + private setPersistenceController = (controllerAid: string | null): void => { if (controllerAid === this.currentControllerAid) { return; diff --git a/src/config.ts b/src/config.ts index 6910a558..613f334a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,8 @@ export interface OperationConfig { minSleepMs: number; /** Maximum polling interval passed to Signify's operation waiter. */ maxSleepMs: number; + /** Session inventory polling interval for dashboard/contact facts. */ + liveRefreshMs: number; } export interface WitnessConfig { @@ -278,6 +280,11 @@ export const buildAppConfig = (runtimeEnv: RuntimeEnv): AppConfig => { runtimeEnv.VITE_OPERATION_MAX_SLEEP_MS, 5000 ), + liveRefreshMs: numberFromEnv( + 'VITE_LIVE_REFRESH_MS', + runtimeEnv.VITE_LIVE_REFRESH_MS, + 3000 + ), }, witnesses: { aids: csvFromEnv( diff --git a/src/features/contacts/ContactDetailView.tsx b/src/features/contacts/ContactDetailView.tsx new file mode 100644 index 00000000..440925a3 --- /dev/null +++ b/src/features/contacts/ContactDetailView.tsx @@ -0,0 +1,533 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Divider, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SaveIcon from '@mui/icons-material/Save'; +import ShieldIcon from '@mui/icons-material/Shield'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import { + Link as RouterLink, + useFetcher, + useLoaderData, + useNavigate, + useParams, +} from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { monoValueSx } from '../../app/consoleStyles'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { + ContactActionData, + ContactsLoaderData, +} from '../../app/routeData'; +import type { ContactRecord } from '../../state/contacts.slice'; +import type { ChallengeRecord } from '../../state/challenges.slice'; +import { useAppSelector } from '../../state/hooks'; +import { + selectChallengesForContact, + selectContactById, +} from '../../state/selectors'; +import { + contactChallengeStatus, + contactOobiGroups, + contactOobiRoleSummary, +} from './contactHelpers'; +import type { ContactOobiGroup } from './contactHelpers'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const copyText = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +const resolutionTone = ( + status: ContactRecord['resolutionStatus'] +): 'success' | 'warning' | 'error' | 'neutral' => + status === 'error' + ? 'error' + : status === 'resolving' + ? 'warning' + : status === 'resolved' + ? 'success' + : 'neutral'; + +export const ContactDetailView = () => { + const loaderData = useLoaderData() as ContactsLoaderData; + const { contactId = '' } = useParams(); + const navigate = useNavigate(); + const fetcher = useFetcher(); + const contact = useAppSelector(selectContactById(contactId)); + const challenges = useAppSelector(selectChallengesForContact(contactId)); + const [aliasDraft, setAliasDraft] = useState({ + contactId, + value: contact?.alias ?? '', + }); + const actionRunning = fetcher.state !== 'idle'; + + useEffect(() => { + if (fetcher.data?.ok === true && fetcher.data.intent === 'delete') { + navigate('/contacts'); + } + }, [fetcher.data, navigate]); + + if (loaderData.status === 'blocked') { + return ; + } + + const submitAliasUpdate = () => { + if (contact === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'updateAlias'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.id); + formData.set('alias', draftAlias); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitDelete = () => { + if (contact === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'delete'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.id); + fetcher.submit(formData, { method: 'post' }); + }; + + if (contact === null) { + return ( + + } + > + Back to contacts + + } + /> + + + ); + } + + const roleSummary = contactOobiRoleSummary(contact); + const oobiGroups = contactOobiGroups(contact); + const challengeStatus = contactChallengeStatus(contact); + const Shield = challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; + const shieldColor = + challengeStatus.status === 'verified' + ? 'success.main' + : challengeStatus.status === 'pending' + ? 'warning.main' + : 'text.disabled'; + const aid = contact.aid ?? contact.id; + const draftAlias = + aliasDraft.contactId === contact.id ? aliasDraft.value : contact.alias; + + return ( + + } + > + Back to contacts + + } + /> + {loaderData.status === 'error' && ( + + {' '} + {loaderData.message} + + )} + {fetcher.data !== undefined && ( + + {' '} + {fetcher.data.message} + + )} + + + + + + {challengeStatus.label} + + + + + + } + > + + + { + setAliasDraft({ + contactId: contact.id, + value: event.target.value, + }); + }} + fullWidth + size="small" + /> + + + + + + + + + + + + + + + + + + + + {contact.error !== null && ( + + {contact.error} + + )} + {oobiGroups.length > 0 && ( + + )} + + + + {contact.endpoints.length === 0 ? ( + + ) : ( + + {contact.endpoints.map((endpoint) => ( + + + + + {endpoint.scheme} + + + + + {endpoint.eid} + + + ))} + + )} + + + + {contact.wellKnowns.length === 0 ? ( + + ) : ( + + {contact.wellKnowns.map((wellKnown) => ( + + + + {timestampText(wellKnown.dt)} + + + ))} + + )} + + + {challenges.length === 0 ? ( + + ) : ( + + {challenges.map((challenge) => ( + + ))} + + )} + + + + ); +}; + +const CopyBlock = ({ label, value }: { label: string; value: string }) => ( + + + + {label} + + + {value} + + + + { + copyText(value); + }} + > + + + + +); + +const FullOobiBlock = ({ groups }: { groups: readonly ContactOobiGroup[] }) => ( + + + Full OOBI + + + {groups.map((group) => ( + + + {group.label} + + + {group.oobis.map((oobi) => ( + + ))} + + + ))} + + +); + +const ChallengeBlock = ({ challenge }: { challenge: ChallengeRecord }) => ( + + + + + {timestampText(challenge.updatedAt)} + + + + + + +); diff --git a/src/features/contacts/ContactsView.tsx b/src/features/contacts/ContactsView.tsx new file mode 100644 index 00000000..fb5a41a6 --- /dev/null +++ b/src/features/contacts/ContactsView.tsx @@ -0,0 +1,711 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + FormControl, + IconButton, + InputLabel, + List, + ListItem, + ListItemText, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LinkIcon from '@mui/icons-material/Link'; +import ShieldIcon from '@mui/icons-material/Shield'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import { + Link as RouterLink, + useFetcher, + useLoaderData, +} from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, +} from '../../app/Console'; +import { clickablePanelSx, monoValueSx } from '../../app/consoleStyles'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { + ContactActionData, + ContactsLoaderData, +} from '../../app/routeData'; +import type { ContactRecord } from '../../state/contacts.slice'; +import { useAppSelector } from '../../state/hooks'; +import { + selectContacts, + selectGeneratedOobis, + selectIdentifiers, +} from '../../state/selectors'; +import { + abbreviateMiddle, + aliasFromOobi, + contactChallengeStatus, + contactOobiRoleSummary, + identifierAvailableOobiRoles, + isWitnessContact, + type OobiGenerationRole, +} from './contactHelpers'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const copyText = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +const contactDetailPath = (contactId: string): string => + `/contacts/${encodeURIComponent(contactId)}`; + +const byAlias = (left: ContactRecord, right: ContactRecord): number => + left.alias.localeCompare(right.alias, undefined, { + sensitivity: 'base', + numeric: true, + }) || + (left.aid ?? left.id).localeCompare(right.aid ?? right.id, undefined, { + sensitivity: 'base', + numeric: true, + }); + +const roleOptionLabel = (role: OobiGenerationRole): string => + role === 'agent' ? 'Agent' : 'Witness'; + +const resolutionTone = ( + status: ContactRecord['resolutionStatus'] +): 'success' | 'warning' | 'error' | 'neutral' => + status === 'error' + ? 'error' + : status === 'resolving' + ? 'warning' + : status === 'resolved' + ? 'success' + : 'neutral'; + +export const ContactsView = () => { + const loaderData = useLoaderData() as ContactsLoaderData; + const fetcher = useFetcher(); + const identifiers = useAppSelector(selectIdentifiers); + const contacts = useAppSelector(selectContacts); + const generatedOobis = useAppSelector(selectGeneratedOobis); + const [oobi, setOobi] = useState(''); + const [alias, setAlias] = useState(''); + const [aliasTouched, setAliasTouched] = useState(false); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [selectedRole, setSelectedRole] = + useState('agent'); + const [pendingGenerateCopy, setPendingGenerateCopy] = useState<{ + id: string; + submittedAt: string; + } | null>(null); + const [pendingResolveClear, setPendingResolveClear] = useState<{ + oobi: string; + alias: string; + submittedAt: string; + } | null>(null); + const autoCopiedGeneratedOobis = useRef(new Set()); + const actionRunning = fetcher.state !== 'idle'; + const activeIdentifier = selectedIdentifier || identifiers[0]?.name || ''; + const activeIdentifierSummary = + identifiers.find((identifier) => identifier.name === activeIdentifier) ?? + null; + const roleOptions = useMemo( + () => identifierAvailableOobiRoles(activeIdentifierSummary), + [activeIdentifierSummary] + ); + const effectiveRole = roleOptions.includes(selectedRole) + ? selectedRole + : (roleOptions[0] ?? 'agent'); + const roleSelectValue = roleOptions.length === 0 ? '' : effectiveRole; + const effectiveAlias = aliasTouched ? alias : (aliasFromOobi(oobi) ?? ''); + const sortedContacts = useMemo( + () => [...contacts].sort(byAlias), + [contacts] + ); + const regularContacts = sortedContacts.filter( + (contact) => !isWitnessContact(contact) + ); + const witnessContacts = sortedContacts.filter(isWitnessContact); + + useEffect(() => { + if (pendingGenerateCopy === null) { + return undefined; + } + + const record = generatedOobis.find( + (candidate) => + candidate.id === pendingGenerateCopy.id && + candidate.generatedAt >= pendingGenerateCopy.submittedAt + ); + const firstOobi = record?.oobis[0]; + if (record === undefined || firstOobi === undefined) { + return undefined; + } + + const copyKey = `${record.id}:${record.generatedAt}:${firstOobi}`; + if (autoCopiedGeneratedOobis.current.has(copyKey)) { + return undefined; + } + + autoCopiedGeneratedOobis.current.add(copyKey); + copyText(firstOobi); + const clearPending = globalThis.setTimeout(() => { + setPendingGenerateCopy(null); + }, 0); + + return () => { + globalThis.clearTimeout(clearPending); + }; + }, [generatedOobis, pendingGenerateCopy]); + + useEffect(() => { + if (pendingResolveClear === null) { + return undefined; + } + + const resolved = contacts.some( + (contact) => + contact.resolutionStatus === 'resolved' && + (contact.oobi === pendingResolveClear.oobi || + (pendingResolveClear.alias.length > 0 && + contact.alias === pendingResolveClear.alias)) && + (contact.updatedAt ?? '') >= pendingResolveClear.submittedAt + ); + if (!resolved) { + return undefined; + } + + const clearForm = globalThis.setTimeout(() => { + setOobi(''); + setAlias(''); + setAliasTouched(false); + setPendingResolveClear(null); + }, 0); + + return () => { + globalThis.clearTimeout(clearForm); + }; + }, [contacts, pendingResolveClear]); + + if (loaderData.status === 'blocked') { + return ; + } + + const actionStatus = + fetcher.data === undefined + ? loaderData.status === 'error' + ? { ok: false, message: loaderData.message } + : null + : fetcher.data; + + const submitResolve = () => { + const submittedAt = new Date().toISOString(); + const requestId = globalThis.crypto.randomUUID(); + const formData = new FormData(); + formData.set('intent', 'resolve'); + formData.set('requestId', requestId); + formData.set('oobi', oobi); + formData.set('alias', effectiveAlias); + setPendingResolveClear({ + oobi: oobi.trim(), + alias: effectiveAlias.trim(), + submittedAt, + }); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitGenerate = () => { + const submittedAt = new Date().toISOString(); + const requestId = globalThis.crypto.randomUUID(); + const formData = new FormData(); + formData.set('intent', 'generateOobi'); + formData.set('requestId', requestId); + formData.set('identifier', activeIdentifier); + formData.set('role', effectiveRole); + setPendingGenerateCopy({ + id: `${activeIdentifier}:${effectiveRole}`, + submittedAt, + }); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitDelete = (contactId: string) => { + const formData = new FormData(); + formData.set('intent', 'delete'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contactId); + fetcher.submit(formData, { method: 'post' }); + }; + + return ( + + + {actionStatus !== null && ( + + {' '} + {actionStatus.message} + + )} + + + + { + setOobi(event.target.value); + }} + fullWidth + multiline + minRows={2} + data-testid="contact-oobi-input" + /> + { + setAliasTouched(true); + setAlias(event.target.value); + }} + fullWidth + data-testid="contact-alias-input" + /> + + + + + + + + Identifier + + + + + Role + + + + {generatedOobis.length > 0 && ( + + {generatedOobis.map((record) => ( + + + + {timestampText( + record.generatedAt + )} + + {record.oobis.map( + (generated) => ( + + + {generated} + + + { + copyText( + generated + ); + }} + > + + + + + ) + )} + + } + /> + + ))} + + )} + + + + + {contacts.length === 0 ? ( + + ) : ( + + {regularContacts.length === 0 ? ( + + Only witness contacts are currently known. Expand + the witnesses section below to inspect them. + + ) : ( + + )} + {witnessContacts.length > 0 && ( + + } + aria-controls="witness-contacts-content" + id="witness-contacts-header" + > + + + Witnesses + + + {witnessContacts.length} known witness + {witnessContacts.length === 1 + ? '' + : 'es'} + + + + + + + + )} + + )} + + + ); +}; + +const ContactGrid = ({ + contacts, + actionRunning, + onDelete, +}: { + contacts: readonly ContactRecord[]; + actionRunning: boolean; + onDelete: (contactId: string) => void; +}) => ( + + {contacts.map((contact) => ( + + ))} + +); + +const ContactCard = ({ + contact, + actionRunning, + onDelete, +}: { + contact: ContactRecord; + actionRunning: boolean; + onDelete: (contactId: string) => void; +}) => { + const roleSummary = contactOobiRoleSummary(contact); + const challengeStatus = contactChallengeStatus(contact); + const Shield = challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; + const shieldColor = + challengeStatus.status === 'verified' + ? 'success.main' + : challengeStatus.status === 'pending' + ? 'warning.main' + : 'text.disabled'; + const aid = contact.aid ?? contact.id; + + return ( + + + + + + {contact.alias} + + + {roleSummary.label} + + + + + + + + {abbreviateMiddle(aid, 36)} + + {contact.oobi !== null && ( + + {abbreviateMiddle(contact.oobi, 44)} + + )} + + + + {contact.endpoints.length} endpoints ·{' '} + {contact.wellKnowns.length} well-knowns + + + + + {contact.oobi !== null && ( + + { + copyText(contact.oobi ?? ''); + }} + > + + + + )} + + + { + onDelete(contact.id); + }} + > + + + + + + + ); +}; diff --git a/src/features/contacts/contactHelpers.ts b/src/features/contacts/contactHelpers.ts new file mode 100644 index 00000000..48aac610 --- /dev/null +++ b/src/features/contacts/contactHelpers.ts @@ -0,0 +1,607 @@ +import type { Contact } from 'signify-ts'; +import type { + ContactEndpoint, + ContactEndpointRole, + ContactRecord, + ContactWellKnown, +} from '../../state/contacts.slice'; +import type { ChallengeRecord } from '../../state/challenges.slice'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; + +export const CONTACT_ENDPOINT_ROLES = [ + 'agent', + 'controller', + 'witness', + 'registrar', + 'watcher', + 'judge', + 'juror', + 'peer', + 'mailbox', +] as const satisfies readonly ContactEndpointRole[]; + +export type OobiGenerationRole = 'agent' | 'witness'; + +export type ContactChallengeDisplayStatus = + | 'verified' + | 'pending' + | 'unverified'; + +export interface ContactOobiRoleSummary { + primaryRole: ContactEndpointRole | null; + roles: ContactEndpointRole[]; + label: string; +} + +export interface ContactChallengeStatusSummary { + status: ContactChallengeDisplayStatus; + label: string; + tooltip: string; +} + +export interface ContactOobiGroup { + role: ContactEndpointRole; + label: string; + oobis: string[]; +} + +const CONTACT_ENDPOINT_ROLE_SET = new Set(CONTACT_ENDPOINT_ROLES); +const CONTACT_OOBI_DETAIL_ROLES = [ + 'agent', + 'witness', + 'mailbox', + 'controller', +] as const satisfies readonly ContactEndpointRole[]; +const CONTACT_OOBI_DETAIL_ROLE_SET = new Set( + CONTACT_OOBI_DETAIL_ROLES +); + +const roleLabels: Record = { + agent: 'Agent', + controller: 'Controller', + witness: 'Witness', + registrar: 'Registrar', + watcher: 'Watcher', + judge: 'Judge', + juror: 'Juror', + peer: 'Peer', + mailbox: 'Mailbox', +}; + +const normalizeEndpointRole = (role: string | null): ContactEndpointRole | null => + role !== null && CONTACT_ENDPOINT_ROLE_SET.has(role) + ? (role as ContactEndpointRole) + : null; + +const pushUniqueRole = ( + roles: ContactEndpointRole[], + role: ContactEndpointRole | null +): void => { + if (role !== null && !roles.includes(role)) { + roles.push(role); + } +}; + +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 : null; + +const endpointScheme = (url: string): string => { + try { + return new URL(url).protocol.replace(':', '') || 'unknown'; + } catch { + return 'unknown'; + } +}; + +const endpointRoleRecords = ( + role: ContactEndpointRole, + value: unknown +): ContactEndpoint[] => { + if (!isRecord(value)) { + return []; + } + + return Object.entries(value).flatMap(([eid, rawEndpoint]) => { + const directUrl = stringValue(rawEndpoint); + if (directUrl !== null) { + return [ + { + role, + eid, + scheme: endpointScheme(directUrl), + url: directUrl, + }, + ]; + } + + if (!isRecord(rawEndpoint)) { + return []; + } + + return Object.entries(rawEndpoint).flatMap(([scheme, rawUrl]) => { + const url = stringValue(rawUrl); + if (url === null) { + return []; + } + + return [ + { + role, + eid, + scheme, + url, + }, + ]; + }); + }); +}; + +const webBaseUrl = (url: string): string | null => { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } +}; + +const endpointOobiUrl = ( + contact: ContactRecord, + endpoint: ContactEndpoint +): string | null => { + const baseUrl = webBaseUrl(endpoint.url); + if (baseUrl === null) { + return null; + } + + const contactAid = contact.aid ?? contact.id; + switch (endpoint.role) { + case 'agent': + return `${baseUrl}/oobi/${contactAid}/agent/${endpoint.eid}`; + case 'witness': + return `${baseUrl}/oobi/${endpoint.eid}/controller?tag=witness`; + case 'controller': + return `${baseUrl}/oobi/${endpoint.eid}/controller`; + case 'mailbox': + return `${baseUrl}/oobi/${contactAid}/mailbox/${endpoint.eid}`; + default: + return null; + } +}; + +/** + * Extract a useful alias from an OOBI URL query string when one is present. + */ +export const aliasFromOobi = (oobi: string): string | null => { + try { + return stringValue(new URL(oobi).searchParams.get('name')); + } catch { + return null; + } +}; + +/** + * Extract the resolved AID from common KERIA OOBI URL paths. + */ +export const aidFromOobi = (oobi: string | null | undefined): string | null => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return null; + } + + try { + const url = new URL(oobi); + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + return oobiIndex >= 0 ? stringValue(parts[oobiIndex + 1]) : null; + } catch { + return null; + } +}; + +/** + * Remove app-local alias hints before handing an OOBI to KERIA. + */ +export const normalizeOobiUrlForResolution = (oobi: string): string => { + const trimmed = oobi.trim(); + try { + const url = new URL(trimmed); + url.searchParams.delete('name'); + return url.toString(); + } catch { + return trimmed; + } +}; + +/** + * Prefer a user-entered alias, then the OOBI `name` query hint. + */ +export const aliasForOobiResolution = ( + oobi: string, + alias: string | null | undefined +): string | null => { + const trimmedAlias = stringValue(alias); + return trimmedAlias ?? aliasFromOobi(oobi); +}; + +/** + * Stable placeholder id used while KERIA resolution is still in flight. + */ +export const pendingContactIdForOobi = ( + oobi: string, + alias: string | null | undefined +): string => `pending:${aliasForOobiResolution(oobi, alias) ?? oobi.trim()}`; + +/** + * Extract likely component tags from KERIA OOBI URLs. + */ +export const componentTagsFromOobi = (oobi: string | null | undefined): string[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const tags = new Set(); + const queryTag = stringValue(url.searchParams.get('tag')); + const queryRole = stringValue(url.searchParams.get('role')); + if (queryTag !== null) { + tags.add(queryTag); + } + if (queryRole !== null) { + tags.add(queryRole); + } + + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + const role = oobiIndex >= 0 ? parts[oobiIndex + 2] : null; + if (role !== null && role !== undefined) { + tags.add(role); + } + + return [...tags]; + } catch { + return []; + } +}; + +/** + * Extract endpoint-role candidates from an OOBI, preserving source priority. + * + * Query tags are more semantically useful for witness/controller OOBIs: local + * witness URLs commonly use `/controller?role=witness`, where the role label + * users care about is "Witness" rather than "Controller". + */ +export const endpointRolesFromOobi = ( + oobi: string | null | undefined +): ContactEndpointRole[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const roles: ContactEndpointRole[] = []; + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('tag'))) + ); + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('role'))) + ); + + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + const pathRole = + oobiIndex >= 0 ? stringValue(parts[oobiIndex + 2]) : null; + pushUniqueRole(roles, normalizeEndpointRole(pathRole)); + + return roles; + } catch { + return []; + } +}; + +/** + * Extract only explicit role metadata from OOBI query hints. + * + * This is intentionally narrower than `endpointRolesFromOobi`: a regular + * witnessed identifier can carry `ends.witness` records, but that does not make + * the contact itself a witness component. + */ +export const explicitOobiMetadataRoles = ( + oobi: string | null | undefined +): ContactEndpointRole[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const roles: ContactEndpointRole[] = []; + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('tag'))) + ); + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('role'))) + ); + return roles; + } catch { + return []; + } +}; + +/** + * Group full OOBI URLs by role for the contact detail view. + */ +export const contactOobiGroups = ( + contact: ContactRecord +): ContactOobiGroup[] => { + const groups = new Map>(); + const addOobi = (role: ContactEndpointRole | null, oobi: string | null) => { + if ( + role === null || + oobi === null || + !CONTACT_OOBI_DETAIL_ROLE_SET.has(role) + ) { + return; + } + + const existing = groups.get(role) ?? new Set(); + existing.add(oobi); + groups.set(role, existing); + }; + + if (contact.oobi !== null) { + const sourceRoles = explicitOobiMetadataRoles(contact.oobi); + const roles = + sourceRoles.length > 0 + ? sourceRoles + : endpointRolesFromOobi(contact.oobi); + for (const role of roles) { + addOobi(role, contact.oobi); + } + } + + for (const endpoint of contact.endpoints) { + addOobi(endpoint.role, endpointOobiUrl(contact, endpoint)); + } + + return CONTACT_OOBI_DETAIL_ROLES.flatMap((role) => { + const oobis = [...(groups.get(role) ?? [])]; + return oobis.length === 0 + ? [] + : [ + { + role, + label: `${roleLabels[role]} OOBI`, + oobis, + }, + ]; + }); +}; + +/** + * Keep long OOBIs and AIDs recognizable without letting them dominate cards. + */ +export const abbreviateMiddle = (value: string, maxLength = 56): string => { + if (value.length <= maxLength) { + return value; + } + + const edgeLength = Math.max(8, Math.floor((maxLength - 3) / 2)); + return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; +}; + +/** + * Summarize the most relevant OOBI/endpoint role for compact contact cards. + */ +export const contactOobiRoleSummary = ( + contact: ContactRecord +): ContactOobiRoleSummary => { + const roles: ContactEndpointRole[] = []; + for (const role of endpointRolesFromOobi(contact.oobi)) { + pushUniqueRole(roles, role); + } + for (const endpoint of contact.endpoints) { + pushUniqueRole(roles, endpoint.role); + } + for (const tag of contact.componentTags) { + pushUniqueRole(roles, normalizeEndpointRole(tag)); + } + + const primaryRole = roles[0] ?? null; + if (primaryRole === null) { + return { + primaryRole, + roles, + label: 'Unknown OOBI', + }; + } + + const extraCount = roles.length - 1; + return { + primaryRole, + roles, + label: `${roleLabels[primaryRole]} OOBI${extraCount > 0 ? ` +${extraCount}` : ''}`, + }; +}; + +/** + * Witness records are useful, but they should not crowd the main contact list. + */ +export const isWitnessContact = (contact: ContactRecord): boolean => + explicitOobiMetadataRoles(contact.oobi).includes('witness'); + +/** + * Shield state for compact contact cards and contact detail headers. + */ +export const contactChallengeStatus = ( + contact: ContactRecord +): ContactChallengeStatusSummary => { + if (contact.authenticatedChallengeCount > 0) { + return { + status: 'verified', + label: 'Verified', + tooltip: `${contact.authenticatedChallengeCount} authenticated challenge${contact.authenticatedChallengeCount === 1 ? '' : 's'}`, + }; + } + + if (contact.challengeCount > 0) { + return { + status: 'pending', + label: 'Challenge pending', + tooltip: `${contact.challengeCount} challenge response${contact.challengeCount === 1 ? '' : 's'} known, none authenticated`, + }; + } + + return { + status: 'unverified', + label: 'Unverified', + tooltip: 'No authenticated challenge is known for this contact', + }; +}; + +/** + * Available local OOBI generation roles for a managed identifier. + */ +export const identifierAvailableOobiRoles = ( + identifier: IdentifierSummary | null | undefined +): OobiGenerationRole[] => { + if (identifier === null || identifier === undefined) { + return []; + } + + const witnessBackers = (identifier as { state?: { b?: unknown } }).state?.b; + const hasWitnessBackers = + Array.isArray(witnessBackers) && + witnessBackers.some((backer) => typeof backer === 'string'); + const hasWitnessIndexes = + Array.isArray(identifier.windexes) && identifier.windexes.length > 0; + + return hasWitnessBackers || hasWitnessIndexes + ? ['agent', 'witness'] + : ['agent']; +}; + +const normalizeWellKnowns = (contact: Contact): ContactWellKnown[] => + (contact.wellKnowns ?? []).flatMap((item) => { + const url = stringValue(item.url); + const dt = stringValue(item.dt); + if (url === null || dt === null) { + return []; + } + + return [{ url, dt }]; + }); + +const normalizeEndpoints = (contact: Contact): ContactEndpoint[] => + CONTACT_ENDPOINT_ROLES.flatMap((role) => + endpointRoleRecords(role, contact.ends?.[role]) + ); + +/** + * Project KERIA's loose contact shape into serializable app state. + */ +export const contactRecordFromKeriaContact = ( + contact: Contact, + updatedAt: string +): ContactRecord => { + const alias = stringValue(contact.alias) ?? contact.id; + const oobi = stringValue(contact.oobi); + const challenges = contact.challenges ?? []; + + return { + id: contact.id, + alias, + aid: contact.id, + oobi, + endpoints: normalizeEndpoints(contact), + wellKnowns: normalizeWellKnowns(contact), + componentTags: componentTagsFromOobi(oobi), + challengeCount: challenges.length, + authenticatedChallengeCount: challenges.filter( + (challenge) => challenge.authenticated === true + ).length, + resolutionStatus: 'resolved', + error: null, + updatedAt, + }; +}; + +/** + * Convert contact challenge arrays into read-only dashboard challenge records. + */ +export const challengeRecordsFromKeriaContacts = ( + contacts: Contact[], + updatedAt: string +): ChallengeRecord[] => + contacts.flatMap((contact) => + (contact.challenges ?? []).map((challenge, index) => { + const said = stringValue(challenge.said); + const authenticated = challenge.authenticated === true; + return { + id: `${contact.id}:${said ?? index.toString()}`, + direction: 'received', + role: stringValue(contact.alias) ?? contact.id, + counterpartyAid: contact.id, + words: challenge.words, + authenticated, + status: authenticated ? 'verified' : 'responded', + result: said, + updatedAt: stringValue(challenge.dt) ?? updatedAt, + }; + }) + ); + +export interface KnownComponentRecord { + id: string; + contactId: string; + alias: string; + role: string; + eid: string | null; + scheme: string | null; + url: string | null; + source: 'endpoint' | 'oobi'; +} + +/** + * Derive witness/watcher/mailbox/etc. records from contact endpoints and OOBIs. + */ +export const knownComponentsFromContacts = ( + contacts: readonly ContactRecord[] +): KnownComponentRecord[] => + contacts.flatMap((contact) => { + const endpointComponents = contact.endpoints.map((endpoint) => ({ + id: `${contact.id}:endpoint:${endpoint.role}:${endpoint.eid}`, + contactId: contact.id, + alias: contact.alias, + role: endpoint.role, + eid: endpoint.eid, + scheme: endpoint.scheme, + url: endpoint.url, + source: 'endpoint' as const, + })); + + const tagComponents = contact.componentTags.map((tag) => ({ + id: `${contact.id}:oobi:${tag}`, + contactId: contact.id, + alias: contact.alias, + role: tag, + eid: contact.aid, + scheme: null, + url: contact.oobi, + source: 'oobi' as const, + })); + + return [...endpointComponents, ...tagComponents]; + }); diff --git a/src/features/dashboard/DashboardView.tsx b/src/features/dashboard/DashboardView.tsx new file mode 100644 index 00000000..736eb89d --- /dev/null +++ b/src/features/dashboard/DashboardView.tsx @@ -0,0 +1,470 @@ +import { + Box, + Divider, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from '@mui/material'; +import { Link as RouterLink, useLoaderData } from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { monoValueSx } from '../../app/consoleStyles'; +import { clickablePanelSx } from '../../app/consoleStyles'; +import { useAppSession } from '../../app/runtimeHooks'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { DashboardLoaderData } from '../../app/routeData'; +import { useAppSelector } from '../../state/hooks'; +import { + selectDashboardCounts, + selectKnownComponentsByRole, + selectRecentAppNotifications, + selectRecentChallenges, + selectRecentKeriaNotifications, + selectRecentOperations, + selectSession, +} from '../../state/selectors'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const CountTile = ({ + label, + value, + to, +}: { + label: string; + value: number; + to: string; +}) => ( + + + {label} + + + {value} + + +); + +export const DashboardView = () => { + const loaderData = useLoaderData() as DashboardLoaderData; + const runtimeSnapshot = useAppSession(); + const session = useAppSelector(selectSession); + const counts = useAppSelector(selectDashboardCounts); + const recentOperations = useAppSelector(selectRecentOperations(5)); + const recentKeriaNotifications = useAppSelector( + selectRecentKeriaNotifications(5) + ); + const recentAppNotifications = useAppSelector(selectRecentAppNotifications(5)); + const recentChallenges = useAppSelector(selectRecentChallenges(5)); + const componentGroups = Array.from( + useAppSelector(selectKnownComponentsByRole).entries() + ).sort(([left], [right]) => left.localeCompare(right)); + const connection = runtimeSnapshot.connection; + + if (loaderData.status === 'blocked') { + return ; + } + + const keriaTarget = + connection.status === 'connected' ? connection.client.url : 'Disconnected'; + + return ( + + + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} + + + + + + + + + + + + + + + + + + + + {componentGroups.length === 0 ? ( + + ) : ( + + {componentGroups.map(([role, components]) => ( + + + + {role} + + + + + {components.slice(0, 6).map( + (component) => ( + + + {component.url ?? + component.eid ?? + component.contactId} + + } + /> + + ) + )} + + + + ))} + + )} + + + + + + {recentOperations.length === 0 ? ( + + ) : ( + + {recentOperations.map((operation) => ( + + + + {operation.title} + + + + } + secondary={timestampText( + operation.startedAt + )} + /> + + ))} + + )} + + + {recentKeriaNotifications.length === 0 ? ( + + ) : ( + + {recentKeriaNotifications.map((notification) => ( + + + + {notification.route} + + + + } + secondary={timestampText( + notification.updatedAt + )} + /> + + ))} + + )} + + + {recentAppNotifications.length === 0 ? ( + + ) : ( + + {recentAppNotifications.map((notification) => ( + + + + ))} + + )} + + + {recentChallenges.length === 0 ? ( + + ) : ( + + {recentChallenges.map((challenge) => ( + + + + {challenge.role} + + + + } + secondary={timestampText( + challenge.updatedAt + )} + /> + + ))} + + )} + + + + ); +}; diff --git a/src/features/identifiers/IdentifierDetailsModal.tsx b/src/features/identifiers/IdentifierDetailsModal.tsx index 7b7b5acd..6d4138b4 100644 --- a/src/features/identifiers/IdentifierDetailsModal.tsx +++ b/src/features/identifiers/IdentifierDetailsModal.tsx @@ -6,15 +6,20 @@ import { Box, Button, Chip, + CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, + IconButton, Stack, + Tooltip, Typography, } from '@mui/material'; +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 { formatIdentifierMetadata, @@ -36,11 +41,18 @@ export interface IdentifierDetailsModalProps { identifier: IdentifierSummary | null; refreshStatus: 'idle' | 'loading' | 'success' | 'error'; refreshMessage: string | null; + oobiState: IdentifierOobiDetailState; actionRunning: boolean; onClose: () => void; onRotate: (name: string) => void; } +export interface IdentifierOobiDetailState { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; + records: GeneratedOobiRecord[]; +} + interface DetailFieldProps { label: string; value: string; @@ -198,6 +210,10 @@ const JsonCodeBlock = ({ value }: { value: string }) => ( ); +const copyValue = (value: string): void => { + void globalThis.navigator.clipboard?.writeText(value); +}; + /** * Identifier details and rotate action. * @@ -209,6 +225,7 @@ export const IdentifierDetailsModal = ({ identifier, refreshStatus, refreshMessage, + oobiState, actionRunning, onClose, onRotate, @@ -350,6 +367,23 @@ export const IdentifierDetailsModal = ({ {refreshMessage} )} + + }> + + OOBIs + {oobiState.status === 'loading' && ( + + )} + + + + + + {identifier !== null && ( }> @@ -395,3 +429,99 @@ export const IdentifierDetailsModal = ({ ); }; + +const IdentifierOobis = ({ state }: { state: IdentifierOobiDetailState }) => { + if (state.status === 'loading' || state.status === 'idle') { + return ( + + Loading identifier OOBIs... + + ); + } + + if (state.status === 'error') { + return ( + + Unable to load identifier OOBIs: {state.message} + + ); + } + + if (state.records.length === 0) { + return ( + + No OOBIs are available for this identifier. + + ); + } + + return ( + + {state.records.map((record) => ( + + + + + + {record.oobis.length} URL + {record.oobis.length === 1 ? '' : 's'} + + + {record.oobis.map((oobi) => ( + + + {oobi} + + + copyValue(oobi)} + > + + + + + ))} + + + ))} + + ); +}; diff --git a/src/features/identifiers/IdentifierTable.tsx b/src/features/identifiers/IdentifierTable.tsx index 776fc5d2..fb1c0263 100644 --- a/src/features/identifiers/IdentifierTable.tsx +++ b/src/features/identifiers/IdentifierTable.tsx @@ -16,6 +16,7 @@ import { Tooltip, Typography, } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import RotateRightIcon from '@mui/icons-material/RotateRight'; import type { IdentifierSummary } from './identifierTypes'; import { @@ -34,6 +35,13 @@ export interface IdentifierTableProps { onSelect: (identifier: IdentifierSummary) => void; onRotate: (name: string) => void; isRotateDisabled: (identifier: IdentifierSummary) => boolean; + onCopyAgentOobi: (identifier: IdentifierSummary) => void; + agentOobiCopyStatus: Record; +} + +export interface IdentifierOobiCopyStatus { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; } const monoSx = { @@ -93,6 +101,8 @@ export const IdentifierTable = ({ onSelect, onRotate, isRotateDisabled, + onCopyAgentOobi, + agentOobiCopyStatus, }: IdentifierTableProps) => { const [copiedValue, setCopiedValue] = useState(null); @@ -114,6 +124,14 @@ export const IdentifierTable = ({ onRotate(identifier.name); }; + const copyAgentOobi = ( + event: MouseEvent, + identifier: IdentifierSummary + ) => { + event.stopPropagation(); + onCopyAgentOobi(identifier); + }; + return ( @@ -180,6 +198,11 @@ export const IdentifierTable = ({ + Type KIDX PIDX + OOBI Actions @@ -262,6 +286,15 @@ export const IdentifierTable = ({ identifierIdentifierIndex(identifier) )} + + + @@ -287,3 +320,53 @@ export const IdentifierTable = ({ ); }; + +const oobiCopyTooltip = ( + status: IdentifierOobiCopyStatus | undefined +): string => { + if (status?.status === 'loading') { + return 'Fetching agent OOBI...'; + } + + if (status?.status === 'success') { + return 'Copied agent OOBI'; + } + + if (status?.status === 'error') { + return status.message ?? 'Unable to copy agent OOBI'; + } + + return 'Copy agent OOBI'; +}; + +const OobiCopyButton = ({ + identifier, + copyStatus, + onCopy, +}: { + identifier: IdentifierSummary; + copyStatus: IdentifierOobiCopyStatus | undefined; + onCopy: ( + event: MouseEvent, + identifier: IdentifierSummary + ) => void; +}) => ( + + + onCopy(event, identifier)} + > + + + + +); diff --git a/src/features/identifiers/IdentifiersView.tsx b/src/features/identifiers/IdentifiersView.tsx index 0a708a2a..9b32ac36 100644 --- a/src/features/identifiers/IdentifiersView.tsx +++ b/src/features/identifiers/IdentifiersView.tsx @@ -10,19 +10,30 @@ import type { IdentifiersLoaderData, } from '../../app/routeData'; import { IdentifierCreateDialog } from './IdentifierCreateDialog'; -import { IdentifierDetailsModal } from './IdentifierDetailsModal'; -import { IdentifierTable } from './IdentifierTable'; +import { + IdentifierDetailsModal, + type IdentifierOobiDetailState, +} from './IdentifierDetailsModal'; +import { + IdentifierTable, + type IdentifierOobiCopyStatus, +} from './IdentifierTable'; import { idleIdentifierAction, type IdentifierActionState, type IdentifierCreateDraft, type IdentifierSummary, } from './identifierTypes'; +import type { GeneratedOobiRecord } from '../../state/contacts.slice'; import { useAppSelector } from '../../state/hooks'; import { selectActiveOperations, selectIdentifiers, } from '../../state/selectors'; +import { + identifierAvailableOobiRoles, + type OobiGenerationRole, +} from '../contacts/contactHelpers'; /** * Connected identifiers feature route. @@ -47,6 +58,14 @@ export const IdentifiersView = () => { status: 'idle' | 'loading' | 'success' | 'error'; message: string | null; }>({ status: 'idle', message: null }); + const [detailOobis, setDetailOobis] = useState({ + status: 'idle', + message: null, + records: [], + }); + const [agentOobiCopyStatus, setAgentOobiCopyStatus] = useState< + Record + >({}); const actionRunning = fetcher.state !== 'idle'; const liveIdentifiers = useAppSelector(selectIdentifiers); const activeOperations = useAppSelector(selectActiveOperations); @@ -79,27 +98,56 @@ export const IdentifiersView = () => { const controller = new AbortController(); - void runtime - .getIdentifier(selectedIdentifierName, { - signal: controller.signal, - track: false, - }) - .then(() => { + void (async () => { + try { + const refreshed = await runtime.getIdentifier( + selectedIdentifierName, + { + signal: controller.signal, + track: false, + } + ); + if (controller.signal.aborted) { + return; + } + + setDetailRefresh({ status: 'success', message: null }); + + const roles = identifierAvailableOobiRoles(refreshed); + const records = await runtime.listIdentifierOobis( + refreshed.name, + roles, + { + signal: controller.signal, + track: false, + } + ); if (!controller.signal.aborted) { - setDetailRefresh({ status: 'success', message: null }); + setDetailOobis({ + status: 'success', + message: null, + records, + }); } - }) - .catch((error: unknown) => { + } catch (error: unknown) { if (controller.signal.aborted) { return; } - setDetailRefresh({ + const message = + error instanceof Error ? error.message : String(error); + setDetailRefresh((current) => + current.status === 'loading' + ? { status: 'error', message } + : current + ); + setDetailOobis({ status: 'error', - message: - error instanceof Error ? error.message : String(error), + message, + records: [], }); - }); + } + })(); return () => { controller.abort(); @@ -176,9 +224,70 @@ export const IdentifiersView = () => { }; const handleSelectIdentifier = (identifier: IdentifierSummary) => { setDetailRefresh({ status: 'loading', message: null }); + setDetailOobis({ status: 'loading', message: null, records: [] }); setSelectedIdentifierName(identifier.name); }; + const copyGeneratedOobi = async ( + identifier: IdentifierSummary, + role: OobiGenerationRole + ): Promise => { + const record = await runtime.getIdentifierOobi( + { + identifier: identifier.name, + role, + }, + { track: false } + ); + const firstOobi = record.oobis[0]; + if (firstOobi === undefined) { + throw new Error(`No ${role} OOBI returned for ${identifier.name}.`); + } + + await globalThis.navigator.clipboard?.writeText(firstOobi); + return record; + }; + + const handleCopyAgentOobi = (identifier: IdentifierSummary) => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { status: 'loading', message: null }, + })); + + void copyGeneratedOobi(identifier, 'agent') + .then(() => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { status: 'success', message: null }, + })); + globalThis.setTimeout(() => { + setAgentOobiCopyStatus((current) => + current[identifier.name]?.status === 'success' + ? { + ...current, + [identifier.name]: { + status: 'idle', + message: null, + }, + } + : current + ); + }, 1800); + }) + .catch((error: unknown) => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { + status: 'error', + message: + error instanceof Error + ? error.message + : String(error), + }, + })); + }); + }; + return ( { isRotateDisabled={(identifier) => isRotateDisabled(identifier.name) } + onCopyAgentOobi={handleCopyAgentOobi} + agentOobiCopyStatus={agentOobiCopyStatus} /> { identifier={selectedIdentifier} refreshStatus={detailRefresh.status} refreshMessage={detailRefresh.message} + oobiState={detailOobis} actionRunning={ selectedIdentifierName === null ? false @@ -282,6 +394,11 @@ export const IdentifiersView = () => { onClose={() => { setSelectedIdentifierName(null); setDetailRefresh({ status: 'idle', message: null }); + setDetailOobis({ + status: 'idle', + message: null, + records: [], + }); }} onRotate={handleRotate} /> diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index 0f5a3272..87a34bb4 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -15,6 +15,7 @@ import { PageHeader, StatusPill, } from '../../app/Console'; +import { PayloadDetails } from '../../app/PayloadDetails'; import { formatTimestamp } from '../../app/timeFormat'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { allAppNotificationsRead } from '../../state/appNotifications.slice'; @@ -52,7 +53,7 @@ export const AppNotificationsView = () => { {notifications.length === 0 ? ( { > {notification.message} + { ))} )} - {keriaNotifications.length > 0 && ( - + + {keriaNotifications.length === 0 ? ( + + ) : ( {keriaNotifications.map((notification) => ( { ))} - - )} + )} + ); }; diff --git a/src/features/operations/OperationDetailView.tsx b/src/features/operations/OperationDetailView.tsx index 0096f21e..a23a6cc6 100644 --- a/src/features/operations/OperationDetailView.tsx +++ b/src/features/operations/OperationDetailView.tsx @@ -6,6 +6,7 @@ import { StatusPill, TelemetryRow, } from '../../app/Console'; +import { PayloadDetails } from '../../app/PayloadDetails'; import { formatTimestamp } from '../../app/timeFormat'; import { useAppSelector } from '../../state/hooks'; import { selectOperationById } from '../../state/selectors'; @@ -100,6 +101,11 @@ export const OperationDetailView = () => { } /> + {operation.payloadDetails.length > 0 && ( + + + + )} + + + + + ))} + {visibleChallengeRequests.length > 0 && + visibleNotifications.length > 0 && ( + + )} + {visibleNotifications.map((notification) => ( + - - )) + onClick={() => setNotificationsAnchor(null)} + sx={{ + bgcolor: + notification.status === 'unread' + ? 'action.selected' + : 'action.hover', + color: 'text.primary', + border: 1, + borderColor: + notification.status === 'unread' + ? 'primary.main' + : 'divider', + borderRadius: 1, + mb: 0.75, + }} + > + + {formatTimestamp( + notification.createdAt + ) !== null && ( + + Created{' '} + {formatTimestamp( + notification.createdAt + )} + + )} + + {notification.message} + + + + } + /> + + ))} + )} ; /** Refresh the normalized Signify state through the connected client. */ - refreshState(options?: { signal?: AbortSignal }): Promise; + refreshState(options?: { + signal?: AbortSignal; + }): Promise; /** Load and normalize identifiers through the connected client. */ - listIdentifiers(options?: { signal?: AbortSignal }): Promise; + listIdentifiers(options?: { + signal?: AbortSignal; + }): Promise; /** Load live contact, challenge, and protocol notification facts. */ syncSessionInventory(options?: { signal?: AbortSignal }): Promise; /** Create an identifier and wait for its KERIA operation to complete. */ @@ -200,6 +238,26 @@ export interface RouteDataRuntime { input: { contactId: string; alias: string }, options?: { requestId?: string } ): BackgroundWorkflowStartResult; + /** Generate challenge words and record them in session state. */ + generateContactChallenge( + input: GenerateContactChallengeInput, + options?: { signal?: AbortSignal; requestId?: string } + ): Promise; + /** Start challenge response sending in the background. */ + startRespondToChallenge( + input: RespondToContactChallengeInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start challenge request notification sending in the background. */ + startSendChallengeRequest( + input: SendChallengeRequestInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start challenger-side verification in the background. */ + startVerifyContactChallenge( + input: VerifyContactChallengeInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; } /** @@ -237,6 +295,18 @@ const parseIdentifierCreateDraft = ( const parseOobiRole = (value: string): OobiRole | null => value === 'agent' || value === 'witness' ? value : null; +const contactIntentFromString = ( + value: string +): Exclude => + value === 'generateOobi' || + value === 'generateChallenge' || + value === 'respondChallenge' || + value === 'verifyChallenge' || + value === 'delete' || + value === 'updateAlias' + ? value + : 'resolve'; + /** * Loader for `/dashboard`. */ @@ -287,6 +357,32 @@ export const loadContacts = async ( } }; +/** + * Loader for `/notifications`. + */ +export const loadNotifications = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + const [identifiers] = await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready', identifiers }; + } catch (error) { + return { + status: 'error', + identifiers: [], + message: `Unable to refresh notifications: ${toRouteError(error).message}`, + }; + } +}; + /** * Loader for `/identifiers`. * @@ -339,7 +435,9 @@ export const loadClient = async ( const summary = (await runtime.refreshState({ signal: request?.signal })) ?? runtime.getState(); - return summary === null ? { status: 'blocked' } : { status: 'ready', summary }; + return summary === null + ? { status: 'blocked' } + : { status: 'ready', summary }; }; /** @@ -533,12 +631,7 @@ export const contactsAction = async ( if (runtime.getClient() === null) { return { - intent: - intent === 'generateOobi' || - intent === 'delete' || - intent === 'updateAlias' - ? intent - : 'resolve', + intent: contactIntentFromString(intent), ok: false, message: 'Connect to KERIA before changing contacts.', requestId, @@ -619,6 +712,223 @@ export const contactsAction = async ( }; } + if (intent === 'generateChallenge') { + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + if (contactId.length === 0 || localIdentifier.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and local identifier are required.', + requestId, + }; + } + + const generated = await runtime.generateContactChallenge( + { + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + }, + { signal: request.signal } + ); + runtime.startSendChallengeRequest( + { + challengeId: generated.challengeId, + counterpartyAid: generated.counterpartyAid, + counterpartyAlias: generated.counterpartyAlias, + localIdentifier: generated.localIdentifier, + localAid: generated.localAid, + wordsHash: generated.wordsHash, + strength: generated.strength, + }, + { + requestId: requestId + ? `${requestId}:challenge-request` + : undefined, + } + ); + const started = runtime.startVerifyContactChallenge( + { + challengeId: generated.challengeId, + counterpartyAid: generated.counterpartyAid, + counterpartyAlias: generated.counterpartyAlias, + localIdentifier: generated.localIdentifier, + localAid: generated.localAid, + words: generated.words, + wordsHash: generated.wordsHash, + generatedAt: generated.generatedAt, + }, + { 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: + 'Generated challenge, sent request, and started verification', + requestId: started.requestId, + operationRoute: started.operationRoute, + challenge: generated, + }; + } + + if (intent === 'respondChallenge') { + const notificationId = formString( + formData, + 'notificationId' + ).trim(); + const challengeId = formString(formData, 'challengeId').trim(); + const wordsHash = formString(formData, 'wordsHash').trim(); + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + const words = parseChallengeWords(formString(formData, 'words')); + const wordError = validateChallengeWords(words); + if (contactId.length === 0 || localIdentifier.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and local identifier are required.', + requestId, + }; + } + + if (wordError !== null) { + return { + intent, + ok: false, + message: wordError, + requestId, + }; + } + + const started = runtime.startRespondToChallenge( + { + challengeId: + challengeId.length > 0 + ? challengeId + : requestId || undefined, + notificationId: + notificationId.length > 0 ? notificationId : undefined, + wordsHash: wordsHash.length > 0 ? wordsHash : null, + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + words, + }, + { 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: `Sending challenge response to ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'verifyChallenge') { + const challengeId = formString(formData, 'challengeId').trim(); + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + const words = parseChallengeWords(formString(formData, 'words')); + const wordsHash = formString(formData, 'wordsHash').trim(); + const generatedAt = formString(formData, 'generatedAt').trim(); + const wordError = validateChallengeWords(words); + if ( + challengeId.length === 0 || + contactId.length === 0 || + localIdentifier.length === 0 + ) { + return { + intent, + ok: false, + message: + 'Challenge id, contact id, and local identifier are required.', + requestId, + }; + } + + if (wordError !== null) { + return { + intent, + ok: false, + message: wordError, + requestId, + }; + } + + const started = runtime.startVerifyContactChallenge( + { + challengeId, + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + words, + wordsHash: wordsHash.length > 0 ? wordsHash : null, + generatedAt: generatedAt.length > 0 ? generatedAt : null, + }, + { 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: `Waiting for challenge response from ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + if (intent === 'delete') { const contactId = formString(formData, 'contactId').trim(); if (contactId.length === 0) { @@ -688,12 +998,7 @@ export const contactsAction = async ( } } catch (error) { return { - intent: - intent === 'generateOobi' || - intent === 'delete' || - intent === 'updateAlias' - ? intent - : 'resolve', + intent: contactIntentFromString(intent), ok: false, message: toRouteError(error).message, requestId, @@ -707,3 +1012,11 @@ export const contactsAction = async ( requestId, }; }; + +/** + * Notification actions share the contact challenge response path. + */ +export const notificationsAction = async ( + runtime: RouteDataRuntime, + request: Request +): Promise => contactsAction(runtime, request); diff --git a/src/app/router.tsx b/src/app/router.tsx index 34b67cdc..a6a6edf8 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -10,6 +10,7 @@ import { CredentialsView } from '../features/credentials/CredentialsView'; import { DashboardView } from '../features/dashboard/DashboardView'; import { IdentifiersView } from '../features/identifiers/IdentifiersView'; import { AppNotificationsView } from '../features/notifications/AppNotificationsView'; +import { NotificationDetailView } from '../features/notifications/NotificationDetailView'; import { OperationDetailView } from '../features/operations/OperationDetailView'; import { OperationsView } from '../features/operations/OperationsView'; import type { AppRuntime } from './runtime'; @@ -22,6 +23,8 @@ import { loadClient, loadCredentials, loadIdentifiers, + loadNotifications, + notificationsAction, rootAction, } from './routeData'; import { RootLayout } from './RootLayout'; @@ -260,7 +263,9 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ handle: APP_FEATURE_ROUTES[4].handle, loader: ({ request }) => loadClient(runtime, request), element: , - errorElement: , + errorElement: ( + + ), }, { id: 'operations', @@ -283,11 +288,23 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ id: 'appNotifications', path: 'notifications', handle: APP_FEATURE_ROUTES[6].handle, + loader: ({ request }) => loadNotifications(runtime, request), + action: ({ request }) => notificationsAction(runtime, request), element: , errorElement: ( ), }, + { + id: 'notificationDetail', + path: 'notifications/:notificationId', + loader: ({ request }) => loadNotifications(runtime, request), + action: ({ request }) => notificationsAction(runtime, request), + element: , + errorElement: ( + + ), + }, { path: '*', loader: () => redirect(DEFAULT_APP_PATH), diff --git a/src/app/runtime.ts b/src/app/runtime.ts index ba4f1b55..253b8016 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -60,6 +60,18 @@ import { type SessionInventorySnapshot, type UpdateContactAliasInput, } from '../workflows/contacts.op'; +import { + challengeResultRoute, + generateContactChallengeOp, + respondToContactChallengeOp, + sendChallengeRequestOp, + verifyContactChallengeOp, + type GeneratedContactChallengeResult, + type GenerateContactChallengeInput, + type RespondToContactChallengeInput, + type SendChallengeRequestInput, + type VerifyContactChallengeInput, +} from '../workflows/challenges.op'; import { bootOrConnectOp, getSignifyStateOp, @@ -225,9 +237,7 @@ 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; + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; const stringArray = (value: unknown): string[] => Array.isArray(value) @@ -796,6 +806,99 @@ export class AppRuntime { }, }); + generateContactChallenge = async ( + input: GenerateContactChallengeInput, + options: Pick = {} + ): Promise => + this.runWorkflow(() => generateContactChallengeOp(input), { + ...options, + kind: 'generateChallenge', + track: false, + }); + + startRespondToChallenge = ( + input: RespondToContactChallengeInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => respondToContactChallengeOp(input), { + requestId: options.requestId, + label: `Sending challenge response to ${input.counterpartyAid}`, + title: 'Send challenge response', + description: + 'Signs the challenge words with the selected identifier and sends the response to the contact.', + kind: 'respondChallenge', + resourceKeys: [ + `challenge:respond:${input.counterpartyAid}:${input.localIdentifier}:${input.challengeId ?? 'current'}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge response sent', + message: 'The signed challenge response was sent.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge response failed', + message: 'The challenge response could not be sent.', + severity: 'error', + }, + }); + + startSendChallengeRequest = ( + input: SendChallengeRequestInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => sendChallengeRequestOp(input), { + requestId: options.requestId, + label: `Sending challenge request to ${input.counterpartyAid}`, + title: 'Send challenge request', + description: + 'Sends a challenge request notification without embedding the challenge words.', + kind: 'sendChallengeRequest', + resourceKeys: [ + `challenge:request:${input.counterpartyAid}:${input.localIdentifier}:${input.challengeId}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge request sent', + message: + 'The contact was notified that a challenge response is requested.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge request failed', + message: + 'The challenge words remain available, but the notification could not be sent.', + severity: 'error', + }, + }); + + startVerifyContactChallenge = ( + input: VerifyContactChallengeInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => verifyContactChallengeOp(input), { + requestId: options.requestId, + label: `Waiting for challenge response from ${input.counterpartyAid}`, + title: 'Verify challenge response', + description: + 'Waits for a matching challenge response, accepts the response SAID, and refreshes contact inventory.', + kind: 'verifyChallenge', + resourceKeys: [ + `challenge:verify:${input.counterpartyAid}:${input.challengeId}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge verified', + message: 'The contact challenge response was accepted.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge verification failed', + message: 'The challenge response was not verified.', + severity: 'error', + }, + }); + /** * Start a non-blocking workflow and return accepted/conflict metadata. * diff --git a/src/features/contacts/ContactDetailView.tsx b/src/features/contacts/ContactDetailView.tsx index 440925a3..e53d78ad 100644 --- a/src/features/contacts/ContactDetailView.tsx +++ b/src/features/contacts/ContactDetailView.tsx @@ -3,7 +3,11 @@ import { Box, Button, Divider, + FormControl, IconButton, + InputLabel, + MenuItem, + Select, Stack, TextField, Tooltip, @@ -13,6 +17,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DeleteIcon from '@mui/icons-material/Delete'; import SaveIcon from '@mui/icons-material/Save'; +import SendIcon from '@mui/icons-material/Send'; import ShieldIcon from '@mui/icons-material/Shield'; import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; import { @@ -42,6 +47,7 @@ import { useAppSelector } from '../../state/hooks'; import { selectChallengesForContact, selectContactById, + selectIdentifiers, } from '../../state/selectors'; import { contactChallengeStatus, @@ -49,6 +55,7 @@ import { contactOobiRoleSummary, } from './contactHelpers'; import type { ContactOobiGroup } from './contactHelpers'; +import { parseChallengeWords, validateChallengeWords } from './challengeWords'; const timestampText = (value: string | null): string => value === null ? 'Not available' : (formatTimestamp(value) ?? value); @@ -73,13 +80,31 @@ export const ContactDetailView = () => { const { contactId = '' } = useParams(); const navigate = useNavigate(); const fetcher = useFetcher(); + const challengeFetcher = useFetcher(); + const responseFetcher = useFetcher(); const contact = useAppSelector(selectContactById(contactId)); const challenges = useAppSelector(selectChallengesForContact(contactId)); + const identifiers = useAppSelector(selectIdentifiers); const [aliasDraft, setAliasDraft] = useState({ contactId, value: contact?.alias ?? '', }); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [responseWordsDraft, setResponseWordsDraft] = useState(''); const actionRunning = fetcher.state !== 'idle'; + const challengeRunning = challengeFetcher.state !== 'idle'; + const responseRunning = responseFetcher.state !== 'idle'; + const activeIdentifier = selectedIdentifier || identifiers[0]?.name || ''; + const activeIdentifierSummary = + identifiers.find( + (identifier) => identifier.name === activeIdentifier + ) ?? null; + const responseWords = parseChallengeWords(responseWordsDraft); + const responseWordsError = + responseWordsDraft.trim().length === 0 + ? null + : validateChallengeWords(responseWords); + const responseWordsInvalid = validateChallengeWords(responseWords) !== null; useEffect(() => { if (fetcher.data?.ok === true && fetcher.data.intent === 'delete') { @@ -116,9 +141,43 @@ export const ContactDetailView = () => { fetcher.submit(formData, { method: 'post' }); }; + const submitGenerateChallenge = () => { + if (contact === null || activeIdentifierSummary === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'generateChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.aid ?? contact.id); + formData.set('contactAlias', contact.alias); + formData.set('localIdentifier', activeIdentifierSummary.name); + formData.set('localAid', activeIdentifierSummary.prefix); + challengeFetcher.submit(formData, { method: 'post' }); + }; + + const submitRespondChallenge = () => { + if (contact === null || activeIdentifierSummary === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'respondChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.aid ?? contact.id); + formData.set('contactAlias', contact.alias); + formData.set('localIdentifier', activeIdentifierSummary.name); + formData.set('localAid', activeIdentifierSummary.prefix); + formData.set('words', responseWordsDraft); + responseFetcher.submit(formData, { method: 'post' }); + }; + if (contact === null) { return ( - + { const roleSummary = contactOobiRoleSummary(contact); const oobiGroups = contactOobiGroups(contact); const challengeStatus = contactChallengeStatus(contact); - const Shield = challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; + const Shield = + challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; const shieldColor = challengeStatus.status === 'verified' ? 'success.main' @@ -153,6 +213,26 @@ export const ContactDetailView = () => { const aid = contact.aid ?? contact.id; const draftAlias = aliasDraft.contactId === contact.id ? aliasDraft.value : contact.alias; + const generatedChallengeCandidate = + challengeFetcher.data?.ok === true && + challengeFetcher.data.intent === 'generateChallenge' + ? challengeFetcher.data.challenge + : null; + const generatedChallengeVerified = + generatedChallengeCandidate !== null && + challenges.some( + (challenge) => + (challenge.id === generatedChallengeCandidate.challengeId || + challenge.wordsHash === + generatedChallengeCandidate.wordsHash) && + (challenge.authenticated || challenge.status === 'verified') + ); + const generatedChallenge = generatedChallengeVerified + ? null + : generatedChallengeCandidate; + const generatedChallengePhrase = + generatedChallenge === null ? null : generatedChallenge.words.join(' '); + const canUseChallenge = activeIdentifierSummary !== null && aid.length > 0; return ( @@ -182,28 +262,17 @@ export const ContactDetailView = () => { }} > {' '} - {loaderData.message} + + {loaderData.message} + )} - {fetcher.data !== undefined && ( - - {' '} - {fetcher.data.message} - + {fetcher.data !== undefined && } + {challengeFetcher.data !== undefined && ( + + )} + {responseFetcher.data !== undefined && ( + )} { )} + + + + + {challengeStatus.label} + + + + } + > + + + + Identifier + + + + + + + + Generate challenge + + + {generatedChallengePhrase !== null && ( + + )} + {generatedChallenge !== null && ( + + Waiting operation{' '} + {generatedChallenge.challengeId} + + )} + + + + + + Respond to challenge + + { + setResponseWordsDraft( + event.target.value + ); + }} + minRows={3} + multiline + fullWidth + error={responseWordsError !== null} + helperText={ + responseWordsError ?? + `${responseWords.length} words` + } + data-testid="challenge-response-input" + /> + + + + + + {contact.endpoints.length === 0 ? ( { mb: 0.75, }} > - + {endpoint.scheme} - + { ); }; -const CopyBlock = ({ label, value }: { label: string; value: string }) => ( +const ActionNotice = ({ data }: { data: ContactActionData }) => ( + + {' '} + {data.message} + +); + +const CopyBlock = ({ + label, + value, + valueTestId, +}: { + label: string; + value: string; + valueTestId?: string; +}) => ( ( {label} - + {value} @@ -453,7 +728,11 @@ const FullOobiBlock = ({ groups }: { groups: readonly ContactOobiGroup[] }) => ( Full OOBI @@ -520,10 +799,7 @@ const ChallengeBlock = ({ challenge }: { challenge: ChallengeRecord }) => ( - + + value + .trim() + .split(/\s+/u) + .map((word) => word.trim().toLowerCase()) + .filter((word) => word.length > 0); + +/** User-facing validation for challenge word lists. */ +export const validateChallengeWords = ( + words: readonly string[] +): string | null => { + if (words.length === 0) { + return 'Challenge words are required.'; + } + + if (words.length !== 12 && words.length !== 24) { + return 'Challenge must contain 12 or 24 words.'; + } + + return null; +}; + +/** Validate and return normalized words, throwing for workflow boundaries. */ +export const requireChallengeWords = (value: string): string[] => { + const words = parseChallengeWords(value); + const error = validateChallengeWords(words); + if (error !== null) { + throw new Error(error); + } + + return words; +}; + +/** + * Stable non-secret fingerprint for challenge words. + * + * This is used only for local matching and operation conflict keys so raw + * challenge phrases do not leak into persisted operation records. + */ +export const challengeWordsFingerprint = (words: readonly string[]): string => { + let hash = 0x811c9dc5; + const text = words.join(' '); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +/** Default user-facing challenge strength: 12 mnemonic words. */ +export const defaultChallengeStrength: ChallengeStrength = 128; diff --git a/src/features/contacts/contactHelpers.ts b/src/features/contacts/contactHelpers.ts index 48aac610..f16008f7 100644 --- a/src/features/contacts/contactHelpers.ts +++ b/src/features/contacts/contactHelpers.ts @@ -7,6 +7,7 @@ import type { } from '../../state/contacts.slice'; import type { ChallengeRecord } from '../../state/challenges.slice'; import type { IdentifierSummary } from '../identifiers/identifierTypes'; +import { challengeWordsFingerprint } from './challengeWords'; export const CONTACT_ENDPOINT_ROLES = [ 'agent', @@ -68,7 +69,9 @@ const roleLabels: Record = { mailbox: 'Mailbox', }; -const normalizeEndpointRole = (role: string | null): ContactEndpointRole | null => +const normalizeEndpointRole = ( + role: string | null +): ContactEndpointRole | null => role !== null && CONTACT_ENDPOINT_ROLE_SET.has(role) ? (role as ContactEndpointRole) : null; @@ -241,7 +244,9 @@ export const pendingContactIdForOobi = ( /** * Extract likely component tags from KERIA OOBI URLs. */ -export const componentTagsFromOobi = (oobi: string | null | undefined): string[] => { +export const componentTagsFromOobi = ( + oobi: string | null | undefined +): string[] => { if (oobi === null || oobi === undefined || oobi.trim().length === 0) { return []; } @@ -549,16 +554,22 @@ export const challengeRecordsFromKeriaContacts = ( (contact.challenges ?? []).map((challenge, index) => { const said = stringValue(challenge.said); const authenticated = challenge.authenticated === true; + const updated = stringValue(challenge.dt) ?? updatedAt; return { id: `${contact.id}:${said ?? index.toString()}`, + source: 'keria', direction: 'received', role: stringValue(contact.alias) ?? contact.id, counterpartyAid: contact.id, words: challenge.words, + wordsHash: challengeWordsFingerprint(challenge.words), + responseSaid: said, authenticated, status: authenticated ? 'verified' : 'responded', result: said, - updatedAt: stringValue(challenge.dt) ?? updatedAt, + error: null, + verifiedAt: authenticated ? updated : null, + updatedAt: updated, }; }) ); diff --git a/src/features/identifiers/IdentifierTable.tsx b/src/features/identifiers/IdentifierTable.tsx index fb1c0263..27171ba9 100644 --- a/src/features/identifiers/IdentifierTable.tsx +++ b/src/features/identifiers/IdentifierTable.tsx @@ -49,6 +49,37 @@ const monoSx = { letterSpacing: 0, }; +const tableHeadBg = 'rgba(39, 215, 255, 0.06)'; + +const compactOnlyActionSx = { + display: { sm: 'inline-flex', lg: 'none' }, +} as const; + +const mdUpCellSx = { + display: { sm: 'none', md: 'table-cell' }, +} as const; + +const lgUpCellSx = { + display: { sm: 'none', lg: 'table-cell' }, +} as const; + +const stickyActionCellSx = { + position: 'sticky', + right: 0, + zIndex: 2, + width: { sm: 104, lg: 72 }, + minWidth: { sm: 104, lg: 72 }, + bgcolor: 'background.paper', + borderLeft: 1, + borderLeftColor: 'divider', +} as const; + +const stickyActionHeadCellSx = { + ...stickyActionCellSx, + zIndex: 4, + bgcolor: tableHeadBg, +} as const; + interface CopyableMonoValueProps { value: string; label: string; @@ -77,10 +108,15 @@ const CopyableMonoValue = ({ bgcolor: 'transparent', color: copied ? 'success.main' : 'primary.main', cursor: 'copy', + display: 'inline-block', fontSize: 'inherit', lineHeight: 'inherit', textAlign: 'left', maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'clip', + verticalAlign: 'bottom', + whiteSpace: 'nowrap', ...monoSx, }} aria-label={`Copy ${label} ${value}`} @@ -197,7 +233,14 @@ export const IdentifierTable = ({ - + - +
- Name - AID - Type - KIDX - PIDX - OOBI - Actions + + Name + + + AID + + + Type + + + KIDX + + + PIDX + + + OOBI + + + Actions + {identifiers.map((identifier) => ( onSelect(identifier)} > - - {identifier.name} + + + {identifier.name} + - + - + {identifierType(identifier)} - + {formatIdentifierMetadata( identifierKeyIndex(identifier) )} - + {formatIdentifierMetadata( identifierIdentifierIndex(identifier) )} - + - - - - - rotate(event, identifier) + + + + - - - - + onCopy={copyAgentOobi} + size="small" + /> + + + + + rotate(event, identifier) + } + > + + + + + ))} @@ -343,6 +461,7 @@ const OobiCopyButton = ({ identifier, copyStatus, onCopy, + size = 'medium', }: { identifier: IdentifierSummary; copyStatus: IdentifierOobiCopyStatus | undefined; @@ -350,10 +469,12 @@ const OobiCopyButton = ({ event: MouseEvent, identifier: IdentifierSummary ) => void; + size?: 'small' | 'medium'; }) => ( onCopy(event, identifier)} > - + diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index 87a34bb4..dc8cf231 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -4,11 +4,12 @@ import { Link, List, ListItem, + ListItemButton, ListItemText, Stack, Typography, } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, useLoaderData } from 'react-router-dom'; import { ConsolePanel, EmptyState, @@ -17,6 +18,8 @@ import { } from '../../app/Console'; import { PayloadDetails } from '../../app/PayloadDetails'; import { formatTimestamp } from '../../app/timeFormat'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import type { NotificationsLoaderData } from '../../app/routeData'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { allAppNotificationsRead } from '../../state/appNotifications.slice'; import { @@ -27,6 +30,7 @@ import { const APP_NOTIFICATION_READ_DELAY_MS = 1250; export const AppNotificationsView = () => { + const loaderData = useLoaderData() as NotificationsLoaderData; const dispatch = useAppDispatch(); const notifications = useAppSelector(selectAppNotifications); const keriaNotifications = useAppSelector(selectKeriaNotifications); @@ -48,6 +52,10 @@ export const AppNotificationsView = () => { }; }, [dispatch, unreadCount]); + if (loaderData.status === 'blocked') { + return ; + } + return ( { title="Notifications" summary="App operation notices and KERIA protocol inbox items for the connected session." /> + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} {notifications.length === 0 ? ( { {notification.message} { ) : ( {keriaNotifications.map((notification) => ( - { {notification.message} )} + {notification.challengeRequest !== + null && + notification.challengeRequest !== + undefined && ( + + From{' '} + { + notification + .challengeRequest + .senderAlias + } + + )} } /> - + ))} )} diff --git a/src/features/notifications/ChallengeRequestResponseForm.tsx b/src/features/notifications/ChallengeRequestResponseForm.tsx new file mode 100644 index 00000000..6285114e --- /dev/null +++ b/src/features/notifications/ChallengeRequestResponseForm.tsx @@ -0,0 +1,204 @@ +import { useMemo, useState } from 'react'; +import { + Box, + Button, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import { useFetcher } from 'react-router-dom'; +import type { ContactActionData } from '../../app/routeData'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; +import type { ChallengeRequestNotification } from '../../state/notifications.slice'; +import { + parseChallengeWords, + validateChallengeWords, +} from '../contacts/challengeWords'; +import { defaultChallengeResponseIdentifierName } from './challengeRequestFormHelpers'; + +const createRequestId = (): string => + globalThis.crypto?.randomUUID?.() ?? + `challenge-response-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +export interface ChallengeRequestResponseFormProps { + request: ChallengeRequestNotification; + identifiers: readonly IdentifierSummary[]; + action?: string; + dense?: boolean; +} + +export const ChallengeRequestResponseForm = ({ + request, + identifiers, + action = '/notifications', + dense = false, +}: ChallengeRequestResponseFormProps) => { + const fetcher = useFetcher(); + const [requestId] = useState(createRequestId); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [wordsDraft, setWordsDraft] = useState(''); + const defaultIdentifier = defaultChallengeResponseIdentifierName( + { recipientAid: request.recipientAid }, + identifiers + ); + const activeIdentifier = selectedIdentifier || defaultIdentifier; + const activeIdentifierSummary = + identifiers.find( + (identifier) => identifier.name === activeIdentifier + ) ?? null; + const words = useMemo(() => parseChallengeWords(wordsDraft), [wordsDraft]); + const wordsError = + wordsDraft.trim().length === 0 ? null : validateChallengeWords(words); + const wordsInvalid = validateChallengeWords(words) !== null; + const running = fetcher.state !== 'idle'; + const actionable = request.status === 'actionable'; + const disabled = + !actionable || + running || + activeIdentifierSummary === null || + wordsInvalid; + + return ( + + + + + + + + + + + + + + Identifier + + + + + { + setWordsDraft(event.target.value); + }} + minRows={dense ? 2 : 6} + multiline + fullWidth + error={wordsError !== null} + helperText={wordsError ?? `${words.length} words`} + disabled={!actionable || running} + data-testid="challenge-notification-response-input" + /> + {dense ? ( + + + + + + + + ) : ( + + )} + + {fetcher.data !== undefined && ( + + {fetcher.data.message} + + )} + + + ); +}; diff --git a/src/features/notifications/NotificationDetailView.tsx b/src/features/notifications/NotificationDetailView.tsx new file mode 100644 index 00000000..b1cff240 --- /dev/null +++ b/src/features/notifications/NotificationDetailView.tsx @@ -0,0 +1,209 @@ +import { Box, Button, Divider, Stack, Typography } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { Link as RouterLink, useLoaderData, useParams } from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { NotificationsLoaderData } from '../../app/routeData'; +import { useAppSelector } from '../../state/hooks'; +import { + selectChallengeRequestNotificationById, + selectIdentifiers, + selectKeriaNotificationById, +} from '../../state/selectors'; +import { ChallengeRequestResponseForm } from './ChallengeRequestResponseForm'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +export const NotificationDetailView = () => { + const loaderData = useLoaderData() as NotificationsLoaderData; + const { notificationId = '' } = useParams(); + const notification = useAppSelector( + selectKeriaNotificationById(notificationId) + ); + const challengeRequest = useAppSelector( + selectChallengeRequestNotificationById(notificationId) + ); + const identifiers = useAppSelector(selectIdentifiers); + + if (loaderData.status === 'blocked') { + return ; + } + + if (notification === null) { + return ( + + } + > + Back to notifications + + } + /> + + + ); + } + + return ( + + } + > + Back to notifications + + } + /> + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} + {challengeRequest !== null ? ( + + } + > + + + + + + + + + + + {challengeRequest.status === 'senderUnknown' ? ( + + ) : ( + + )} + + + ) : ( + + + + + + + + {notification.message !== null && ( + + )} + + + )} + + ); +}; diff --git a/src/features/notifications/challengeRequestFormHelpers.ts b/src/features/notifications/challengeRequestFormHelpers.ts new file mode 100644 index 00000000..4106c065 --- /dev/null +++ b/src/features/notifications/challengeRequestFormHelpers.ts @@ -0,0 +1,18 @@ +import type { ChallengeRequestNotification } from '../../state/notifications.slice'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; + +export const defaultChallengeResponseIdentifierName = ( + request: Pick, + identifiers: readonly IdentifierSummary[] +): string => { + if (request.recipientAid !== null) { + const recipientIdentifier = identifiers.find( + (identifier) => identifier.prefix === request.recipientAid + ); + if (recipientIdentifier !== undefined) { + return recipientIdentifier.name; + } + } + + return identifiers[0]?.name ?? ''; +}; diff --git a/src/services/challenges.service.ts b/src/services/challenges.service.ts new file mode 100644 index 00000000..4ebb4786 --- /dev/null +++ b/src/services/challenges.service.ts @@ -0,0 +1,240 @@ +import { + Serder, + type Operation as KeriaOperation, + type SignifyClient, +} from 'signify-ts'; +import type { Operation as EffectionOperation } from 'effection'; +import { callPromise } from '../effects/promise'; +import { + defaultChallengeStrength, + validateChallengeWords, + type ChallengeStrength, +} from '../features/contacts/challengeWords'; +import type { OperationLogger } from '../signify/client'; +import { waitOperationService } from './signify.service'; + +export const CHALLENGE_TOPIC = 'challenge'; +export const CHALLENGE_REQUEST_ROUTE = '/challenge/request'; + +export interface VerifyChallengeResult { + operationName: string; + responseSaid: string; +} + +export interface SendChallengeRequestResult { + challengeId: string; + recipientAid: string; + exnSaid: string | null; + sentAt: 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 requireValidWords = (words: readonly string[]): string[] => { + const normalized = words.map((word) => word.trim().toLowerCase()); + const error = validateChallengeWords(normalized); + if (error !== null) { + throw new Error(error); + } + + return normalized; +}; + +const requireNonEmpty = (value: string, label: string): string => { + const normalized = value.trim(); + if (normalized.length === 0) { + throw new Error(`${label} is required.`); + } + + return normalized; +}; + +const exchangeSaid = (exchange: unknown): string | null => { + if (!isRecord(exchange)) { + return null; + } + + return stringValue(exchange.d); +}; + +/** + * Extract the challenge response exchange SAID from a completed KERIA + * challenge operation. + */ +export const responseSaidFromChallengeOperation = ( + operation: unknown +): string => { + if (!isRecord(operation) || !isRecord(operation.response)) { + throw new Error('Challenge operation completed without a response.'); + } + + const exn = operation.response.exn; + if (!isRecord(exn)) { + throw new Error('Challenge operation response did not include an EXN.'); + } + + try { + const serder = new Serder( + exn as ConstructorParameters[0] + ); + const said = stringValue(serder.said); + if (said !== null) { + return said; + } + } catch { + const said = stringValue(exn.d); + if (said !== null) { + return said; + } + } + + const said = stringValue(exn.d); + if (said === null) { + throw new Error('Challenge response EXN did not include a SAID.'); + } + + return said; +}; + +/** + * Generate a mnemonic challenge phrase through KERIA. + */ +export function* generateChallengeService({ + client, + strength = defaultChallengeStrength, +}: { + client: SignifyClient; + strength?: ChallengeStrength; +}): EffectionOperation { + const challenge = yield* callPromise(() => + client.challenges().generate(strength) + ); + const words = Array.isArray(challenge.words) ? challenge.words : []; + return requireValidWords(words); +} + +/** + * Send a signed challenge response from one local identifier to a contact AID. + */ +export function* respondToChallengeService({ + client, + localIdentifier, + recipientAid, + words, +}: { + client: SignifyClient; + localIdentifier: string; + recipientAid: string; + words: readonly string[]; +}): EffectionOperation { + const normalizedWords = requireValidWords(words); + yield* callPromise(() => + client + .challenges() + .respond(localIdentifier, recipientAid, normalizedWords) + ); +} + +/** + * Send a lightweight responder-facing challenge request notification. + * + * This deliberately sends only challenge metadata. The challenge words remain + * out-of-band and are never embedded in the request EXN. + */ +export function* sendChallengeRequestService({ + client, + localIdentifier, + recipientAid, + challengeId, + wordsHash, + strength, +}: { + client: SignifyClient; + localIdentifier: string; + recipientAid: string; + challengeId: string; + wordsHash: string; + strength: ChallengeStrength; +}): EffectionOperation { + const name = requireNonEmpty(localIdentifier, 'Local identifier'); + const recipient = requireNonEmpty(recipientAid, 'Recipient AID'); + const normalizedChallengeId = requireNonEmpty(challengeId, 'Challenge id'); + const normalizedWordsHash = requireNonEmpty(wordsHash, 'Challenge hash'); + const hab = yield* callPromise(() => client.identifiers().get(name)); + const exchange = yield* callPromise(() => + client.exchanges().send( + name, + CHALLENGE_TOPIC, + hab, + CHALLENGE_REQUEST_ROUTE, + { + challengeId: normalizedChallengeId, + wordsHash: normalizedWordsHash, + strength, + }, + {}, + [recipient] + ) + ); + + return { + challengeId: normalizedChallengeId, + recipientAid: recipient, + exnSaid: exchangeSaid(exchange), + sentAt: new Date().toISOString(), + }; +} + +/** + * Wait for a matching challenge response, accept its EXN SAID, and return the + * accepted response identifier. + */ +export function* verifyChallengeResponseService({ + client, + sourceAid, + words, + timeoutMs, + pollMs, + logger, +}: { + client: SignifyClient; + sourceAid: string; + words: readonly string[]; + timeoutMs: number; + pollMs: number; + logger?: OperationLogger; +}): EffectionOperation { + const normalizedWords = requireValidWords(words); + const operation = (yield* callPromise(() => + client.challenges().verify(sourceAid, normalizedWords) + )) as KeriaOperation; + + const completed = yield* waitOperationService({ + client, + operation, + label: `verifying challenge response from ${sourceAid}`, + timeoutMs, + minSleepMs: Math.max(250, Math.min(1000, pollMs)), + maxSleepMs: pollMs, + logger, + }); + const responseSaid = responseSaidFromChallengeOperation(completed); + const response = yield* callPromise(() => + client.challenges().responded(sourceAid, responseSaid) + ); + + if (!response.ok) { + throw new Error( + `KERIA rejected challenge acceptance: ${response.status} ${response.statusText}` + ); + } + + return { + operationName: operation.name, + responseSaid, + }; +} diff --git a/src/services/notifications.service.ts b/src/services/notifications.service.ts index 233a1be4..2669473e 100644 --- a/src/services/notifications.service.ts +++ b/src/services/notifications.service.ts @@ -1,30 +1,71 @@ import type { Operation as EffectionOperation } from 'effection'; import type { SignifyClient } from 'signify-ts'; import { callPromise } from '../effects/promise'; -import type { NotificationRecord } from '../state/notifications.slice'; +import { CHALLENGE_REQUEST_ROUTE } from './challenges.service'; +import type { ContactRecord } from '../state/contacts.slice'; +import type { + ChallengeRequestNotification, + ChallengeRequestNotificationStatus, + NotificationRecord, +} from '../state/notifications.slice'; export interface NotificationInventorySnapshot { notifications: NotificationRecord[]; loadedAt: string; + unknownChallengeSenders: UnknownChallengeSenderNotice[]; } +export interface UnknownChallengeSenderNotice { + notificationId: string; + exnSaid: string; + senderAid: string; + createdAt: string; +} + +export const SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX = 'challenge-request:'; + +export const syntheticChallengeNotificationId = (exnSaid: string): string => + `${SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX}${exnSaid}`; + +export const isSyntheticChallengeNotificationId = (id: string): boolean => + id.startsWith(SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX); + 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; + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + +const numberValue = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const notificationItemsFromResponse = (raw: unknown): unknown[] => { + if (Array.isArray(raw)) { + return raw; + } + + if (isRecord(raw) && Array.isArray(raw.notes)) { + return raw.notes; + } -const notificationRecordsFromResponse = ( + return []; +}; + +export const notificationRecordsFromResponse = ( raw: unknown, loadedAt: string ): NotificationRecord[] => { - if (!Array.isArray(raw)) { - return []; - } - - return raw.flatMap((item) => { + return notificationItemsFromResponse(raw).flatMap((item) => { if (!isRecord(item)) { return []; } @@ -38,6 +79,7 @@ const notificationRecordsFromResponse = ( const route = stringValue(attrs.r) ?? 'unknown'; const dt = stringValue(item.dt); const read = item.r === true; + const anchorSaid = stringValue(attrs.d) ?? stringValue(item.d); return [ { @@ -45,28 +87,550 @@ const notificationRecordsFromResponse = ( dt, read, route, - anchorSaid: stringValue(attrs.d), + anchorSaid, status: read ? 'processed' : 'unread', message: stringValue(attrs.m), + challengeRequest: null, updatedAt: dt ?? loadedAt, }, ]; }); }; +const contactForAid = ( + contacts: readonly ContactRecord[], + aid: string +): ContactRecord | null => + contacts.find((contact) => contact.aid === aid || contact.id === aid) ?? + null; + +const aidSet = (aids: readonly string[]): ReadonlySet => + new Set(aids.map((aid) => aid.trim()).filter((aid) => aid.length > 0)); + +const exchangeExn = (exchange: unknown): Record => { + if (!isRecord(exchange) || !isRecord(exchange.exn)) { + throw new Error( + 'Challenge request notification did not include an EXN.' + ); + } + + return exchange.exn; +}; + +const exchangeRoute = (exchange: unknown): string | null => + stringValue(exchangeExn(exchange).r); + +const exchangeSaid = (exchange: unknown): string | null => + stringValue(exchangeExn(exchange).d); + +const exchangeRecipientAid = (exchange: unknown): string | null => { + const exn = exchangeExn(exchange); + const attrs = isRecord(exn.a) ? exn.a : {}; + + return stringValue(exn.rp) ?? stringValue(attrs.i); +}; + +const isOutboundOrUnrelatedChallengeRequest = ({ + contacts, + localAids, + recipientAid, + sender, + senderAid, +}: { + contacts: readonly ContactRecord[]; + localAids: ReadonlySet; + recipientAid: string | null; + sender: ContactRecord | null; + senderAid: string; +}): boolean => { + if (localAids.has(senderAid)) { + return true; + } + + if (localAids.size > 0) { + return recipientAid === null || !localAids.has(recipientAid); + } + + return ( + sender === null && + recipientAid !== null && + contactForAid(contacts, recipientAid) !== null + ); +}; + +export const challengeRequestFromExchange = ({ + notification, + exchange, + senderAlias, + status, + loadedAt, +}: { + notification: NotificationRecord; + exchange: unknown; + senderAlias: string; + status: ChallengeRequestNotificationStatus; + loadedAt: string; +}): ChallengeRequestNotification => { + const exn = exchangeExn(exchange); + const route = stringValue(exn.r); + if (route !== CHALLENGE_REQUEST_ROUTE) { + throw new Error( + `Expected ${CHALLENGE_REQUEST_ROUTE} EXN, received ${route ?? 'unknown route'}.` + ); + } + + const attrs = isRecord(exn.a) ? exn.a : {}; + const exnSaid = stringValue(exn.d) ?? notification.anchorSaid; + const senderAid = stringValue(exn.i); + const recipientAid = stringValue(exn.rp) ?? stringValue(attrs.i); + const challengeId = stringValue(attrs.challengeId); + const wordsHash = stringValue(attrs.wordsHash); + const strength = numberValue(attrs.strength); + const createdAt = + stringValue(exn.dt) ?? + notification.dt ?? + notification.updatedAt ?? + loadedAt; + + if (exnSaid === null) { + throw new Error('Challenge request EXN is missing its SAID.'); + } + + if (senderAid === null) { + throw new Error('Challenge request EXN is missing its sender AID.'); + } + + if (challengeId === null || wordsHash === null || strength === null) { + throw new Error('Challenge request EXN is missing challenge metadata.'); + } + + return { + notificationId: notification.id, + exnSaid, + senderAid, + senderAlias, + recipientAid, + challengeId, + wordsHash, + strength, + createdAt, + status, + }; +}; + +function* hydrateChallengeRequestNotification({ + client, + notification, + contacts, + localAids, + loadedAt, +}: { + client: SignifyClient; + notification: NotificationRecord; + contacts: readonly ContactRecord[]; + localAids: ReadonlySet; + loadedAt: string; +}): EffectionOperation<{ + notification: NotificationRecord; + unknownChallengeSender: UnknownChallengeSenderNotice | null; +}> { + const canHydrate = + notification.route === CHALLENGE_REQUEST_ROUTE || + (notification.anchorSaid !== null && notification.route === '/exn'); + if (!canHydrate) { + return { notification, unknownChallengeSender: null }; + } + + if (notification.anchorSaid === null) { + return { + notification: { + ...notification, + status: 'error', + message: + 'Challenge request notification is missing its EXN SAID.', + }, + unknownChallengeSender: null, + }; + } + + try { + const anchorSaid = notification.anchorSaid; + const exchange = yield* callPromise(() => + client.exchanges().get(anchorSaid) + ); + if (exchangeRoute(exchange) !== CHALLENGE_REQUEST_ROUTE) { + return notification.route === CHALLENGE_REQUEST_ROUTE + ? { + notification: { + ...notification, + status: 'error', + message: + 'Challenge request notification referenced a non-challenge EXN.', + }, + unknownChallengeSender: null, + } + : { notification, unknownChallengeSender: null }; + } + + const provisional = challengeRequestFromExchange({ + notification, + exchange, + senderAlias: 'Unknown sender', + status: 'actionable', + loadedAt, + }); + const sender = contactForAid(contacts, provisional.senderAid); + const recipientAid = exchangeRecipientAid(exchange); + if ( + isOutboundOrUnrelatedChallengeRequest({ + contacts, + localAids, + recipientAid, + sender, + senderAid: provisional.senderAid, + }) + ) { + if (!notification.read) { + yield* callPromise(() => + client.notifications().mark(notification.id) + ); + } + + return { + notification: { + ...notification, + read: true, + status: 'processed', + message: + 'Challenge request was ignored because it is not inbound to this wallet.', + challengeRequest: null, + }, + unknownChallengeSender: null, + }; + } + + if (sender === null) { + if (!notification.read) { + yield* callPromise(() => + client.notifications().mark(notification.id) + ); + } + + const challengeRequest = { + ...provisional, + status: 'senderUnknown' as const, + }; + return { + notification: { + ...notification, + read: true, + status: 'processed', + message: + 'Challenge request sender is not in contacts; notification was marked read.', + challengeRequest, + }, + unknownChallengeSender: notification.read + ? null + : { + notificationId: notification.id, + exnSaid: challengeRequest.exnSaid, + senderAid: challengeRequest.senderAid, + createdAt: challengeRequest.createdAt, + }, + }; + } + + const challengeRequest = { + ...provisional, + senderAlias: sender.alias, + status: notification.read ? 'responded' : 'actionable', + } satisfies ChallengeRequestNotification; + + return { + notification: { + ...notification, + status: notification.read ? 'processed' : 'unread', + message: + notification.message ?? + `Challenge request from ${sender.alias}`, + challengeRequest, + }, + unknownChallengeSender: null, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unable to hydrate challenge request notification.'; + return { + notification: { + ...notification, + status: 'error', + message, + challengeRequest: null, + }, + unknownChallengeSender: null, + }; + } +} + +function* hydrateChallengeRequestNotifications({ + client, + notifications, + contacts, + localAids, + loadedAt, +}: { + client: SignifyClient; + notifications: NotificationRecord[]; + contacts: readonly ContactRecord[]; + localAids: ReadonlySet; + loadedAt: string; +}): EffectionOperation<{ + notifications: NotificationRecord[]; + unknownChallengeSenders: UnknownChallengeSenderNotice[]; +}> { + const hydrated: NotificationRecord[] = []; + const unknownChallengeSenders: UnknownChallengeSenderNotice[] = []; + + for (const notification of notifications) { + const result = yield* hydrateChallengeRequestNotification({ + client, + notification, + contacts, + localAids, + loadedAt, + }); + hydrated.push(result.notification); + if (result.unknownChallengeSender !== null) { + unknownChallengeSenders.push(result.unknownChallengeSender); + } + } + + return { notifications: hydrated, unknownChallengeSenders }; +} + +const challengeRequestExchangesFromResponse = (raw: unknown): unknown[] => + Array.isArray(raw) + ? raw.filter((item) => { + try { + return exchangeRoute(item) === CHALLENGE_REQUEST_ROUTE; + } catch { + return false; + } + }) + : []; + +function* listChallengeRequestExchanges({ + client, +}: { + client: SignifyClient; +}): EffectionOperation { + const raw: unknown = yield* callPromise(() => + client + .fetch('/exchanges/query', 'POST', { + filter: { + '-r': CHALLENGE_REQUEST_ROUTE, + }, + limit: 50, + }) + .then((response) => response.json()) + ); + + return challengeRequestExchangesFromResponse(raw); +} + +const syntheticNotificationFromExchange = ( + exchange: unknown, + loadedAt: string +): NotificationRecord | null => { + const exn = exchangeExn(exchange); + const exnSaid = stringValue(exn.d); + if (exnSaid === null) { + return null; + } + + const dt = stringValue(exn.dt) ?? loadedAt; + return { + id: syntheticChallengeNotificationId(exnSaid), + dt, + read: false, + route: CHALLENGE_REQUEST_ROUTE, + anchorSaid: exnSaid, + status: 'unread', + message: null, + challengeRequest: null, + updatedAt: dt, + }; +}; + +function* syntheticChallengeRequestNotifications({ + client, + contacts, + localAids, + loadedAt, + existingExnSaids, + respondedChallengeIds, + respondedWordsHashes, +}: { + client: SignifyClient; + contacts: readonly ContactRecord[]; + localAids: ReadonlySet; + loadedAt: string; + existingExnSaids: ReadonlySet; + respondedChallengeIds: ReadonlySet; + respondedWordsHashes: ReadonlySet; +}): EffectionOperation<{ + notifications: NotificationRecord[]; + unknownChallengeSenders: UnknownChallengeSenderNotice[]; +}> { + const exchanges = yield* listChallengeRequestExchanges({ client }); + const notifications: NotificationRecord[] = []; + const unknownChallengeSenders: UnknownChallengeSenderNotice[] = []; + + for (const exchange of exchanges) { + const exnSaid = exchangeSaid(exchange); + if (exnSaid === null || existingExnSaids.has(exnSaid)) { + continue; + } + + const notification = syntheticNotificationFromExchange( + exchange, + loadedAt + ); + if (notification === null) { + continue; + } + + try { + const provisional = challengeRequestFromExchange({ + notification, + exchange, + senderAlias: 'Unknown sender', + status: 'actionable', + loadedAt, + }); + const recipientAid = exchangeRecipientAid(exchange); + const sender = contactForAid(contacts, provisional.senderAid); + if ( + isOutboundOrUnrelatedChallengeRequest({ + contacts, + localAids, + recipientAid, + sender, + senderAid: provisional.senderAid, + }) + ) { + continue; + } + + if (sender === null) { + const challengeRequest = { + ...provisional, + status: 'senderUnknown' as const, + }; + notifications.push({ + ...notification, + read: true, + status: 'processed', + message: + 'Challenge request sender is not in contacts; synthetic notification was closed.', + challengeRequest, + }); + unknownChallengeSenders.push({ + notificationId: notification.id, + exnSaid: challengeRequest.exnSaid, + senderAid: challengeRequest.senderAid, + createdAt: challengeRequest.createdAt, + }); + continue; + } + + const responded = + respondedChallengeIds.has(provisional.challengeId) || + respondedWordsHashes.has(provisional.wordsHash); + const challengeRequest = { + ...provisional, + senderAlias: sender.alias, + status: responded ? 'responded' : 'actionable', + } satisfies ChallengeRequestNotification; + notifications.push({ + ...notification, + read: responded, + status: responded ? 'processed' : 'unread', + message: `Challenge request from ${sender.alias}`, + challengeRequest, + }); + } catch (error) { + notifications.push({ + ...notification, + status: 'error', + message: + error instanceof Error + ? error.message + : 'Unable to hydrate challenge request exchange.', + }); + } + } + + return { notifications, unknownChallengeSenders }; +} + /** * Load KERIA protocol notifications without mixing them with local app notices. */ export function* listNotificationsService({ client, + contacts = [], + localAids = [], + respondedChallengeIds = [], + respondedWordsHashes = [], }: { client: SignifyClient; + contacts?: readonly ContactRecord[]; + localAids?: readonly string[]; + respondedChallengeIds?: readonly string[]; + respondedWordsHashes?: readonly string[]; }): EffectionOperation { - const raw: unknown = yield* callPromise(() => client.notifications().list()); + const raw: unknown = yield* callPromise(() => + client.notifications().list() + ); const loadedAt = new Date().toISOString(); + const localAidSet = aidSet(localAids); + const notifications = notificationRecordsFromResponse(raw, loadedAt); + const hydrated = yield* hydrateChallengeRequestNotifications({ + client, + notifications, + contacts, + localAids: localAidSet, + loadedAt, + }); + const existingExnSaids = new Set( + hydrated.notifications.flatMap((notification) => + notification.challengeRequest?.exnSaid !== undefined + ? [notification.challengeRequest.exnSaid] + : notification.anchorSaid !== null + ? [notification.anchorSaid] + : [] + ) + ); + const synthetic = yield* syntheticChallengeRequestNotifications({ + client, + contacts, + localAids: localAidSet, + loadedAt, + existingExnSaids, + respondedChallengeIds: new Set(respondedChallengeIds), + respondedWordsHashes: new Set(respondedWordsHashes), + }); + return { - notifications: notificationRecordsFromResponse(raw, loadedAt), + notifications: [...hydrated.notifications, ...synthetic.notifications], loadedAt, + unknownChallengeSenders: [ + ...hydrated.unknownChallengeSenders, + ...synthetic.unknownChallengeSenders, + ], }; } @@ -76,12 +640,26 @@ export function* listNotificationsService({ export function* markNotificationReadService({ client, notificationId, + contacts = [], + localAids = [], + respondedChallengeIds = [], + respondedWordsHashes = [], }: { client: SignifyClient; notificationId: string; + contacts?: readonly ContactRecord[]; + localAids?: readonly string[]; + respondedChallengeIds?: readonly string[]; + respondedWordsHashes?: readonly string[]; }): EffectionOperation { yield* callPromise(() => client.notifications().mark(notificationId)); - return yield* listNotificationsService({ client }); + return yield* listNotificationsService({ + client, + contacts, + localAids, + respondedChallengeIds, + respondedWordsHashes, + }); } /** @@ -90,10 +668,24 @@ export function* markNotificationReadService({ export function* deleteNotificationService({ client, notificationId, + contacts = [], + localAids = [], + respondedChallengeIds = [], + respondedWordsHashes = [], }: { client: SignifyClient; notificationId: string; + contacts?: readonly ContactRecord[]; + localAids?: readonly string[]; + respondedChallengeIds?: readonly string[]; + respondedWordsHashes?: readonly string[]; }): EffectionOperation { yield* callPromise(() => client.notifications().delete(notificationId)); - return yield* listNotificationsService({ client }); + return yield* listNotificationsService({ + client, + contacts, + localAids, + respondedChallengeIds, + respondedWordsHashes, + }); } diff --git a/src/services/signify.service.ts b/src/services/signify.service.ts index f0605f44..49198846 100644 --- a/src/services/signify.service.ts +++ b/src/services/signify.service.ts @@ -73,11 +73,17 @@ export function* waitOperationService({ operation, label, logger, + timeoutMs, + minSleepMs, + maxSleepMs, }: { client: SignifyClient; operation: KeriaOperation; label: string; logger?: OperationLogger; + timeoutMs?: number; + minSleepMs?: number; + maxSleepMs?: number; }): EffectionOperation { const signal = yield* effectionAbortSignal(); return yield* callPromise(() => @@ -85,6 +91,9 @@ export function* waitOperationService({ label, signal, logger, + timeoutMs, + minSleepMs, + maxSleepMs, }) ); } diff --git a/src/state/challenges.slice.ts b/src/state/challenges.slice.ts index 9a70d0c5..bedb9f4c 100644 --- a/src/state/challenges.slice.ts +++ b/src/state/challenges.slice.ts @@ -11,18 +11,31 @@ export type ChallengeDirection = 'issued' | 'received'; /** Lifecycle state for challenge/response verification. */ export type ChallengeStatus = 'pending' | 'responded' | 'verified' | 'failed'; +/** Origin for a challenge record in session state. */ +export type ChallengeSource = 'keria' | 'workflow'; + /** * Durable summary of one challenge exchange. */ export interface ChallengeRecord { id: string; + source?: ChallengeSource; direction: ChallengeDirection; role: string; counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier?: string | null; + localAid?: string | null; words: string[]; + wordsHash?: string | null; + responseSaid?: string | null; authenticated: boolean; status: ChallengeStatus; result: string | null; + error?: string | null; + generatedAt?: string | null; + sentAt?: string | null; + verifiedAt?: string | null; updatedAt: string; } @@ -43,6 +56,11 @@ const createInitialState = (): ChallengesState => ({ const initialState: ChallengesState = createInitialState(); +const challengeMergeKey = (challenge: ChallengeRecord): string => + challenge.wordsHash === undefined || challenge.wordsHash === null + ? challenge.id + : `${challenge.counterpartyAid}:${challenge.wordsHash}`; + /** * Redux slice for challenge/response workflow progress. */ @@ -59,13 +77,25 @@ export const challengesSlice = createSlice({ loadedAt: string; }> ) { + const inventoryKeys = new Set( + payload.challenges.map(challengeMergeKey) + ); + const preservedWorkflowRecords = state.ids + .map((id) => state.byId[id]) + .filter( + (challenge): challenge is ChallengeRecord => + challenge?.source === 'workflow' && + !inventoryKeys.has(challengeMergeKey(challenge)) + ); + const nextChallenges = [ + ...preservedWorkflowRecords, + ...payload.challenges, + ]; + state.byId = Object.fromEntries( - payload.challenges.map((challenge) => [ - challenge.id, - challenge, - ]) + nextChallenges.map((challenge) => [challenge.id, challenge]) ); - state.ids = payload.challenges.map((challenge) => challenge.id); + state.ids = nextChallenges.map((challenge) => challenge.id); state.loadedAt = payload.loadedAt; }, challengeRecorded(state, { payload }: PayloadAction) { diff --git a/src/state/notifications.slice.ts b/src/state/notifications.slice.ts index 8bd4e7d5..858ffc7a 100644 --- a/src/state/notifications.slice.ts +++ b/src/state/notifications.slice.ts @@ -6,7 +6,37 @@ import { } from './session.slice'; /** Local handling status for a KERIA notification route. */ -export type NotificationStatus = 'unread' | 'processing' | 'processed' | 'error'; +export type NotificationStatus = + | 'unread' + | 'processing' + | 'processed' + | 'error'; + +/** Responder-facing state for challenge request notifications. */ +export type ChallengeRequestNotificationStatus = + | 'actionable' + | 'senderUnknown' + | 'responded' + | 'error'; + +/** + * Actionable challenge request metadata hydrated from a KERIA notification EXN. + * + * Raw challenge words never belong here. The responder supplies words + * out-of-band when sending the response. + */ +export interface ChallengeRequestNotification { + notificationId: string; + exnSaid: string; + senderAid: string; + senderAlias: string; + recipientAid: string | null; + challengeId: string; + wordsHash: string; + strength: number; + createdAt: string; + status: ChallengeRequestNotificationStatus; +} /** * Durable notification summary used by polling and future processing workflows. @@ -19,6 +49,7 @@ export interface NotificationRecord { anchorSaid: string | null; status: NotificationStatus; message: string | null; + challengeRequest?: ChallengeRequestNotification | null; updatedAt: string; } @@ -93,6 +124,33 @@ export const notificationsSlice = createSlice({ notification.message = payload.message ?? notification.message; } }, + challengeRequestNotificationResponded( + 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.challengeRequest !== null && + notification.challengeRequest !== undefined + ) { + notification.challengeRequest = { + ...notification.challengeRequest, + status: 'responded', + }; + } + } + }, }, extraReducers: (builder) => { builder @@ -107,8 +165,8 @@ export const { notificationInventoryLoaded, notificationRecorded, notificationStatusChanged, -} = - notificationsSlice.actions; + challengeRequestNotificationResponded, +} = notificationsSlice.actions; /** Reducer mounted at `state.notifications`. */ export const notificationsReducer = notificationsSlice.reducer; diff --git a/src/state/operations.slice.ts b/src/state/operations.slice.ts index 5442d7f1..c5c84ce8 100644 --- a/src/state/operations.slice.ts +++ b/src/state/operations.slice.ts @@ -23,6 +23,10 @@ export type OperationKind = | 'createIdentifier' | 'rotateIdentifier' | 'generateOobi' + | 'generateChallenge' + | 'sendChallengeRequest' + | 'respondChallenge' + | 'verifyChallenge' | 'deleteContact' | 'updateContact' | 'syncInventory' @@ -83,7 +87,8 @@ const initialState: OperationsState = { */ const now = (): string => new Date().toISOString(); -const operationRoute = (requestId: string): string => `/operations/${requestId}`; +const operationRoute = (requestId: string): string => + `/operations/${requestId}`; /** * Trim completed/canceled/failed operation history without dropping active work. diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 4a33f684..9efe58c0 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,7 +1,10 @@ import type { RootState } from './store'; import type { OperationRecord } from './operations.slice'; import type { AppNotificationRecord } from './appNotifications.slice'; -import type { NotificationRecord } from './notifications.slice'; +import type { + ChallengeRequestNotification, + NotificationRecord, +} from './notifications.slice'; import type { ContactRecord } from './contacts.slice'; import type { ChallengeRecord } from './challenges.slice'; import { @@ -70,6 +73,11 @@ const byNewestKeriaNotificationTimestamp = ( right: NotificationRecord ): number => right.updatedAt.localeCompare(left.updatedAt); +const byNewestChallengeRequestTimestamp = ( + left: ChallengeRequestNotification, + right: ChallengeRequestNotification +): number => right.createdAt.localeCompare(left.createdAt); + const byNewestOperationTimestamp = ( left: OperationRecord, right: OperationRecord @@ -80,10 +88,8 @@ const byNewestChallengeTimestamp = ( right: ChallengeRecord ): number => right.updatedAt.localeCompare(left.updatedAt); -const byUpdatedContact = ( - left: ContactRecord, - right: ContactRecord -): number => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''); +const byUpdatedContact = (left: ContactRecord, right: ContactRecord): number => + (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''); /** Select user-facing app notification records in descending timestamp order. */ export const selectAppNotifications = (state: RootState) => @@ -127,7 +133,9 @@ export const selectGeneratedOobis = (state: RootState) => state.contacts.generatedOobiIds .map((id) => state.contacts.generatedOobis[id]) .filter((record) => record !== undefined) - .sort((left, right) => right.generatedAt.localeCompare(left.generatedAt)); + .sort((left, right) => + right.generatedAt.localeCompare(left.generatedAt) + ); /** Build an OOBI lookup for contacts that were created from an OOBI. */ export const selectContactsByOobi = (state: RootState) => { @@ -161,6 +169,37 @@ export const selectKeriaNotifications = (state: RootState) => ) .sort(byNewestKeriaNotificationTimestamp); +/** Select one KERIA notification by id. */ +export const selectKeriaNotificationById = + (id: string) => + (state: RootState): NotificationRecord | null => + state.notifications.byId[id] ?? null; + +/** Select hydrated challenge request notifications newest first. */ +export const selectChallengeRequestNotifications = (state: RootState) => + selectKeriaNotifications(state) + .flatMap((notification) => + notification.challengeRequest === null || + notification.challengeRequest === undefined + ? [] + : [notification.challengeRequest] + ) + .sort(byNewestChallengeRequestTimestamp); + +/** Select challenge requests that still need responder action. */ +export const selectActionableChallengeRequestNotifications = ( + state: RootState +) => + selectChallengeRequestNotifications(state).filter( + (notification) => notification.status === 'actionable' + ); + +/** Select one hydrated challenge request notification by KERIA notification id. */ +export const selectChallengeRequestNotificationById = + (notificationId: string) => + (state: RootState): ChallengeRequestNotification | null => + state.notifications.byId[notificationId]?.challengeRequest ?? null; + /** Select challenge-response records newest first. */ export const selectChallenges = (state: RootState) => state.challenges.ids @@ -197,10 +236,12 @@ export const selectKnownComponentsByRole = (state: RootState) => { }; /** Select recent operations for compact dashboard panels. */ -export const selectRecentOperations = (limit = 5) => (state: RootState) => - [...selectOperationRecords(state)] - .sort(byNewestOperationTimestamp) - .slice(0, limit); +export const selectRecentOperations = + (limit = 5) => + (state: RootState) => + [...selectOperationRecords(state)] + .sort(byNewestOperationTimestamp) + .slice(0, limit); /** Select recent protocol notifications for dashboard panels. */ export const selectRecentKeriaNotifications = diff --git a/src/workflows/challenges.op.ts b/src/workflows/challenges.op.ts new file mode 100644 index 00000000..c633ef9b --- /dev/null +++ b/src/workflows/challenges.op.ts @@ -0,0 +1,432 @@ +import type { Operation as EffectionOperation } from 'effection'; +import { toErrorText } from '../effects/promise'; +import { AppServicesContext, type AppServices } from '../effects/contexts'; +import { + challengeWordsFingerprint, + defaultChallengeStrength, + type ChallengeStrength, +} from '../features/contacts/challengeWords'; +import { + generateChallengeService, + respondToChallengeService, + sendChallengeRequestService, + verifyChallengeResponseService, + type SendChallengeRequestResult, +} from '../services/challenges.service'; +import { listContactsService } from '../services/contacts.service'; +import { + isSyntheticChallengeNotificationId, + markNotificationReadService, +} from '../services/notifications.service'; +import { + challengeRecorded, + type ChallengeRecord, +} from '../state/challenges.slice'; +import { challengeRequestNotificationResponded } from '../state/notifications.slice'; +import { + localIdentifierAids, + publishContactInventory, + publishNotificationInventory, +} from './contacts.op'; + +export interface GenerateContactChallengeInput { + challengeId?: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + strength?: ChallengeStrength; +} + +export interface GeneratedContactChallengeResult { + challengeId: string; + counterpartyAid: string; + counterpartyAlias: string | null; + localIdentifier: string; + localAid: string | null; + words: string[]; + wordsHash: string; + strength: ChallengeStrength; + generatedAt: string; +} + +export interface RespondToContactChallengeInput { + challengeId?: string; + notificationId?: string; + wordsHash?: string | null; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: readonly string[]; +} + +export interface SendChallengeRequestInput { + challengeId: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + wordsHash: string; + strength: ChallengeStrength; +} + +export interface VerifyContactChallengeInput { + challengeId: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: readonly string[]; + wordsHash?: string | null; + generatedAt?: string | null; +} + +const createWorkflowId = (prefix: string): string => + globalThis.crypto?.randomUUID?.() ?? + `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const normalizedAid = (value: string): string => { + const aid = value.trim(); + if (aid.length === 0) { + throw new Error('Contact AID is required.'); + } + + return aid; +}; + +const normalizedIdentifier = (value: string): string => { + const identifier = value.trim(); + if (identifier.length === 0) { + throw new Error('Local identifier is required.'); + } + + return identifier; +}; + +const contactRoute = (aid: string): string => + `/contacts/${encodeURIComponent(aid)}`; + +const challengeRecord = ({ + id, + direction, + role, + counterpartyAid, + counterpartyAlias, + localIdentifier, + localAid, + words, + status, + authenticated, + result, + responseSaid, + error, + wordsHash, + generatedAt, + sentAt, + verifiedAt, + updatedAt, +}: { + id: string; + direction: ChallengeRecord['direction']; + role: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: readonly string[]; + status: ChallengeRecord['status']; + authenticated: boolean; + result: string | null; + wordsHash?: string | null; + responseSaid?: string | null; + error?: string | null; + generatedAt?: string | null; + sentAt?: string | null; + verifiedAt?: string | null; + updatedAt: string; +}): ChallengeRecord => { + const normalizedWords = words.map((word) => word.trim().toLowerCase()); + return { + id, + source: 'workflow', + direction, + role, + counterpartyAid, + counterpartyAlias: counterpartyAlias ?? null, + localIdentifier, + localAid: localAid ?? null, + words: normalizedWords, + wordsHash: wordsHash ?? challengeWordsFingerprint(normalizedWords), + responseSaid: responseSaid ?? null, + authenticated, + status, + result, + error: error ?? null, + generatedAt: generatedAt ?? null, + sentAt: sentAt ?? null, + verifiedAt: verifiedAt ?? null, + updatedAt, + }; +}; + +const currentContacts = (services: AppServices) => + Object.values(services.store.getState().contacts.byId).filter( + (contact) => contact !== undefined + ); + +/** + * Generate and record a contact challenge phrase. + */ +export function* generateContactChallengeOp( + input: GenerateContactChallengeInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const counterpartyAid = normalizedAid(input.counterpartyAid); + const localIdentifier = normalizedIdentifier(input.localIdentifier); + const strength = input.strength ?? defaultChallengeStrength; + const words = yield* generateChallengeService({ + client: services.runtime.requireConnectedClient(), + strength, + }); + const generatedAt = new Date().toISOString(); + const challengeId = input.challengeId ?? createWorkflowId('challenge'); + const wordsHash = challengeWordsFingerprint(words); + + services.store.dispatch( + challengeRecorded( + challengeRecord({ + id: challengeId, + direction: 'issued', + role: 'challenger', + counterpartyAid, + counterpartyAlias: input.counterpartyAlias, + localIdentifier, + localAid: input.localAid, + words, + status: 'pending', + authenticated: false, + result: null, + generatedAt, + updatedAt: generatedAt, + }) + ) + ); + + return { + challengeId, + counterpartyAid, + counterpartyAlias: input.counterpartyAlias ?? null, + localIdentifier, + localAid: input.localAid ?? null, + words, + wordsHash, + strength, + generatedAt, + }; +} + +/** + * Send a signed challenge response from a local identifier. + */ +export function* respondToContactChallengeOp( + input: RespondToContactChallengeInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const counterpartyAid = normalizedAid(input.counterpartyAid); + const localIdentifier = normalizedIdentifier(input.localIdentifier); + const challengeId = + input.challengeId ?? createWorkflowId('challenge-response'); + const computedWordsHash = challengeWordsFingerprint(input.words); + + if ( + input.wordsHash !== undefined && + input.wordsHash !== null && + input.wordsHash.trim().length > 0 && + input.wordsHash.trim() !== computedWordsHash + ) { + throw new Error( + 'Challenge words do not match the requested challenge.' + ); + } + + yield* respondToChallengeService({ + client: services.runtime.requireConnectedClient(), + localIdentifier, + recipientAid: counterpartyAid, + words: input.words, + }); + + const sentAt = new Date().toISOString(); + const record = challengeRecord({ + id: challengeId, + direction: 'received', + role: 'responder', + counterpartyAid, + counterpartyAlias: input.counterpartyAlias, + localIdentifier, + localAid: input.localAid, + words: input.words, + wordsHash: input.wordsHash ?? computedWordsHash, + status: 'responded', + authenticated: false, + result: 'Response sent', + sentAt, + updatedAt: sentAt, + }); + services.store.dispatch(challengeRecorded(record)); + if ( + input.notificationId !== undefined && + input.notificationId.trim().length > 0 + ) { + const notificationId = input.notificationId.trim(); + if (isSyntheticChallengeNotificationId(notificationId)) { + services.store.dispatch( + challengeRequestNotificationResponded({ + id: notificationId, + updatedAt: sentAt, + message: 'Challenge response sent.', + }) + ); + } else { + const inventory = yield* markNotificationReadService({ + client: services.runtime.requireConnectedClient(), + notificationId, + contacts: currentContacts(services), + localAids: localIdentifierAids(services.store), + respondedChallengeIds: [record.id], + respondedWordsHashes: + record.wordsHash === undefined || record.wordsHash === null + ? [] + : [record.wordsHash], + }); + publishNotificationInventory(services.store, inventory); + } + } + + return record; +} + +/** + * Send an out-of-band-word challenge request EXN to the target contact. + */ +export function* sendChallengeRequestOp( + input: SendChallengeRequestInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const counterpartyAid = normalizedAid(input.counterpartyAid); + const localIdentifier = normalizedIdentifier(input.localIdentifier); + + return yield* sendChallengeRequestService({ + client: services.runtime.requireConnectedClient(), + localIdentifier, + recipientAid: counterpartyAid, + challengeId: input.challengeId, + wordsHash: input.wordsHash, + strength: input.strength, + }); +} + +/** + * Wait for, verify, and accept a target contact's challenge response. + */ +export function* verifyContactChallengeOp( + input: VerifyContactChallengeInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const counterpartyAid = normalizedAid(input.counterpartyAid); + const localIdentifier = normalizedIdentifier(input.localIdentifier); + const startedAt = new Date().toISOString(); + const wordsHash = input.wordsHash ?? challengeWordsFingerprint(input.words); + const pending = challengeRecord({ + id: input.challengeId, + direction: 'issued', + role: 'challenger', + counterpartyAid, + counterpartyAlias: input.counterpartyAlias, + localIdentifier, + localAid: input.localAid, + words: input.words, + status: 'pending', + authenticated: false, + result: 'Waiting for response', + generatedAt: input.generatedAt ?? startedAt, + updatedAt: startedAt, + }); + + services.store.dispatch( + challengeRecorded({ + ...pending, + wordsHash, + }) + ); + + try { + const pollMs = Math.max( + 1000, + Math.min(5000, services.config.operations.liveRefreshMs) + ); + const timeoutMs = Math.max( + 300000, + services.config.operations.timeoutMs + ); + const verified = yield* verifyChallengeResponseService({ + client: services.runtime.requireConnectedClient(), + sourceAid: counterpartyAid, + words: input.words, + timeoutMs, + pollMs, + logger: services.logger, + }); + const verifiedAt = new Date().toISOString(); + const record = challengeRecord({ + id: input.challengeId, + direction: 'issued', + role: 'challenger', + counterpartyAid, + counterpartyAlias: input.counterpartyAlias, + localIdentifier, + localAid: input.localAid, + words: input.words, + status: 'verified', + authenticated: true, + result: verified.responseSaid, + responseSaid: verified.responseSaid, + generatedAt: input.generatedAt ?? pending.generatedAt, + verifiedAt, + updatedAt: verifiedAt, + }); + + services.store.dispatch(challengeRecorded(record)); + const inventory = yield* listContactsService({ + client: services.runtime.requireConnectedClient(), + }); + publishContactInventory(services.store.dispatch, inventory); + return record; + } catch (error) { + const failedAt = new Date().toISOString(); + const record = challengeRecord({ + id: input.challengeId, + direction: 'issued', + role: 'challenger', + counterpartyAid, + counterpartyAlias: input.counterpartyAlias, + localIdentifier, + localAid: input.localAid, + words: input.words, + status: 'failed', + authenticated: false, + result: null, + error: toErrorText(error), + generatedAt: input.generatedAt ?? pending.generatedAt, + updatedAt: failedAt, + }); + services.store.dispatch(challengeRecorded(record)); + throw error; + } +} + +export const challengeResultRoute = (counterpartyAid: string) => ({ + label: 'View contact', + path: contactRoute(counterpartyAid), +}); diff --git a/src/workflows/contacts.op.ts b/src/workflows/contacts.op.ts index 91c1023b..94bbc7b7 100644 --- a/src/workflows/contacts.op.ts +++ b/src/workflows/contacts.op.ts @@ -16,7 +16,10 @@ import { type ResolveContactInput, type ResolveContactResult, } from '../services/contacts.service'; -import { listNotificationsService } from '../services/notifications.service'; +import { + listNotificationsService, + type NotificationInventorySnapshot, +} from '../services/notifications.service'; import { appNotificationRecorded } from '../state/appNotifications.slice'; import { challengesLoaded } from '../state/challenges.slice'; import { @@ -28,7 +31,8 @@ import { type GeneratedOobiRecord, } from '../state/contacts.slice'; import { notificationInventoryLoaded } from '../state/notifications.slice'; -import type { AppDispatch } from '../state/store'; +import type { AppDispatch, AppStore } from '../state/store'; +import type { ChallengeRecord } from '../state/challenges.slice'; export interface GenerateOobiInput { identifier: string; @@ -44,7 +48,7 @@ export interface SessionInventorySnapshot extends ContactInventorySnapshot { notificationsLoadedAt: string; } -const publishContactInventory = ( +export const publishContactInventory = ( dispatch: AppDispatch, inventory: ContactInventorySnapshot ): void => { @@ -62,6 +66,103 @@ const publishContactInventory = ( ); }; +export const publishNotificationInventory = ( + store: Pick, + inventory: NotificationInventorySnapshot +): void => { + store.dispatch( + notificationInventoryLoaded({ + notifications: inventory.notifications, + loadedAt: inventory.loadedAt, + }) + ); + + for (const unknownSender of inventory.unknownChallengeSenders) { + const id = `challenge-sender-unknown:${unknownSender.notificationId}`; + if (store.getState().appNotifications.byId[id] !== undefined) { + continue; + } + + store.dispatch( + appNotificationRecorded({ + id, + severity: 'warning', + status: 'unread', + title: 'Challenge request sender unknown', + message: + 'A challenge request came from an AID that is not in contacts. The KERIA notification was marked read.', + createdAt: unknownSender.createdAt, + readAt: null, + operationId: null, + links: [ + { + rel: 'result', + label: 'View notifications', + path: '/notifications', + }, + ], + payloadDetails: [ + { + id: 'sender-aid', + label: 'Sender AID', + value: unknownSender.senderAid, + kind: 'aid', + copyable: true, + }, + { + id: 'exchange-said', + label: 'EXN SAID', + value: unknownSender.exnSaid, + kind: 'text', + copyable: true, + }, + ], + }) + ); + } +}; + +export const localIdentifierAids = ( + store: Pick +): string[] => { + const { identifiers } = store.getState(); + const aids = identifiers.prefixes.flatMap((prefix) => { + const identifier = identifiers.byPrefix[prefix]; + const aid = identifier?.prefix ?? prefix; + return aid.trim().length > 0 ? [aid] : []; + }); + + return [...new Set(aids)]; +}; + +const respondedChallengeKeys = ( + store: Pick, + inventory: ContactInventorySnapshot +): { + ids: string[]; + wordsHashes: string[]; +} => { + const allChallenges = [ + ...Object.values(store.getState().challenges.byId), + ...inventory.challenges, + ].filter( + (challenge): challenge is ChallengeRecord => challenge !== undefined + ); + const responded = allChallenges.filter( + (challenge) => + challenge.status === 'responded' || challenge.status === 'verified' + ); + + return { + ids: responded.map((challenge) => challenge.id), + wordsHashes: responded.flatMap((challenge) => + challenge.wordsHash === undefined || challenge.wordsHash === null + ? [] + : [challenge.wordsHash] + ), + }; +}; + /** * Load session-scoped contact, challenge, and KERIA notification facts. */ @@ -69,15 +170,17 @@ export function* syncSessionInventoryOp(): EffectionOperation { } } - yield* sleep(Math.max(250, services.config.operations.liveRefreshMs)); + yield* sleep( + Math.max( + 3000, + Math.min(5000, services.config.operations.liveRefreshMs) + ) + ); } } diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs index a6db8075..d6fc11fd 100644 --- a/tests/browser-smoke.mjs +++ b/tests/browser-smoke.mjs @@ -128,7 +128,7 @@ try { timeout: 10000, }); const identifierTableText = await textContent(page, '[data-testid="identifier-table"]'); - for (const expectedHeader of ['AID', 'KIDX', 'PIDX', 'Actions']) { + for (const expectedHeader of ['Name', 'AID', 'Actions']) { if (!identifierTableText.includes(expectedHeader)) { throw new Error(`Identifier table is missing ${expectedHeader} header`); } diff --git a/tests/contact-challenge-smoke.ts b/tests/contact-challenge-smoke.ts new file mode 100644 index 00000000..16a5f8ce --- /dev/null +++ b/tests/contact-challenge-smoke.ts @@ -0,0 +1,592 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import puppeteer, { type ElementHandle, type Page } from 'puppeteer'; +import { appConfig } from '../src/config'; +import { + challengeWordsFingerprint, + validateChallengeWords, +} from '../src/features/contacts/challengeWords'; +import { + CHALLENGE_REQUEST_ROUTE, + CHALLENGE_TOPIC, + responseSaidFromChallengeOperation, +} from '../src/services/challenges.service'; +import { connectSignifyClient, waitOperation } from '../src/signify/client'; +import { + addAgentEndRole, + createRole, + createWitnessedIdentifier, + resolveOobi, + uniqueAlias, + waitForEvent, + waitForKeriaOperation, + type Role, +} from './support/keria'; + +const appUrl = + process.env.CONTACT_CHALLENGE_SMOKE_URL ?? 'http://127.0.0.1:5177'; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + globalThis.setTimeout(resolve, ms); + }); + +const canReachApp = async (): Promise => { + try { + const response = await fetch(appUrl); + return response.ok; + } catch { + return false; + } +}; + +const waitForApp = async (): Promise => { + for (let attempt = 0; attempt < 60; attempt += 1) { + if (await canReachApp()) { + return; + } + await sleep(500); + } + + throw new Error(`Vite app did not become reachable at ${appUrl}`); +}; + +const startViteIfNeeded = async (): Promise => { + if (await canReachApp()) { + return null; + } + + const url = new URL(appUrl); + const child = spawn( + 'pnpm', + [ + 'exec', + 'vite', + '--host', + url.hostname, + '--port', + url.port, + '--strictPort', + ], + { + stdio: 'ignore', + env: { + ...process.env, + BROWSER: 'none', + }, + } + ); + + await waitForApp(); + return child; +}; + +const elementFor = async ( + page: Page, + selector: string +): Promise> => { + const element = await page.$(selector); + if (element === null) { + throw new Error(`Missing element ${selector}`); + } + + return element; +}; + +const passcodeValue = (page: Page): Promise => + page.$eval( + '#outlined-password-input', + (element) => (element as HTMLInputElement).value ?? '' + ); + +const setInputValue = async ( + page: Page, + selector: string, + value: string +): Promise => { + const element = await elementFor(page, selector); + await element.click({ clickCount: 3 }); + await page.keyboard.press('Backspace'); + await element.type(value); +}; + +const waitForText = async ( + page: Page, + selector: string, + expected: string, + timeout = 120_000 +): Promise => { + await page.waitForFunction( + (visibleSelector, text) => + Array.from( + globalThis.document.querySelectorAll(visibleSelector) + ).some((element) => element.textContent?.includes(text)), + { timeout }, + selector, + expected + ); +}; + +const connectBrowserAgent = async (page: Page): Promise => { + await page.goto(appUrl, { waitUntil: 'networkidle0' }); + await page.click('[data-testid="connect-open"]'); + await page.waitForSelector('[data-testid="connect-dialog"]'); + await page.click('[data-testid="generate-passcode"]'); + await page.waitForFunction( + () => + globalThis.document.querySelector('#outlined-password-input')?.value + .length >= 21, + { timeout: 10_000 } + ); + + const passcode = await passcodeValue(page); + await page.click('[data-testid="connect-submit"]'); + await page.waitForSelector('[data-testid="connect-dialog"]', { + hidden: true, + timeout: 30_000, + }); + await page.waitForSelector('[data-testid="known-components"]', { + timeout: 30_000, + }); + + return passcode; +}; + +const roleFromPasscode = async ( + passcode: string, + name: string +): Promise => { + const connected = await connectSignifyClient({ + adminUrl: appConfig.keria.adminUrl, + bootUrl: appConfig.keria.bootUrl, + passcode, + tier: appConfig.defaultTier, + }); + + let role: Role; + role = { + name, + passcode, + client: connected.client, + controllerPre: connected.state.controllerPre, + agentPre: connected.state.agentPre, + waitEvent: async (result, label) => waitForEvent(role, result, label), + waitOperation: async (operation, label) => + waitForKeriaOperation(role, operation, label), + }; + + return role; +}; + +const navigateInApp = async ( + page: Page, + navTestId: string, + readySelector: string +): Promise => { + await page.click('[data-testid="nav-open"]'); + await page.waitForSelector(`[data-testid="${navTestId}"]`, { + timeout: 10_000, + }); + await page.click(`[data-testid="${navTestId}"]`); + await page.waitForSelector(readySelector, { + timeout: 30_000, + }); +}; + +const waitForResolvedContact = async ( + page: Page, + alias: string +): Promise => { + await page.waitForFunction( + (expectedAlias) => + Array.from( + globalThis.document.querySelectorAll( + '[data-testid="contact-card"]' + ) + ).some( + (element) => + element.textContent?.includes(expectedAlias) === true && + element.textContent.includes('resolved') + ), + { timeout: 120_000 }, + alias + ); +}; + +const resolveOobiInContacts = async ( + page: Page, + oobi: string, + alias: string +): Promise => { + if ((await page.$('[data-testid="contacts-view"]')) === null) { + await navigateInApp( + page, + 'nav-contacts', + '[data-testid="contacts-view"]' + ); + } + await setInputValue( + page, + '[data-testid="contact-oobi-input"] textarea', + oobi + ); + await setInputValue( + page, + '[data-testid="contact-alias-input"] input', + alias + ); + await page.click('[data-testid="contact-resolve-submit"]'); + await waitForText(page, '[data-testid="contact-card"]', alias); + await waitForResolvedContact(page, alias); +}; + +const openContactDetail = async (page: Page, alias: string): Promise => { + await page.waitForFunction( + (expectedAlias) => + Array.from( + globalThis.document.querySelectorAll( + '[data-testid="contact-card-link"]' + ) + ).some((element) => element.textContent?.includes(expectedAlias)), + { timeout: 30_000 }, + alias + ); + await page.evaluate((expectedAlias) => { + const link = Array.from( + globalThis.document.querySelectorAll( + '[data-testid="contact-card-link"]' + ) + ).find((element) => element.textContent?.includes(expectedAlias)); + if (link instanceof HTMLElement) { + link.click(); + } + }, alias); + await page.waitForSelector('[data-testid="contact-detail"]', { + timeout: 30_000, + }); +}; + +const generatedChallengeWords = async (page: Page): Promise => { + await page.waitForSelector('[data-testid="challenge-generated-words"]', { + timeout: 30_000, + }); + const text = await page.$eval( + '[data-testid="challenge-generated-words"]', + (element) => element.textContent ?? '' + ); + const words = text.trim().split(/\s+/u).filter(Boolean); + if (words.length !== 12 && words.length !== 24) { + throw new Error( + `Expected challenge words, got ${words.length} tokens.` + ); + } + + return words; +}; + +const generatedKeriaChallengeWords = async (role: Role): Promise => { + const challenge = await role.client.challenges().generate(128); + const words = Array.isArray(challenge.words) ? challenge.words : []; + const error = validateChallengeWords(words); + if (error !== null) { + throw new Error(error); + } + + return words.map((word) => word.trim().toLowerCase()); +}; + +const sendChallengeRequest = async ({ + challenger, + challengerAlias, + recipientAid, + words, +}: { + challenger: Role; + challengerAlias: string; + recipientAid: string; + words: readonly string[]; +}): Promise => { + const challengeId = `smoke-challenge-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const hab = await challenger.client.identifiers().get(challengerAlias); + await challenger.client.exchanges().send( + challengerAlias, + CHALLENGE_TOPIC, + hab, + CHALLENGE_REQUEST_ROUTE, + { + challengeId, + wordsHash: challengeWordsFingerprint(words), + strength: words.length === 24 ? 256 : 128, + }, + {}, + [recipientAid] + ); + + return challengeId; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const exchangeChallengeId = (value: unknown): string | null => { + if (!isRecord(value) || !isRecord(value.exn) || !isRecord(value.exn.a)) { + return null; + } + + const challengeId = value.exn.a.challengeId; + return typeof challengeId === 'string' ? challengeId : null; +}; + +const exchangeSaid = (value: unknown): string | null => { + if (!isRecord(value) || !isRecord(value.exn)) { + return null; + } + + const said = value.exn.d; + return typeof said === 'string' ? said : null; +}; + +const challengeRequestExchanges = async (role: Role): Promise => { + const response = await role.client.fetch('/exchanges/query', 'POST', { + filter: { + '-r': CHALLENGE_REQUEST_ROUTE, + }, + limit: 50, + }); + const raw: unknown = await response.json(); + return Array.isArray(raw) ? raw : []; +}; + +const waitForChallengeRequestExchange = async ( + role: Role, + challengeId: string +): Promise => { + for (let attempt = 0; attempt < 45; attempt += 1) { + const exchanges = await challengeRequestExchanges(role); + const exchange = exchanges.find( + (candidate) => exchangeChallengeId(candidate) === challengeId + ); + if (exchange !== undefined) { + const said = exchangeSaid(exchange); + if (said !== null) { + return said; + } + } + await sleep(1000); + } + + throw new Error(`Challenge request ${challengeId} was not indexed.`); +}; + +const verifyChallengeResponse = async ({ + challenger, + responderAid, + words, + label, +}: { + challenger: Role; + responderAid: string; + words: readonly string[]; + label: string; +}): Promise => { + const operation = await challenger.client + .challenges() + .verify(responderAid, [...words]); + const completed = await waitOperation(challenger.client, operation, { + label: `${challenger.name}: ${label}`, + ...appConfig.operations, + timeoutMs: 120_000, + }); + const responseSaid = responseSaidFromChallengeOperation(completed); + const response = await challenger.client + .challenges() + .responded(responderAid, responseSaid); + if (!response.ok) { + throw new Error( + `KERIA rejected ${label}: ${response.status} ${response.statusText}` + ); + } +}; + +const openNotificationsBell = async (page: Page): Promise => { + await page.keyboard.press('Escape'); + await page.click('[data-testid="notifications-open"]'); +}; + +const waitForChallengeNotificationCard = async (page: Page): Promise => { + await openNotificationsBell(page); + try { + await page.waitForSelector( + '[data-testid="challenge-notification-card"]', + { + timeout: 45_000, + } + ); + } catch (error) { + const visibleText = await page.evaluate( + () => globalThis.document.body.textContent?.slice(0, 4000) ?? '' + ); + throw new Error( + `Challenge notification card did not appear. Visible text: ${visibleText}`, + { cause: error } + ); + } +}; + +const respondFromBellNotification = async ( + page: Page, + words: readonly string[] +): Promise => { + await waitForChallengeNotificationCard(page); + await setInputValue( + page, + '[data-testid="challenge-notification-card"] [data-testid="challenge-notification-response-input"] textarea', + words.join(' ') + ); + await page.click( + '[data-testid="challenge-notification-card"] [data-testid="challenge-notification-response-submit"]' + ); +}; + +const respondFromNotificationDetail = async ( + page: Page, + words: readonly string[] +): Promise => { + await waitForChallengeNotificationCard(page); + await page.click('[data-testid="challenge-notification-detail-link"]'); + await page.waitForFunction( + () => globalThis.location.pathname.startsWith('/notifications/'), + { timeout: 10_000 } + ); + try { + await page.waitForSelector( + 'main [data-testid="challenge-notification-response-input"] textarea', + { timeout: 45_000 } + ); + } catch (error) { + const visibleText = await page.evaluate( + () => globalThis.document.body.textContent?.slice(0, 4000) ?? '' + ); + throw new Error( + `Challenge notification detail did not render. Visible text: ${visibleText}`, + { cause: error } + ); + } + await setInputValue( + page, + 'main [data-testid="challenge-notification-response-input"] textarea', + words.join(' ') + ); + await page.click( + 'main [data-testid="challenge-notification-response-submit"]' + ); +}; + +const chromeArgs = + process.env.CI === 'true' + ? ['--no-sandbox', '--disable-setuid-sandbox'] + : []; + +const vite = await startViteIfNeeded(); +const browser = await puppeteer.launch({ + headless: 'new', + args: chromeArgs, +}); + +try { + const page = await browser.newPage(); + const browserPasscode = await connectBrowserAgent(page); + const browserRole = await roleFromPasscode( + browserPasscode, + 'ui-challenge-browser' + ); + const browserAlias = uniqueAlias('ui-challenge-browser'); + const browserAid = await createWitnessedIdentifier( + browserRole, + browserAlias + ); + const browserOobi = await addAgentEndRole(browserRole, browserAlias); + + const harness = await createRole('ui-challenge-harness'); + const harnessAlias = uniqueAlias('ui-challenge-harness'); + await createWitnessedIdentifier(harness, harnessAlias); + const harnessOobi = await addAgentEndRole(harness, harnessAlias); + await resolveOobi(harness, browserOobi, browserAlias); + + await resolveOobiInContacts(page, harnessOobi, harnessAlias); + await openContactDetail(page, harnessAlias); + await waitForText(page, '[data-testid="contact-detail"]', 'Unverified'); + + await page.click('[data-testid="challenge-generate-submit"]'); + const words = await generatedChallengeWords(page); + + await harness.client + .challenges() + .respond(harnessAlias, browserAid.prefix, words); + await waitForText( + page, + '[data-testid="contact-detail"]', + 'Verified', + 180_000 + ); + await page.waitForFunction( + () => + globalThis.document.querySelector( + '[data-testid="challenge-generated-words"]' + ) === null, + { timeout: 30_000 } + ); + + const detailWords = await generatedKeriaChallengeWords(harness); + const detailChallengeId = await sendChallengeRequest({ + challenger: harness, + challengerAlias: harnessAlias, + recipientAid: browserAid.prefix, + words: detailWords, + }); + await waitForChallengeRequestExchange(browserRole, detailChallengeId); + await respondFromNotificationDetail(page, detailWords); + await verifyChallengeResponse({ + challenger: harness, + responderAid: browserAid.prefix, + words: detailWords, + label: 'verifying detail challenge response', + }); + + const bellWords = await generatedKeriaChallengeWords(harness); + const bellChallengeId = await sendChallengeRequest({ + challenger: harness, + challengerAlias: harnessAlias, + recipientAid: browserAid.prefix, + words: bellWords, + }); + await waitForChallengeRequestExchange(browserRole, bellChallengeId); + await respondFromBellNotification(page, bellWords); + await verifyChallengeResponse({ + challenger: harness, + responderAid: browserAid.prefix, + words: bellWords, + label: 'verifying bell challenge response', + }); + + console.log( + JSON.stringify( + { + status: 'passed', + browserAlias, + harnessAlias, + wordCount: words.length, + bellWordCount: bellWords.length, + detailWordCount: detailWords.length, + }, + null, + 2 + ) + ); +} finally { + await browser.close(); + if (vite !== null) { + vite.kill('SIGTERM'); + } +} diff --git a/tests/responsive-smoke.mjs b/tests/responsive-smoke.mjs index 03dd4758..e6a69189 100644 --- a/tests/responsive-smoke.mjs +++ b/tests/responsive-smoke.mjs @@ -1,14 +1,25 @@ import { spawn } from 'node:child_process'; import puppeteer from 'puppeteer'; +import { + Algos, + SignifyClient, + Tier, + randomPasscode, + ready, +} from 'signify-ts'; /** - * Responsive browser smoke for the disconnected app shell. + * Responsive browser smoke for the app shell and connected identifier table. * - * This checks the mobile layout contract without requiring KERIA: the app - * starts near the top of the viewport, does not create page-level horizontal - * overflow, and keeps the connect dialog controls inside small viewports. + * This checks the mobile layout contract, then connects with a fixture + * identifier so compact table widths keep copy/rotate actions inside the + * viewport. */ const appUrl = process.env.RESPONSIVE_SMOKE_URL ?? 'http://127.0.0.1:5175'; +const keriaAdminUrl = + process.env.VITE_KERIA_ADMIN_URL ?? 'http://127.0.0.1:3901'; +const keriaBootUrl = + process.env.VITE_KERIA_BOOT_URL ?? 'http://127.0.0.1:3903'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -64,6 +75,77 @@ const startViteIfNeeded = async () => { const routeUrl = (path) => new URL(path, appUrl).toString(); +const responsiveAlias = () => + `responsive-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; + +const waitForOperation = async (client, operation, label) => { + const controller = new globalThis.AbortController(); + const timeout = globalThis.setTimeout(() => { + controller.abort(new Error(`${label} timed out`)); + }, 60000); + + try { + await client.operations().wait(operation, { + signal: controller.signal, + minSleep: 250, + maxSleep: 1000, + }); + } catch (error) { + throw new Error( + `${label} failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } finally { + globalThis.clearTimeout(timeout); + } +}; + +const connectClient = async (passcode) => { + await ready(); + const client = new SignifyClient( + keriaAdminUrl, + passcode, + Tier.low, + keriaBootUrl + ); + + try { + await client.connect(); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes('agent does not exist') + ) { + throw error; + } + + const response = await client.boot(); + if (!response.ok) { + throw new Error( + `KERIA boot failed: ${response.status} ${response.statusText}`, + { cause: error } + ); + } + await client.connect(); + } + + return client; +}; + +const createIdentifierFixture = async () => { + await ready(); + const passcode = randomPasscode(); + const client = await connectClient(passcode); + const alias = responsiveAlias(); + const result = await client.identifiers().create(alias, { + algo: Algos.randy, + }); + const operation = await result.op(); + await waitForOperation(client, operation, `creating ${alias}`); + + return { passcode, alias }; +}; + const assertNoHorizontalOverflow = async (page, label) => { const metrics = await page.evaluate(() => ({ innerWidth: globalThis.innerWidth, @@ -149,6 +231,139 @@ const assertElementsFitViewport = async (page, selectors, label) => { } }; +const assertVisibleControlFitsViewport = async (page, ariaLabel, label) => { + const failures = await page.evaluate((expectedLabel) => { + const viewportWidth = globalThis.innerWidth; + const controls = [...globalThis.document.querySelectorAll('button')] + .filter( + (button) => button.getAttribute('aria-label') === expectedLabel + ) + .filter((button) => { + const rect = button.getBoundingClientRect(); + const style = globalThis.getComputedStyle(button); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== 'none' && + style.visibility !== 'hidden' + ); + }); + + if (controls.length === 0) { + return [`No visible control for ${expectedLabel}`]; + } + + return controls.flatMap((control) => { + const rect = control.getBoundingClientRect(); + if (rect.left < -1 || rect.right > viewportWidth + 1) { + return [ + `${expectedLabel} overflows horizontally: left=${rect.left}, right=${rect.right}, viewport=${viewportWidth}`, + ]; + } + + return []; + }); + }, ariaLabel); + + if (failures.length > 0) { + throw new Error(`${label} control fit failed: ${failures.join('; ')}`); + } +}; + +const visibleIdentifierHeaders = async (page) => + page.$$eval('[data-testid="identifier-table"] thead th', (headers) => + headers + .filter((header) => { + const rect = header.getBoundingClientRect(); + const style = globalThis.getComputedStyle(header); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== 'none' && + style.visibility !== 'hidden' + ); + }) + .map((header) => header.textContent?.trim() ?? '') + ); + +const assertIdentifierHeaders = async ( + page, + { expected, omitted }, + label +) => { + const headers = await visibleIdentifierHeaders(page); + const missing = expected.filter((header) => !headers.includes(header)); + const unexpectedlyVisible = omitted.filter((header) => + headers.includes(header) + ); + + if (missing.length > 0 || unexpectedlyVisible.length > 0) { + throw new Error( + `${label} identifier headers mismatch: visible=${headers.join(', ')}, missing=${missing.join(', ')}, unexpectedlyVisible=${unexpectedlyVisible.join(', ')}` + ); + } +}; + +const setInputValue = async (page, selector, value) => { + await page.$eval( + selector, + (element, nextValue) => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + 'value' + ); + descriptor?.set?.call(element, nextValue); + element.dispatchEvent( + new globalThis.Event('input', { bubbles: true }) + ); + }, + value + ); +}; + +const connectBrowser = async (page, passcode) => { + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); + await page.click('[data-testid="connect-open"]'); + await page.waitForSelector('[data-testid="connect-dialog"]', { + timeout: 10000, + }); + await setInputValue(page, '#outlined-password-input', passcode); + await page.click('[data-testid="connect-submit"]'); + await page.waitForSelector('[data-testid="connect-dialog"]', { + hidden: true, + timeout: 30000, + }); + await page.waitForSelector('[data-testid="app-loading-overlay"]', { + hidden: true, + timeout: 10000, + }); + await page.waitForSelector('[data-testid="known-components"]', { + timeout: 30000, + }); +}; + +const navigateToIdentifiers = async (page) => { + await page.click('[data-testid="nav-open"]'); + await page.waitForSelector('[data-testid="nav-identifiers"]', { + timeout: 10000, + }); + await page.click('[data-testid="nav-identifiers"]'); + try { + await page.waitForSelector('[data-testid="identifier-table"]', { + timeout: 10000, + }); + } catch (error) { + const debug = await page.evaluate(() => ({ + url: globalThis.location.href, + text: globalThis.document.body.innerText.slice(0, 1000), + })); + throw new Error( + `Identifiers route did not render table at ${debug.url}: ${debug.text}`, + { cause: error } + ); + } +}; + const assertOverlayFitsViewport = async (page, label) => { await page.click('[data-testid="generate-passcode"]'); await page.waitForFunction( @@ -185,6 +400,27 @@ const viewports = [ { label: 'mobile', width: 390, height: 844 }, ]; +const identifierViewports = [ + { + label: 'compact table', + width: 640, + height: 800, + headers: { + expected: ['Name', 'AID', 'Actions'], + omitted: ['Type', 'KIDX', 'PIDX', 'OOBI'], + }, + }, + { + label: 'medium table', + width: 960, + height: 800, + headers: { + expected: ['Name', 'AID', 'Type', 'Actions'], + omitted: ['KIDX', 'PIDX', 'OOBI'], + }, + }, +]; + const vite = await startViteIfNeeded(); const browser = await puppeteer.launch({ headless: 'new', @@ -230,11 +466,64 @@ try { await page.click('[data-testid="connect-close"]'); } + const fixture = await createIdentifierFixture(); + await page.setViewport({ + width: identifierViewports[0].width, + height: identifierViewports[0].height, + isMobile: false, + deviceScaleFactor: 1, + }); + await connectBrowser(page, fixture.passcode); + + let identifiersRouteLoaded = false; + for (const viewport of identifierViewports) { + await page.setViewport({ + width: viewport.width, + height: viewport.height, + isMobile: false, + deviceScaleFactor: 1, + }); + if (!identifiersRouteLoaded) { + await navigateToIdentifiers(page); + identifiersRouteLoaded = true; + } else { + await page.waitForSelector('[data-testid="identifier-table"]', { + timeout: 10000, + }); + } + await page.waitForFunction( + (alias) => + [...globalThis.document.querySelectorAll('button')].some( + (button) => + button.getAttribute('aria-label') === + `Rotate identifier ${alias}` + ), + { timeout: 10000 }, + fixture.alias + ); + + await assertNoHorizontalOverflow(page, viewport.label); + await assertIdentifierHeaders(page, viewport.headers, viewport.label); + await assertVisibleControlFitsViewport( + page, + `Rotate identifier ${fixture.alias}`, + viewport.label + ); + await assertVisibleControlFitsViewport( + page, + `Copy agent OOBI for ${fixture.alias}`, + viewport.label + ); + } + console.log( JSON.stringify( { status: 'passed', viewports, + identifierViewports: identifierViewports.map( + ({ label, width, height }) => ({ label, width, height }) + ), }, null, 2 diff --git a/tests/unit/challengeRequestFormHelpers.test.ts b/tests/unit/challengeRequestFormHelpers.test.ts new file mode 100644 index 00000000..969bfde2 --- /dev/null +++ b/tests/unit/challengeRequestFormHelpers.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { defaultChallengeResponseIdentifierName } from '../../src/features/notifications/challengeRequestFormHelpers'; +import type { IdentifierSummary } from '../../src/features/identifiers/identifierTypes'; + +const identifier = (name: string, prefix: string): IdentifierSummary => + ({ name, prefix }) as IdentifierSummary; + +describe('challenge request form helpers', () => { + it('preselects the local identifier that matches the challenge request recipient AID', () => { + expect( + defaultChallengeResponseIdentifierName( + { recipientAid: 'Etarget' }, + [identifier('first', 'Efirst'), identifier('target', 'Etarget')] + ) + ).toBe('target'); + }); + + it('falls back to the first identifier when the recipient AID is unavailable', () => { + expect( + defaultChallengeResponseIdentifierName({ recipientAid: null }, [ + identifier('first', 'Efirst'), + identifier('target', 'Etarget'), + ]) + ).toBe('first'); + }); + + it('falls back to the first identifier when the recipient AID is not local', () => { + expect( + defaultChallengeResponseIdentifierName( + { recipientAid: 'Eunknown' }, + [identifier('first', 'Efirst'), identifier('target', 'Etarget')] + ) + ).toBe('first'); + }); +}); diff --git a/tests/unit/challengeWords.test.ts b/tests/unit/challengeWords.test.ts new file mode 100644 index 00000000..ed5084e5 --- /dev/null +++ b/tests/unit/challengeWords.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + challengeWordsFingerprint, + parseChallengeWords, + validateChallengeWords, +} from '../../src/features/contacts/challengeWords'; + +describe('challenge word helpers', () => { + it('normalizes pasted challenge words', () => { + expect(parseChallengeWords(' Able\nBaker\tCharlie ')).toEqual([ + 'able', + 'baker', + 'charlie', + ]); + }); + + it('validates supported challenge word counts', () => { + expect( + validateChallengeWords( + Array.from({ length: 12 }, (_, i) => `w${i}`) + ) + ).toBeNull(); + expect( + validateChallengeWords( + Array.from({ length: 24 }, (_, i) => `w${i}`) + ) + ).toBeNull(); + expect(validateChallengeWords([])).toBe( + 'Challenge words are required.' + ); + expect(validateChallengeWords(['one', 'two'])).toBe( + 'Challenge must contain 12 or 24 words.' + ); + }); + + it('fingerprints normalized words without returning the phrase', () => { + const words = ['one', 'two', 'three']; + const fingerprint = challengeWordsFingerprint(words); + + expect(fingerprint).toMatch(/^[0-9a-f]{8}$/u); + expect(fingerprint).toBe(challengeWordsFingerprint([...words])); + expect(fingerprint).not.toContain(words.join(' ')); + }); +}); diff --git a/tests/unit/challengesService.test.ts b/tests/unit/challengesService.test.ts new file mode 100644 index 00000000..5aa6276e --- /dev/null +++ b/tests/unit/challengesService.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { responseSaidFromChallengeOperation } from '../../src/services/challenges.service'; + +describe('challenge service helpers', () => { + it('extracts the response SAID from a completed challenge operation', () => { + expect( + responseSaidFromChallengeOperation({ + response: { + exn: { + d: 'EresponseSaid', + }, + }, + }) + ).toBe('EresponseSaid'); + }); + + it('rejects completed challenge operations without an EXN SAID', () => { + expect(() => + responseSaidFromChallengeOperation({ + response: { + exn: {}, + }, + }) + ).toThrow('Challenge response EXN did not include a SAID.'); + }); +}); diff --git a/tests/unit/contactHelpers.test.ts b/tests/unit/contactHelpers.test.ts index 098355a3..39c2b134 100644 --- a/tests/unit/contactHelpers.test.ts +++ b/tests/unit/contactHelpers.test.ts @@ -279,13 +279,18 @@ describe('contact helpers', () => { ).toEqual([ { id: 'Econtact:Ssaid', + source: 'keria', direction: 'received', role: 'Alice', counterpartyAid: 'Econtact', words: ['one', 'two'], + wordsHash: '42c28241', + responseSaid: 'Ssaid', authenticated: true, status: 'verified', result: 'Ssaid', + error: null, + verifiedAt: '2026-04-21T00:00:00.000Z', updatedAt: '2026-04-21T00:00:00.000Z', }, ]); diff --git a/tests/unit/notificationsService.test.ts b/tests/unit/notificationsService.test.ts new file mode 100644 index 00000000..4e6ada55 --- /dev/null +++ b/tests/unit/notificationsService.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SignifyClient } from 'signify-ts'; +import { createAppRuntime } from '../../src/app/runtime'; +import { + CHALLENGE_REQUEST_ROUTE, + CHALLENGE_TOPIC, +} from '../../src/services/challenges.service'; +import { + challengeRequestFromExchange, + listNotificationsService, + notificationRecordsFromResponse, +} from '../../src/services/notifications.service'; +import type { ContactRecord } from '../../src/state/contacts.slice'; + +const loadedAt = '2026-04-22T00:00:00.000Z'; + +const contact = { + id: 'Esender', + alias: 'Alice', + aid: 'Esender', + oobi: null, + endpoints: [], + wellKnowns: [], + componentTags: [], + challengeCount: 0, + authenticatedChallengeCount: 0, + resolutionStatus: 'resolved', + error: null, + updatedAt: loadedAt, +} satisfies ContactRecord; + +const challengeExchange = { + exn: { + d: 'Eexn', + i: 'Esender', + rp: 'Erecipient', + dt: loadedAt, + r: CHALLENGE_REQUEST_ROUTE, + a: { + i: 'Erecipient', + challengeId: 'challenge-1', + wordsHash: 'hash-one', + strength: 128, + }, + }, +}; + +const makeClient = ({ + rawNotifications, + exchange = challengeExchange, + queryExchanges = [], +}: { + rawNotifications: unknown; + exchange?: unknown; + queryExchanges?: 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, + fetch: vi.fn(async () => ({ + json: async () => queryExchanges, + })), + } as unknown as SignifyClient; + + return { client, notifications, exchanges }; +}; + +const runListNotifications = async ( + client: SignifyClient, + contacts: readonly ContactRecord[] = [], + localAids: readonly string[] = [] +) => { + const runtime = createAppRuntime({ storage: null }); + try { + return await runtime.runWorkflow( + () => listNotificationsService({ client, contacts, localAids }), + { scope: 'app', track: false } + ); + } finally { + await runtime.destroy(); + } +}; + +describe('notification service helpers', () => { + it('normalizes Signify notification responses that wrap notes', () => { + expect( + notificationRecordsFromResponse( + { + notes: [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { + r: CHALLENGE_REQUEST_ROUTE, + d: 'Eexn', + m: 'message', + }, + }, + ], + }, + loadedAt + ) + ).toEqual([ + expect.objectContaining({ + id: 'note-1', + read: false, + route: CHALLENGE_REQUEST_ROUTE, + anchorSaid: 'Eexn', + status: 'unread', + message: 'message', + }), + ]); + }); + + it('parses challenge request EXNs without raw challenge words', () => { + const [notification] = notificationRecordsFromResponse( + [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { r: CHALLENGE_REQUEST_ROUTE, d: 'Eexn' }, + }, + ], + loadedAt + ); + + expect( + challengeRequestFromExchange({ + notification, + exchange: challengeExchange, + senderAlias: 'Alice', + status: 'actionable', + loadedAt, + }) + ).toEqual({ + notificationId: 'note-1', + exnSaid: 'Eexn', + senderAid: 'Esender', + senderAlias: 'Alice', + recipientAid: 'Erecipient', + challengeId: 'challenge-1', + wordsHash: 'hash-one', + strength: 128, + createdAt: loadedAt, + status: 'actionable', + }); + expect(JSON.stringify(challengeExchange)).not.toContain('"words":'); + expect(CHALLENGE_TOPIC).toBe('challenge'); + }); + + it('hydrates known challenge request senders as actionable', async () => { + const { client, exchanges } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { r: '/exn', d: 'Eexn' }, + }, + ], + }, + }); + + const snapshot = await runListNotifications(client, [contact]); + + expect(exchanges.get).toHaveBeenCalledWith('Eexn'); + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'note-1', + status: 'unread', + challengeRequest: expect.objectContaining({ + senderAlias: 'Alice', + status: 'actionable', + wordsHash: 'hash-one', + }), + }), + ]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + + it('hydrates challenge requests from exchange query when no KERIA note exists', async () => { + const { client, exchanges } = makeClient({ + rawNotifications: { notes: [] }, + queryExchanges: [challengeExchange], + }); + + const snapshot = await runListNotifications(client, [contact]); + + expect(exchanges.get).not.toHaveBeenCalled(); + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'challenge-request:Eexn', + route: CHALLENGE_REQUEST_ROUTE, + status: 'unread', + challengeRequest: expect.objectContaining({ + notificationId: 'challenge-request:Eexn', + senderAlias: 'Alice', + status: 'actionable', + }), + }), + ]); + }); + + it('ignores locally-authored challenge request exchanges', async () => { + const outboundExchange = { + exn: { + d: 'Eoutbound', + i: 'Erecipient', + rp: 'Esender', + dt: loadedAt, + r: CHALLENGE_REQUEST_ROUTE, + a: { + i: 'Esender', + challengeId: 'challenge-outbound', + wordsHash: 'hash-outbound', + strength: 128, + }, + }, + }; + const { client } = makeClient({ + rawNotifications: { notes: [] }, + queryExchanges: [outboundExchange], + }); + + const snapshot = await runListNotifications( + client, + [contact], + ['Erecipient'] + ); + + expect(snapshot.notifications).toEqual([]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + + it('does not warn for outbound challenge requests before local identifiers are loaded', async () => { + const outboundExchange = { + exn: { + d: 'Eoutbound', + i: 'Erecipient', + rp: 'Esender', + dt: loadedAt, + r: CHALLENGE_REQUEST_ROUTE, + a: { + i: 'Esender', + challengeId: 'challenge-outbound', + wordsHash: 'hash-outbound', + strength: 128, + }, + }, + }; + const { client } = makeClient({ + rawNotifications: { notes: [] }, + queryExchanges: [outboundExchange], + }); + + const snapshot = await runListNotifications(client, [contact]); + + expect(snapshot.notifications).toEqual([]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + + it('marks unknown challenge request senders read and reports one notice', async () => { + const { client, notifications } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { r: '/exn', d: 'Eexn' }, + }, + ], + }, + }); + + const snapshot = await runListNotifications(client, []); + + expect(notifications.mark).toHaveBeenCalledWith('note-1'); + expect(snapshot.notifications).toEqual([ + expect.objectContaining({ + id: 'note-1', + read: true, + status: 'processed', + challengeRequest: expect.objectContaining({ + senderAid: 'Esender', + status: 'senderUnknown', + }), + }), + ]); + expect(snapshot.unknownChallengeSenders).toEqual([ + { + notificationId: 'note-1', + exnSaid: 'Eexn', + senderAid: 'Esender', + createdAt: loadedAt, + }, + ]); + }); +}); diff --git a/tests/unit/routeData.test.ts b/tests/unit/routeData.test.ts index 14f50e5b..6442c9e7 100644 --- a/tests/unit/routeData.test.ts +++ b/tests/unit/routeData.test.ts @@ -52,7 +52,7 @@ const makeRuntime = ( ): RouteDataRuntime => ({ getClient: vi.fn(() => ({ url: 'http://keria.example' })), getState: vi.fn(() => summary), - connect: vi.fn(async () => ({ state: summary } as ConnectedSignifyClient)), + connect: vi.fn(async () => ({ state: summary }) as ConnectedSignifyClient), generatePasscode: vi.fn(async () => '0123456789abcdefghijk'), refreshState: vi.fn(async () => summary), listIdentifiers: vi.fn(async () => [ @@ -91,6 +91,32 @@ const makeRuntime = ( requestId: 'update-contact-request-1', operationRoute: '/operations/update-contact-request-1', })), + generateContactChallenge: vi.fn(async () => ({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: Array.from({ length: 12 }, (_, index) => `word${index}`), + wordsHash: 'hash-one', + strength: 128, + generatedAt: '2026-04-21T00:00:00.000Z', + })), + startRespondToChallenge: vi.fn(() => ({ + status: 'accepted', + requestId: 'respond-challenge-request-1', + operationRoute: '/operations/respond-challenge-request-1', + })), + startSendChallengeRequest: vi.fn(() => ({ + status: 'accepted', + requestId: 'send-challenge-request-1', + operationRoute: '/operations/send-challenge-request-1', + })), + startVerifyContactChallenge: vi.fn(() => ({ + status: 'accepted', + requestId: 'verify-challenge-request-1', + operationRoute: '/operations/verify-challenge-request-1', + })), ...overrides, }); @@ -391,4 +417,167 @@ describe('route actions', () => { expect.objectContaining({ requestId: 'oobi-request-1' }) ); }); + + it('generates contact challenges and starts verification', async () => { + const runtime = makeRuntime(); + + await expect( + contactsAction( + runtime, + makeRequest('/contacts/Econtact', { + intent: 'generateChallenge', + requestId: 'verify-challenge-request-1', + contactId: 'Econtact', + contactAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + }) + ) + ).resolves.toEqual({ + intent: 'generateChallenge', + ok: true, + message: + 'Generated challenge, sent request, and started verification', + requestId: 'verify-challenge-request-1', + operationRoute: '/operations/verify-challenge-request-1', + challenge: expect.objectContaining({ + challengeId: 'challenge-1', + words: expect.arrayContaining(['word0']), + }), + }); + expect(runtime.generateContactChallenge).toHaveBeenCalledWith( + { + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(runtime.startSendChallengeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + localIdentifier: 'alice', + wordsHash: 'hash-one', + strength: 128, + }), + expect.objectContaining({ + requestId: 'verify-challenge-request-1:challenge-request', + }) + ); + expect(runtime.startVerifyContactChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + words: expect.arrayContaining(['word0']), + }), + expect.objectContaining({ requestId: 'verify-challenge-request-1' }) + ); + }); + + it('starts challenge responses through the contacts action', async () => { + const runtime = makeRuntime(); + const words = Array.from({ length: 12 }, (_, index) => `word${index}`); + + await expect( + contactsAction( + runtime, + makeRequest('/contacts/Econtact', { + intent: 'respondChallenge', + requestId: 'respond-challenge-request-1', + contactId: 'Econtact', + contactAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: words.join(' '), + }) + ) + ).resolves.toEqual({ + intent: 'respondChallenge', + ok: true, + message: 'Sending challenge response to Econtact', + requestId: 'respond-challenge-request-1', + operationRoute: '/operations/respond-challenge-request-1', + }); + expect(runtime.startRespondToChallenge).toHaveBeenCalledWith( + { + challengeId: 'respond-challenge-request-1', + notificationId: undefined, + wordsHash: null, + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words, + }, + expect.objectContaining({ + requestId: 'respond-challenge-request-1', + }) + ); + }); + + it('passes challenge notification metadata through responses', async () => { + const runtime = makeRuntime(); + const words = Array.from({ length: 12 }, (_, index) => `word${index}`); + + await expect( + contactsAction( + runtime, + makeRequest('/notifications/note-1', { + intent: 'respondChallenge', + requestId: 'respond-challenge-request-3', + notificationId: 'note-1', + challengeId: 'challenge-1', + wordsHash: 'hash-one', + contactId: 'Econtact', + contactAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: words.join(' '), + }) + ) + ).resolves.toMatchObject({ + intent: 'respondChallenge', + ok: true, + }); + expect(runtime.startRespondToChallenge).toHaveBeenCalledWith( + { + challengeId: 'challenge-1', + notificationId: 'note-1', + wordsHash: 'hash-one', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words, + }, + expect.objectContaining({ + requestId: 'respond-challenge-request-3', + }) + ); + }); + + it('rejects malformed challenge word submissions', async () => { + const runtime = makeRuntime(); + + await expect( + contactsAction( + runtime, + makeRequest('/contacts/Econtact', { + intent: 'respondChallenge', + requestId: 'respond-challenge-request-2', + contactId: 'Econtact', + localIdentifier: 'alice', + words: 'one two', + }) + ) + ).resolves.toEqual({ + intent: 'respondChallenge', + ok: false, + message: 'Challenge must contain 12 or 24 words.', + requestId: 'respond-challenge-request-2', + }); + expect(runtime.startRespondToChallenge).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 352bb9f0..7310c4b3 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -27,7 +27,10 @@ import { sessionDisconnected, } from '../../src/state/session.slice'; import { notificationRecorded } from '../../src/state/notifications.slice'; -import { challengesLoaded } from '../../src/state/challenges.slice'; +import { + challengeRecorded, + challengesLoaded, +} from '../../src/state/challenges.slice'; import { contactInventoryLoaded, generatedOobiRecorded, @@ -238,20 +241,93 @@ describe('RTK state foundation', () => { expect(selectContactById('Econtact')(store.getState())?.alias).toBe( 'Wan' ); - expect(selectKnownComponents(store.getState()).map((item) => item.role)).toEqual([ - 'agent', - 'witness', - ]); + expect( + selectKnownComponents(store.getState()).map((item) => item.role) + ).toEqual(['agent', 'witness']); expect(selectChallenges(store.getState())).toHaveLength(1); - expect(selectChallengesForContact('Econtact')(store.getState())).toEqual([ - expect.objectContaining({ id: 'challenge-1' }), - ]); + expect( + selectChallengesForContact('Econtact')(store.getState()) + ).toEqual([expect.objectContaining({ id: 'challenge-1' })]); expect(selectDashboardCounts(store.getState())).toMatchObject({ contacts: 1, knownComponents: 2, challenges: 1, }); - expect(store.getState().contacts.generatedOobiIds).toEqual(['alice:agent']); + expect(store.getState().contacts.generatedOobiIds).toEqual([ + 'alice:agent', + ]); + }); + + it('preserves workflow challenge records across inventory refreshes', () => { + const store = createAppStore(); + + store.dispatch( + challengeRecorded({ + id: 'workflow-challenge-1', + source: 'workflow', + direction: 'issued', + role: 'challenger', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: Array.from({ length: 12 }, (_, index) => `word${index}`), + wordsHash: 'hash-one', + responseSaid: null, + authenticated: false, + status: 'pending', + result: null, + error: null, + generatedAt: '2026-04-21T00:00:01.000Z', + sentAt: null, + verifiedAt: null, + updatedAt: '2026-04-21T00:00:01.000Z', + }) + ); + store.dispatch( + challengesLoaded({ + loadedAt: '2026-04-21T00:00:02.000Z', + challenges: [], + }) + ); + + expect(selectChallenges(store.getState())).toEqual([ + expect.objectContaining({ id: 'workflow-challenge-1' }), + ]); + + store.dispatch( + challengesLoaded({ + loadedAt: '2026-04-21T00:00:03.000Z', + challenges: [ + { + id: 'Econtact:Eresponse', + source: 'keria', + direction: 'received', + role: 'Wan', + counterpartyAid: 'Econtact', + words: Array.from( + { length: 12 }, + (_, index) => `word${index}` + ), + wordsHash: 'hash-one', + responseSaid: 'Eresponse', + authenticated: true, + status: 'verified', + result: 'Eresponse', + error: null, + verifiedAt: '2026-04-21T00:00:03.000Z', + updatedAt: '2026-04-21T00:00:03.000Z', + }, + ], + }) + ); + + expect(selectChallenges(store.getState())).toEqual([ + expect.objectContaining({ + id: 'Econtact:Eresponse', + status: 'verified', + }), + ]); }); it('clears session-scoped inventory when a new connection starts', () => { @@ -411,7 +487,9 @@ describe('RTK state foundation', () => { ); expect(selectUnreadAppNotifications(store.getState())).toHaveLength(0); - expect(store.getState().appNotifications.byId['app-n-new']).toMatchObject({ + expect( + store.getState().appNotifications.byId['app-n-new'] + ).toMatchObject({ status: 'read', readAt: '2026-04-21T00:00:02.000Z', }); From eec61937055e3300649b7f7dd07b863710f3064f Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Wed, 22 Apr 2026 02:06:59 -0600 Subject: [PATCH 3/3] allow tombstoning exns and clean up challenge process Signed-off-by: Kent Bull --- src/app/NavigationDrawer.tsx | 123 +++++++++- src/app/RootLayout.tsx | 12 +- src/app/TopBar.tsx | 122 ++++++++-- src/app/routeData.ts | 44 ++++ src/app/runtime.ts | 78 +++++- src/features/contacts/ContactDetailView.tsx | 117 +++++++++ .../notifications/NotificationDetailView.tsx | 101 +++++++- src/services/notifications.service.ts | 42 +++- src/state/challenges.slice.ts | 73 +++++- src/state/credentials.slice.ts | 17 +- src/state/exchangeTombstones.slice.ts | 64 +++++ src/state/persistence.ts | 109 ++++++++- src/state/registry.slice.ts | 17 +- src/state/roles.slice.ts | 17 +- src/state/schema.slice.ts | 17 +- src/state/selectors.ts | 57 ++++- src/state/store.ts | 2 + src/workflows/challenges.op.ts | 29 +++ src/workflows/contacts.op.ts | 5 + src/workflows/notifications.op.ts | 63 +++++ tests/contact-challenge-smoke.ts | 47 +++- tests/unit/notificationsService.test.ts | 81 ++++++- tests/unit/persistence.test.ts | 115 ++++++++- tests/unit/routeData.test.ts | 36 +++ tests/unit/runtimeWorkflow.test.ts | 228 +++++++++++++++++- tests/unit/state.test.ts | 162 +++++++++++++ 26 files changed, 1705 insertions(+), 73 deletions(-) create mode 100644 src/state/exchangeTombstones.slice.ts create mode 100644 src/workflows/notifications.op.ts diff --git a/src/app/NavigationDrawer.tsx b/src/app/NavigationDrawer.tsx index 864e86d7..2bb82f38 100644 --- a/src/app/NavigationDrawer.tsx +++ b/src/app/NavigationDrawer.tsx @@ -1,6 +1,7 @@ import type { KeyboardEvent } from 'react'; import { Box, + Divider, Drawer, List, ListItemButton, @@ -11,9 +12,11 @@ import { } from '@mui/material'; import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'; import CreditCardIcon from '@mui/icons-material/CreditCard'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import ListAltIcon from '@mui/icons-material/ListAlt'; import NotificationsIcon from '@mui/icons-material/Notifications'; import TerminalIcon from '@mui/icons-material/Terminal'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { useLocation, useNavigate } from 'react-router-dom'; import { APP_NAV_ITEMS } from './router'; import type { AppRouteId } from './router'; @@ -26,6 +29,13 @@ export interface NavigationDrawerProps { open: boolean; /** Close the drawer after backdrop, keyboard, or item selection events. */ onClose: () => void; + /** Clear all persisted local app state for every controller bucket. */ + onClearLocalState: () => void; +} + +export interface DesktopNavigationRailProps { + /** Clear all persisted local app state for every controller bucket. */ + onClearLocalState: () => void; } const routeIcon = (routeId: AppRouteId) => { @@ -67,6 +77,57 @@ const navButtonSx = (active: boolean) => ({ }, }); +const LOCAL_STATE_CLEAR_CONFIRMATION = + 'Clear all saved local app state for every identifier? This removes operation history, app notifications, dismissed exchange records, and saved challenge words stored in this browser.'; + +const ClearLocalStateIcon = () => ( + + + + +); + +const clearLocalStateButtonSx = { + ...navButtonSx(false), + color: 'error.main', + borderColor: 'transparent', + '&:hover': { + borderColor: 'error.main', + bgcolor: 'rgba(255, 75, 90, 0.08)', + color: 'error.main', + }, + '.MuiListItemIcon-root': { + minWidth: 38, + }, +}; + +const confirmAndClearLocalState = ( + onClearLocalState: () => void, + onClose?: () => void +) => { + const confirmed = globalThis.confirm(LOCAL_STATE_CLEAR_CONFIRMATION); + if (!confirmed) { + return; + } + + onClearLocalState(); + onClose?.(); +}; + /** * Drawer generated from data-router route handles. * @@ -74,7 +135,11 @@ const navButtonSx = (active: boolean) => ({ * drawer item should mean updating the route descriptor, not hardcoding a * second navigation list here. */ -export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => { +export const NavigationDrawer = ({ + open, + onClose, + onClearLocalState, +}: NavigationDrawerProps) => { const navigate = useNavigate(); const location = useLocation(); @@ -99,7 +164,15 @@ export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => { }, }} > -
+ {APP_NAV_ITEMS.map((view) => ( { ))} -
+ + + + confirmAndClearLocalState(onClearLocalState, onClose) + } + data-testid="clear-local-state" + sx={clearLocalStateButtonSx} + > + + + + ); }; -export const DesktopNavigationRail = () => { +export const DesktopNavigationRail = ({ + onClearLocalState, +}: DesktopNavigationRailProps) => { const navigate = useNavigate(); const location = useLocation(); @@ -187,6 +277,31 @@ export const DesktopNavigationRail = () => { ); })} + + + + confirmAndClearLocalState(onClearLocalState)} + data-testid="rail-clear-local-state" + sx={{ + ...clearLocalStateButtonSx, + mx: 0, + minHeight: 48, + }} + > + + + Clear local state + + } + /> + + ); }; diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 1fad1f1c..299af509 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Box } from '@mui/material'; import { Outlet, useFetchers, useNavigation } from 'react-router-dom'; import type { AppRuntime } from './runtime'; -import { useAppSession } from './runtimeHooks'; +import { useAppRuntime, useAppSession } from './runtimeHooks'; import { AppRuntimeProvider } from './runtimeContext'; import { ConnectDialog } from './ConnectDialog'; import { derivePendingState } from './pendingState'; @@ -36,6 +36,7 @@ const RootLayoutContent = () => { const [drawerOpen, setDrawerOpen] = useState(false); const navigation = useNavigation(); const fetchers = useFetchers(); + const runtime = useAppRuntime(); const { connection } = useAppSession(); const activeOperations = useAppSelector(selectActiveOperations); const appNotifications = useAppSelector(selectAppNotifications); @@ -74,8 +75,15 @@ const RootLayoutContent = () => { setDrawerOpen(false)} + onClearLocalState={() => { + runtime.clearAllLocalState(); + }} + /> + { + runtime.clearAllLocalState(); + }} /> - (null); const [notificationsAnchor, setNotificationsAnchor] = useState(null); + const dismissFetcher = useFetcher(); const dispatch = useAppDispatch(); const operationsOpen = operationsAnchor !== null; const notificationsOpen = notificationsAnchor !== null; @@ -110,6 +114,19 @@ export const TopBar = ({ setNotificationsAnchor(event.currentTarget); }; + const dismissChallengeRequest = (request: ChallengeRequestNotification) => { + const formData = new FormData(); + formData.set('intent', 'dismissExchangeNotification'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', request.notificationId); + formData.set('exnSaid', request.exnSaid); + formData.set('route', '/challenge/request'); + dismissFetcher.submit(formData, { + method: 'post', + action: '/notifications', + }); + }; + return ( - + - {request.senderAlias} - {formatTimestamp( - request.createdAt - ) === null - ? '' - : ` - ${formatTimestamp( - request.createdAt - )}`} + From {request.senderAlias} ( + {abbreviateMiddle( + request.senderAid, + 28 + )} + ) + {formatTimestamp( + request.createdAt + ) !== null && ( + + {formatTimestamp( + request.createdAt + )} + + )} - + + + + + dismissChallengeRequest( + request + ) + } + > + + + + + ; } /** @@ -302,6 +310,7 @@ const contactIntentFromString = ( value === 'generateChallenge' || value === 'respondChallenge' || value === 'verifyChallenge' || + value === 'dismissExchangeNotification' || value === 'delete' || value === 'updateAlias' ? value @@ -929,6 +938,41 @@ export const contactsAction = async ( }; } + if (intent === 'dismissExchangeNotification') { + const notificationId = formString( + formData, + 'notificationId' + ).trim(); + const exnSaid = formString(formData, 'exnSaid').trim(); + const route = formString(formData, 'route').trim(); + if ( + notificationId.length === 0 || + exnSaid.length === 0 || + route.length === 0 + ) { + return { + intent, + ok: false, + message: + 'Notification id, EXN SAID, and route are required.', + requestId, + }; + } + + await runtime.dismissExchangeNotification( + { notificationId, exnSaid, route }, + { requestId: requestId || undefined, signal: request.signal } + ); + + return { + intent, + ok: true, + message: 'Exchange notification dismissed.', + requestId: requestId || '', + operationRoute: '/notifications', + }; + } + if (intent === 'delete') { const contactId = formString(formData, 'contactId').trim(); if (contactId.length === 0) { diff --git a/src/app/runtime.ts b/src/app/runtime.ts index 253b8016..6c05114d 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -18,11 +18,14 @@ import { type SignifyStateSummary, } from '../signify/client'; import { + appNotificationsRehydrated, appNotificationRecorded, type AppNotificationLink, type AppNotificationRecord, type AppNotificationSeverity, } from '../state/appNotifications.slice'; +import { storedChallengeWordsRehydrated } from '../state/challenges.slice'; +import { exchangeTombstonesRehydrated } from '../state/exchangeTombstones.slice'; import { cancelRunningOperations, type OperationKind, @@ -30,18 +33,26 @@ import { operationCanceled, operationFailed, operationPayloadDetailsRecorded, + operationsRehydrated, operationResultLinked, operationStarted, operationSucceeded, } from '../state/operations.slice'; import type { PayloadDetailRecord } from '../state/payloadDetails'; import { + clearAllPersistedAppStates, flushPersistedAppState, installAppStatePersistence, rehydratePersistedAppState, type AppStateStorage, } from '../state/persistence'; -import { sessionDisconnected } from '../state/session.slice'; +import { + sessionConnected, + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, + sessionStateRefreshed, +} from '../state/session.slice'; import { appStore, type AppStore } from '../state/store'; import { createIdentifierOp, @@ -77,6 +88,10 @@ import { getSignifyStateOp, randomPasscodeOp, } from '../workflows/signify.op'; +import { + dismissExchangeNotificationOp, + type DismissExchangeNotificationInput, +} from '../workflows/notifications.op'; /** * Complete connection-state model for the app runtime. @@ -423,6 +438,7 @@ export class AppRuntime { config: SignifyClientConfig, options: WorkflowRunOptions = {} ): Promise => { + this.store.dispatch(sessionConnecting()); this.setConnection({ status: 'connecting', client: null, @@ -450,10 +466,19 @@ export class AppRuntime { error: null, booted: connected.booted, }); + this.store.dispatch( + sessionConnected({ + booted: connected.booted, + controllerAid: connected.state.controllerPre, + agentAid: connected.state.agentPre, + connectedAt: new Date().toISOString(), + }) + ); this.startLiveSync(); return connected; } catch (error) { const normalized = toError(error); + this.store.dispatch(sessionConnectionFailed(normalized.message)); this.setConnection({ status: 'error', client: null, @@ -518,9 +543,50 @@ export class AppRuntime { ...connection, state, }); + this.store.dispatch( + sessionStateRefreshed({ + controllerAid: state.controllerPre, + agentAid: state.agentPre, + }) + ); return state; }; + /** + * Clear all browser-persisted app state buckets and the current in-memory + * projections that are backed by those buckets. + */ + clearAllLocalState = (): number => { + const previousControllerAid = this.currentControllerAid; + this.currentControllerAid = null; + try { + this.store.dispatch( + operationsRehydrated({ + records: [], + interruptedAt: new Date().toISOString(), + }) + ); + this.store.dispatch( + appNotificationsRehydrated({ + records: [], + }) + ); + this.store.dispatch( + exchangeTombstonesRehydrated({ + records: [], + }) + ); + this.store.dispatch( + storedChallengeWordsRehydrated({ + records: [], + }) + ); + return clearAllPersistedAppStates(this.storage); + } finally { + this.currentControllerAid = previousControllerAid; + } + }; + /** * List identifiers through the connected Signify client and normalize the * response shape for route loader consumers. @@ -899,6 +965,16 @@ export class AppRuntime { }, }); + dismissExchangeNotification = async ( + input: DismissExchangeNotificationInput, + options: Pick = {} + ): Promise => + this.runWorkflow(() => dismissExchangeNotificationOp(input), { + ...options, + kind: 'workflow', + track: false, + }); + /** * Start a non-blocking workflow and return accepted/conflict metadata. * diff --git a/src/features/contacts/ContactDetailView.tsx b/src/features/contacts/ContactDetailView.tsx index e53d78ad..04201bad 100644 --- a/src/features/contacts/ContactDetailView.tsx +++ b/src/features/contacts/ContactDetailView.tsx @@ -48,6 +48,7 @@ import { selectChallengesForContact, selectContactById, selectIdentifiers, + selectStoredChallengeWordsForContact, } from '../../state/selectors'; import { contactChallengeStatus, @@ -84,6 +85,9 @@ export const ContactDetailView = () => { const responseFetcher = useFetcher(); const contact = useAppSelector(selectContactById(contactId)); const challenges = useAppSelector(selectChallengesForContact(contactId)); + const storedChallengeWords = useAppSelector( + selectStoredChallengeWordsForContact(contactId) + ); const identifiers = useAppSelector(selectIdentifiers); const [aliasDraft, setAliasDraft] = useState({ contactId, @@ -156,6 +160,30 @@ export const ContactDetailView = () => { challengeFetcher.submit(formData, { method: 'post' }); }; + const submitRetryVerifyChallenge = (challenge: { + challengeId: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: readonly string[]; + wordsHash: string; + generatedAt: string; + }) => { + const formData = new FormData(); + formData.set('intent', 'verifyChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('challengeId', challenge.challengeId); + formData.set('contactId', challenge.counterpartyAid); + formData.set('contactAlias', challenge.counterpartyAlias ?? ''); + formData.set('localIdentifier', challenge.localIdentifier); + formData.set('localAid', challenge.localAid ?? ''); + formData.set('words', challenge.words.join(' ')); + formData.set('wordsHash', challenge.wordsHash); + formData.set('generatedAt', challenge.generatedAt); + challengeFetcher.submit(formData, { method: 'post' }); + }; + const submitRespondChallenge = () => { if (contact === null || activeIdentifierSummary === null) { return; @@ -232,6 +260,9 @@ export const ContactDetailView = () => { : generatedChallengeCandidate; const generatedChallengePhrase = generatedChallenge === null ? null : generatedChallenge.words.join(' '); + const savedChallengeWords = storedChallengeWords.filter( + (record) => record.challengeId !== generatedChallenge?.challengeId + ); const canUseChallenge = activeIdentifierSummary !== null && aid.length > 0; return ( @@ -476,6 +507,92 @@ export const ContactDetailView = () => { {generatedChallenge.challengeId} )} + {savedChallengeWords.length > 0 && ( + + + + Saved challenge words + + {savedChallengeWords.map((record) => ( + + + + + + {timestampText( + record.updatedAt + )} + + + + + + + ))} + + )} export const NotificationDetailView = () => { const loaderData = useLoaderData() as NotificationsLoaderData; const { notificationId = '' } = useParams(); + const navigate = useNavigate(); + const dismissFetcher = useFetcher(); const notification = useAppSelector( selectKeriaNotificationById(notificationId) ); @@ -33,6 +54,15 @@ export const NotificationDetailView = () => { ); const identifiers = useAppSelector(selectIdentifiers); + useEffect(() => { + if ( + dismissFetcher.data?.ok === true && + dismissFetcher.data.intent === 'dismissExchangeNotification' + ) { + navigate('/notifications'); + } + }, [dismissFetcher.data, navigate]); + if (loaderData.status === 'blocked') { return ; } @@ -72,13 +102,60 @@ export const NotificationDetailView = () => { } summary={notification.id} actions={ - + + {challengeRequest !== null && ( + + + { + const formData = new FormData(); + formData.set( + 'intent', + 'dismissExchangeNotification' + ); + formData.set( + 'requestId', + globalThis.crypto.randomUUID() + ); + formData.set( + 'notificationId', + notification.id + ); + formData.set( + 'exnSaid', + challengeRequest.exnSaid + ); + formData.set( + 'route', + notification.route + ); + dismissFetcher.submit(formData, { + method: 'post', + action: `/notifications/${encodeURIComponent( + notification.id + )}`, + }); + }} + > + + + + + )} + + } /> {loaderData.status === 'error' && ( @@ -121,11 +198,11 @@ export const NotificationDetailView = () => { diff --git a/src/services/notifications.service.ts b/src/services/notifications.service.ts index 2669473e..4914af67 100644 --- a/src/services/notifications.service.ts +++ b/src/services/notifications.service.ts @@ -23,6 +23,7 @@ export interface UnknownChallengeSenderNotice { } export const SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX = 'challenge-request:'; +export const SYNTHETIC_EXCHANGE_NOTIFICATION_PREFIX = 'exchange:'; export const syntheticChallengeNotificationId = (exnSaid: string): string => `${SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX}${exnSaid}`; @@ -30,6 +31,13 @@ export const syntheticChallengeNotificationId = (exnSaid: string): string => export const isSyntheticChallengeNotificationId = (id: string): boolean => id.startsWith(SYNTHETIC_CHALLENGE_NOTIFICATION_PREFIX); +export const syntheticExchangeNotificationId = (exnSaid: string): string => + `${SYNTHETIC_EXCHANGE_NOTIFICATION_PREFIX}${exnSaid}`; + +export const isSyntheticExchangeNotificationId = (id: string): boolean => + isSyntheticChallengeNotificationId(id) || + id.startsWith(SYNTHETIC_EXCHANGE_NOTIFICATION_PREFIX); + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -379,12 +387,14 @@ function* hydrateChallengeRequestNotifications({ notifications, contacts, localAids, + tombstonedExnSaids, loadedAt, }: { client: SignifyClient; notifications: NotificationRecord[]; contacts: readonly ContactRecord[]; localAids: ReadonlySet; + tombstonedExnSaids: ReadonlySet; loadedAt: string; }): EffectionOperation<{ notifications: NotificationRecord[]; @@ -401,6 +411,13 @@ function* hydrateChallengeRequestNotifications({ localAids, loadedAt, }); + const exnSaid = + result.notification.challengeRequest?.exnSaid ?? + result.notification.anchorSaid; + if (exnSaid !== null && tombstonedExnSaids.has(exnSaid)) { + continue; + } + hydrated.push(result.notification); if (result.unknownChallengeSender !== null) { unknownChallengeSenders.push(result.unknownChallengeSender); @@ -468,6 +485,7 @@ function* syntheticChallengeRequestNotifications({ client, contacts, localAids, + tombstonedExnSaids, loadedAt, existingExnSaids, respondedChallengeIds, @@ -476,6 +494,7 @@ function* syntheticChallengeRequestNotifications({ client: SignifyClient; contacts: readonly ContactRecord[]; localAids: ReadonlySet; + tombstonedExnSaids: ReadonlySet; loadedAt: string; existingExnSaids: ReadonlySet; respondedChallengeIds: ReadonlySet; @@ -490,7 +509,11 @@ function* syntheticChallengeRequestNotifications({ for (const exchange of exchanges) { const exnSaid = exchangeSaid(exchange); - if (exnSaid === null || existingExnSaids.has(exnSaid)) { + if ( + exnSaid === null || + existingExnSaids.has(exnSaid) || + tombstonedExnSaids.has(exnSaid) + ) { continue; } @@ -583,12 +606,14 @@ export function* listNotificationsService({ client, contacts = [], localAids = [], + tombstonedExnSaids = [], respondedChallengeIds = [], respondedWordsHashes = [], }: { client: SignifyClient; contacts?: readonly ContactRecord[]; localAids?: readonly string[]; + tombstonedExnSaids?: readonly string[]; respondedChallengeIds?: readonly string[]; respondedWordsHashes?: readonly string[]; }): EffectionOperation { @@ -597,12 +622,18 @@ export function* listNotificationsService({ ); const loadedAt = new Date().toISOString(); const localAidSet = aidSet(localAids); - const notifications = notificationRecordsFromResponse(raw, loadedAt); + const tombstoneSet = aidSet(tombstonedExnSaids); + const notifications = notificationRecordsFromResponse(raw, loadedAt).filter( + (notification) => + notification.anchorSaid === null || + !tombstoneSet.has(notification.anchorSaid) + ); const hydrated = yield* hydrateChallengeRequestNotifications({ client, notifications, contacts, localAids: localAidSet, + tombstonedExnSaids: tombstoneSet, loadedAt, }); const existingExnSaids = new Set( @@ -618,6 +649,7 @@ export function* listNotificationsService({ client, contacts, localAids: localAidSet, + tombstonedExnSaids: tombstoneSet, loadedAt, existingExnSaids, respondedChallengeIds: new Set(respondedChallengeIds), @@ -642,6 +674,7 @@ export function* markNotificationReadService({ notificationId, contacts = [], localAids = [], + tombstonedExnSaids = [], respondedChallengeIds = [], respondedWordsHashes = [], }: { @@ -649,6 +682,7 @@ export function* markNotificationReadService({ notificationId: string; contacts?: readonly ContactRecord[]; localAids?: readonly string[]; + tombstonedExnSaids?: readonly string[]; respondedChallengeIds?: readonly string[]; respondedWordsHashes?: readonly string[]; }): EffectionOperation { @@ -657,6 +691,7 @@ export function* markNotificationReadService({ client, contacts, localAids, + tombstonedExnSaids, respondedChallengeIds, respondedWordsHashes, }); @@ -670,6 +705,7 @@ export function* deleteNotificationService({ notificationId, contacts = [], localAids = [], + tombstonedExnSaids = [], respondedChallengeIds = [], respondedWordsHashes = [], }: { @@ -677,6 +713,7 @@ export function* deleteNotificationService({ notificationId: string; contacts?: readonly ContactRecord[]; localAids?: readonly string[]; + tombstonedExnSaids?: readonly string[]; respondedChallengeIds?: readonly string[]; respondedWordsHashes?: readonly string[]; }): EffectionOperation { @@ -685,6 +722,7 @@ export function* deleteNotificationService({ client, contacts, localAids, + tombstonedExnSaids, respondedChallengeIds, respondedWordsHashes, }); diff --git a/src/state/challenges.slice.ts b/src/state/challenges.slice.ts index bedb9f4c..f3349631 100644 --- a/src/state/challenges.slice.ts +++ b/src/state/challenges.slice.ts @@ -39,18 +39,36 @@ export interface ChallengeRecord { updatedAt: string; } +export interface StoredChallengeWordsRecord { + challengeId: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: string[]; + wordsHash: string; + strength: number; + generatedAt: string; + updatedAt: string; + status: 'pending' | 'failed'; +} + /** * Challenge slice state keyed by local challenge id. */ export interface ChallengesState { byId: Record; ids: string[]; + storedWordsById: Record; + storedWordIds: string[]; loadedAt: string | null; } const createInitialState = (): ChallengesState => ({ byId: {}, ids: [], + storedWordsById: {}, + storedWordIds: [], loadedAt: null, }); @@ -104,6 +122,52 @@ export const challengesSlice = createSlice({ state.ids.push(payload.id); } }, + storedChallengeWordsRecorded( + state, + { payload }: PayloadAction + ) { + state.storedWordsById[payload.challengeId] = payload; + if (!state.storedWordIds.includes(payload.challengeId)) { + state.storedWordIds.push(payload.challengeId); + } + }, + storedChallengeWordsFailed( + state, + { + payload, + }: PayloadAction<{ + challengeId: string; + updatedAt: string; + }> + ) { + const record = state.storedWordsById[payload.challengeId]; + if (record !== undefined) { + record.status = 'failed'; + record.updatedAt = payload.updatedAt; + } + }, + storedChallengeWordsCleared( + state, + { payload }: PayloadAction<{ challengeId: string }> + ) { + delete state.storedWordsById[payload.challengeId]; + state.storedWordIds = state.storedWordIds.filter( + (id) => id !== payload.challengeId + ); + }, + storedChallengeWordsRehydrated( + state, + { + payload, + }: PayloadAction<{ records: StoredChallengeWordsRecord[] }> + ) { + state.storedWordsById = Object.fromEntries( + payload.records.map((record) => [record.challengeId, record]) + ); + state.storedWordIds = payload.records.map( + (record) => record.challengeId + ); + }, }, extraReducers: (builder) => { builder @@ -114,7 +178,14 @@ export const challengesSlice = createSlice({ }); /** Action creators for recording challenge workflow progress. */ -export const { challengesLoaded, challengeRecorded } = challengesSlice.actions; +export const { + challengesLoaded, + challengeRecorded, + storedChallengeWordsRecorded, + storedChallengeWordsFailed, + storedChallengeWordsCleared, + storedChallengeWordsRehydrated, +} = challengesSlice.actions; /** Reducer mounted at `state.challenges`. */ export const challengesReducer = challengesSlice.reducer; diff --git a/src/state/credentials.slice.ts b/src/state/credentials.slice.ts index 0982d5dd..2f1785bd 100644 --- a/src/state/credentials.slice.ts +++ b/src/state/credentials.slice.ts @@ -1,4 +1,9 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, +} from './session.slice'; /** Local status of a credential as it moves through issuer/holder/verifier flows. */ export type CredentialStatus = @@ -29,10 +34,12 @@ export interface CredentialsState { saids: string[]; } -const initialState: CredentialsState = { +const createInitialState = (): CredentialsState => ({ bySaid: {}, saids: [], -}; +}); + +const initialState: CredentialsState = createInitialState(); /** * Redux slice for credential inventory and lifecycle status. @@ -51,6 +58,12 @@ export const credentialsSlice = createSlice({ } }, }, + extraReducers: (builder) => { + builder + .addCase(sessionConnecting, createInitialState) + .addCase(sessionConnectionFailed, createInitialState) + .addCase(sessionDisconnected, createInitialState); + }, }); /** Action creators for updating credential summary state. */ diff --git a/src/state/exchangeTombstones.slice.ts b/src/state/exchangeTombstones.slice.ts new file mode 100644 index 00000000..a3e6f11f --- /dev/null +++ b/src/state/exchangeTombstones.slice.ts @@ -0,0 +1,64 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, +} from './session.slice'; + +export type ExchangeTombstoneReason = 'userDismissed'; + +export interface ExchangeTombstoneRecord { + exnSaid: string; + route: string; + notificationId?: string | null; + reason: ExchangeTombstoneReason; + createdAt: string; +} + +export interface ExchangeTombstonesState { + bySaid: Record; + saids: string[]; +} + +const createInitialState = (): ExchangeTombstonesState => ({ + bySaid: {}, + saids: [], +}); + +const initialState = createInitialState(); + +export const exchangeTombstonesSlice = createSlice({ + name: 'exchangeTombstones', + initialState, + reducers: { + exchangeTombstoneRecorded( + state, + { payload }: PayloadAction + ) { + state.bySaid[payload.exnSaid] = payload; + if (!state.saids.includes(payload.exnSaid)) { + state.saids.push(payload.exnSaid); + } + }, + exchangeTombstonesRehydrated( + state, + { payload }: PayloadAction<{ records: ExchangeTombstoneRecord[] }> + ) { + state.bySaid = Object.fromEntries( + payload.records.map((record) => [record.exnSaid, record]) + ); + state.saids = payload.records.map((record) => record.exnSaid); + }, + }, + extraReducers: (builder) => { + builder + .addCase(sessionConnecting, createInitialState) + .addCase(sessionConnectionFailed, createInitialState) + .addCase(sessionDisconnected, createInitialState); + }, +}); + +export const { exchangeTombstoneRecorded, exchangeTombstonesRehydrated } = + exchangeTombstonesSlice.actions; + +export const exchangeTombstonesReducer = exchangeTombstonesSlice.reducer; diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 135dc948..83fb49f4 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -1,6 +1,10 @@ import { appNotificationsRehydrated } from './appNotifications.slice'; +import { storedChallengeWordsRehydrated } from './challenges.slice'; +import { exchangeTombstonesRehydrated } from './exchangeTombstones.slice'; import { operationsRehydrated } from './operations.slice'; import type { AppNotificationRecord } from './appNotifications.slice'; +import type { StoredChallengeWordsRecord } from './challenges.slice'; +import type { ExchangeTombstoneRecord } from './exchangeTombstones.slice'; import type { OperationRecord } from './operations.slice'; import type { AppStore, RootState } from './store'; @@ -14,6 +18,9 @@ const PERSISTENCE_KEY_PREFIX = 'signify-react-ts:app-state:v1'; export interface AppStateStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; + removeItem?(key: string): void; + key?(index: number): string | null; + readonly length?: number; } /** @@ -28,6 +35,8 @@ export interface PersistedAppState { version: typeof PERSISTENCE_VERSION; operations: OperationRecord[]; appNotifications: AppNotificationRecord[]; + exchangeTombstones: ExchangeTombstoneRecord[]; + storedChallengeWords: StoredChallengeWordsRecord[]; } const isRecord = (value: unknown): value is Record => @@ -72,6 +81,45 @@ const isAppNotificationRecord = ( ); }; +const isExchangeTombstoneRecord = ( + value: unknown +): value is ExchangeTombstoneRecord => { + if (!isRecord(value)) { + return false; + } + + return ( + hasString(value, 'exnSaid') && + hasString(value, 'route') && + value.reason === 'userDismissed' && + hasString(value, 'createdAt') && + (value.notificationId === undefined || + value.notificationId === null || + typeof value.notificationId === 'string') + ); +}; + +const isStoredChallengeWordsRecord = ( + value: unknown +): value is StoredChallengeWordsRecord => { + if (!isRecord(value)) { + return false; + } + + return ( + hasString(value, 'challengeId') && + hasString(value, 'counterpartyAid') && + hasString(value, 'localIdentifier') && + Array.isArray(value.words) && + value.words.every((word) => typeof word === 'string') && + hasString(value, 'wordsHash') && + typeof value.strength === 'number' && + hasString(value, 'generatedAt') && + hasString(value, 'updatedAt') && + (value.status === 'pending' || value.status === 'failed') + ); +}; + const browserStorage = (): AppStateStorage | null => { try { const storage = globalThis.localStorage; @@ -94,6 +142,36 @@ const browserStorage = (): AppStateStorage | null => { export const persistedAppStateKey = (controllerAid: string): string => `${PERSISTENCE_KEY_PREFIX}:${controllerAid}`; +/** + * Remove every controller-scoped app-state bucket owned by this app. + */ +export const clearAllPersistedAppStates = ( + storage: AppStateStorage | null = browserStorage() +): number => { + if ( + storage === null || + typeof storage.removeItem !== 'function' || + typeof storage.key !== 'function' || + typeof storage.length !== 'number' + ) { + return 0; + } + + const keysToRemove: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(`${PERSISTENCE_KEY_PREFIX}:`) === true) { + keysToRemove.push(key); + } + } + + for (const key of keysToRemove) { + storage.removeItem(key); + } + + return keysToRemove.length; +}; + /** * Load one controller's persisted app state, filtering malformed records. */ @@ -122,11 +200,19 @@ export const loadPersistedAppState = ( const appNotifications = Array.isArray(parsed.appNotifications) ? parsed.appNotifications.filter(isAppNotificationRecord) : []; + const exchangeTombstones = Array.isArray(parsed.exchangeTombstones) + ? parsed.exchangeTombstones.filter(isExchangeTombstoneRecord) + : []; + const storedChallengeWords = Array.isArray(parsed.storedChallengeWords) + ? parsed.storedChallengeWords.filter(isStoredChallengeWordsRecord) + : []; return { version: PERSISTENCE_VERSION, operations, appNotifications, + exchangeTombstones, + storedChallengeWords, }; } catch { return null; @@ -148,10 +234,21 @@ export const persistedAppStateFromRoot = ( .filter( (record): record is AppNotificationRecord => record !== undefined ), + exchangeTombstones: state.exchangeTombstones.saids + .map((said) => state.exchangeTombstones.bySaid[said]) + .filter( + (record): record is ExchangeTombstoneRecord => record !== undefined + ), + storedChallengeWords: state.challenges.storedWordIds + .map((id) => state.challenges.storedWordsById[id]) + .filter( + (record): record is StoredChallengeWordsRecord => + record !== undefined + ), }); /** - * Save the current operation and app-notification facts for one controller. + * Save the current controller-scoped local app facts. */ export const savePersistedAppState = ( state: RootState, @@ -192,6 +289,16 @@ export const rehydratePersistedAppState = ( records: persisted?.appNotifications ?? [], }) ); + store.dispatch( + exchangeTombstonesRehydrated({ + records: persisted?.exchangeTombstones ?? [], + }) + ); + store.dispatch( + storedChallengeWordsRehydrated({ + records: persisted?.storedChallengeWords ?? [], + }) + ); }; /** diff --git a/src/state/registry.slice.ts b/src/state/registry.slice.ts index df11d06b..a3908d3f 100644 --- a/src/state/registry.slice.ts +++ b/src/state/registry.slice.ts @@ -1,4 +1,9 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, +} from './session.slice'; /** Lifecycle of a credential registry known to the local issuer role. */ export type RegistryStatus = 'unknown' | 'creating' | 'ready' | 'error'; @@ -22,10 +27,12 @@ export interface RegistryState { ids: string[]; } -const initialState: RegistryState = { +const createInitialState = (): RegistryState => ({ byId: {}, ids: [], -}; +}); + +const initialState: RegistryState = createInitialState(); /** * Redux slice for credential registry creation/discovery state. @@ -41,6 +48,12 @@ export const registrySlice = createSlice({ } }, }, + extraReducers: (builder) => { + builder + .addCase(sessionConnecting, createInitialState) + .addCase(sessionConnectionFailed, createInitialState) + .addCase(sessionDisconnected, createInitialState); + }, }); /** Action creators for updating registry records. */ diff --git a/src/state/roles.slice.ts b/src/state/roles.slice.ts index 39781444..0dfdccaa 100644 --- a/src/state/roles.slice.ts +++ b/src/state/roles.slice.ts @@ -1,4 +1,9 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, +} from './session.slice'; /** Demo role names used by issuer/holder/verifier workflows. */ export type LocalRole = 'issuer' | 'holder' | 'verifier'; @@ -32,13 +37,15 @@ const emptyRole = (role: LocalRole): RoleRecord => ({ updatedAt: null, }); -const initialState: RolesState = { +const createInitialState = (): RolesState => ({ byRole: { issuer: emptyRole('issuer'), holder: emptyRole('holder'), verifier: emptyRole('verifier'), }, -}; +}); + +const initialState: RolesState = createInitialState(); /** * Redux slice for issuer/holder/verifier role bindings. @@ -51,6 +58,12 @@ export const rolesSlice = createSlice({ state.byRole[payload.role] = payload; }, }, + extraReducers: (builder) => { + builder + .addCase(sessionConnecting, createInitialState) + .addCase(sessionConnectionFailed, createInitialState) + .addCase(sessionDisconnected, createInitialState); + }, }); /** Action creators for updating role bindings. */ diff --git a/src/state/schema.slice.ts b/src/state/schema.slice.ts index ab066cb8..3d32905c 100644 --- a/src/state/schema.slice.ts +++ b/src/state/schema.slice.ts @@ -1,4 +1,9 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, +} from './session.slice'; /** Resolution lifecycle for a credential schema OOBI. */ export type SchemaResolutionStatus = 'unknown' | 'resolving' | 'resolved' | 'error'; @@ -22,10 +27,12 @@ export interface SchemaState { saids: string[]; } -const initialState: SchemaState = { +const createInitialState = (): SchemaState => ({ bySaid: {}, saids: [], -}; +}); + +const initialState: SchemaState = createInitialState(); /** * Redux slice for credential schema resolution state. @@ -41,6 +48,12 @@ export const schemaSlice = createSlice({ } }, }, + extraReducers: (builder) => { + builder + .addCase(sessionConnecting, createInitialState) + .addCase(sessionConnectionFailed, createInitialState) + .addCase(sessionDisconnected, createInitialState); + }, }); /** Action creators for updating schema resolution records. */ diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 9efe58c0..ca3015f8 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -6,7 +6,10 @@ import type { NotificationRecord, } from './notifications.slice'; import type { ContactRecord } from './contacts.slice'; -import type { ChallengeRecord } from './challenges.slice'; +import type { + ChallengeRecord, + StoredChallengeWordsRecord, +} from './challenges.slice'; import { knownComponentsFromContacts, type KnownComponentRecord, @@ -88,9 +91,26 @@ const byNewestChallengeTimestamp = ( right: ChallengeRecord ): number => right.updatedAt.localeCompare(left.updatedAt); +const byNewestStoredChallengeWordsTimestamp = ( + left: StoredChallengeWordsRecord, + right: StoredChallengeWordsRecord +): number => right.updatedAt.localeCompare(left.updatedAt); + const byUpdatedContact = (left: ContactRecord, right: ContactRecord): number => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''); +export const selectExchangeTombstoneSaids = (state: RootState) => + state.exchangeTombstones.saids; + +const notificationExnSaid = (notification: NotificationRecord): string | null => + notification.challengeRequest?.exnSaid ?? notification.anchorSaid; + +const isExchangeTombstoned = ( + state: RootState, + exnSaid: string | null +): boolean => + exnSaid !== null && state.exchangeTombstones.bySaid[exnSaid] !== undefined; + /** Select user-facing app notification records in descending timestamp order. */ export const selectAppNotifications = (state: RootState) => state.appNotifications.ids @@ -156,6 +176,10 @@ export const selectUnreadNotifications = (state: RootState) => .filter( (notification): notification is NotificationRecord => notification !== undefined && + !isExchangeTombstoned( + state, + notificationExnSaid(notification) + ) && (notification.status === 'unread' || !notification.read) ); @@ -165,15 +189,21 @@ export const selectKeriaNotifications = (state: RootState) => .map((id) => state.notifications.byId[id]) .filter( (notification): notification is NotificationRecord => - notification !== undefined + notification !== undefined && + !isExchangeTombstoned(state, notificationExnSaid(notification)) ) .sort(byNewestKeriaNotificationTimestamp); /** Select one KERIA notification by id. */ export const selectKeriaNotificationById = (id: string) => - (state: RootState): NotificationRecord | null => - state.notifications.byId[id] ?? null; + (state: RootState): NotificationRecord | null => { + const notification = state.notifications.byId[id] ?? null; + return notification !== null && + isExchangeTombstoned(state, notificationExnSaid(notification)) + ? null + : notification; + }; /** Select hydrated challenge request notifications newest first. */ export const selectChallengeRequestNotifications = (state: RootState) => @@ -198,7 +228,8 @@ export const selectActionableChallengeRequestNotifications = ( export const selectChallengeRequestNotificationById = (notificationId: string) => (state: RootState): ChallengeRequestNotification | null => - state.notifications.byId[notificationId]?.challengeRequest ?? null; + selectKeriaNotificationById(notificationId)(state)?.challengeRequest ?? + null; /** Select challenge-response records newest first. */ export const selectChallenges = (state: RootState) => @@ -217,6 +248,22 @@ export const selectChallengesForContact = (challenge) => challenge.counterpartyAid === contactId ); +export const selectStoredChallengeWords = (state: RootState) => + state.challenges.storedWordIds + .map((id) => state.challenges.storedWordsById[id]) + .filter( + (record): record is StoredChallengeWordsRecord => + record !== undefined + ) + .sort(byNewestStoredChallengeWordsTimestamp); + +export const selectStoredChallengeWordsForContact = + (contactId: string) => + (state: RootState): StoredChallengeWordsRecord[] => + selectStoredChallengeWords(state).filter( + (record) => record.counterpartyAid === contactId + ); + /** Select known witnesses/watchers/mailboxes/components derived from contacts. */ export const selectKnownComponents = ( state: RootState diff --git a/src/state/store.ts b/src/state/store.ts index 95dabc4e..ab59b0e5 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -3,6 +3,7 @@ import { appNotificationsReducer } from './appNotifications.slice'; import { challengesReducer } from './challenges.slice'; import { contactsReducer } from './contacts.slice'; import { credentialsReducer } from './credentials.slice'; +import { exchangeTombstonesReducer } from './exchangeTombstones.slice'; import { identifiersReducer } from './identifiers.slice'; import { notificationsReducer } from './notifications.slice'; import { operationsReducer } from './operations.slice'; @@ -22,6 +23,7 @@ export const createAppStore = () => appNotifications: appNotificationsReducer, contacts: contactsReducer, challenges: challengesReducer, + exchangeTombstones: exchangeTombstonesReducer, credentials: credentialsReducer, identifiers: identifiersReducer, notifications: notificationsReducer, diff --git a/src/workflows/challenges.op.ts b/src/workflows/challenges.op.ts index c633ef9b..7c7d0f4b 100644 --- a/src/workflows/challenges.op.ts +++ b/src/workflows/challenges.op.ts @@ -20,6 +20,9 @@ import { } from '../services/notifications.service'; import { challengeRecorded, + storedChallengeWordsCleared, + storedChallengeWordsFailed, + storedChallengeWordsRecorded, type ChallengeRecord, } from '../state/challenges.slice'; import { challengeRequestNotificationResponded } from '../state/notifications.slice'; @@ -27,6 +30,7 @@ import { localIdentifierAids, publishContactInventory, publishNotificationInventory, + tombstonedExchangeSaids, } from './contacts.op'; export interface GenerateContactChallengeInput { @@ -212,6 +216,21 @@ export function* generateContactChallengeOp( }) ) ); + services.store.dispatch( + storedChallengeWordsRecorded({ + challengeId, + counterpartyAid, + counterpartyAlias: input.counterpartyAlias ?? null, + localIdentifier, + localAid: input.localAid ?? null, + words, + wordsHash, + strength, + generatedAt, + updatedAt: generatedAt, + status: 'pending', + }) + ); return { challengeId, @@ -294,6 +313,7 @@ export function* respondToContactChallengeOp( notificationId, contacts: currentContacts(services), localAids: localIdentifierAids(services.store), + tombstonedExnSaids: tombstonedExchangeSaids(services.store), respondedChallengeIds: [record.id], respondedWordsHashes: record.wordsHash === undefined || record.wordsHash === null @@ -398,6 +418,9 @@ export function* verifyContactChallengeOp( }); services.store.dispatch(challengeRecorded(record)); + services.store.dispatch( + storedChallengeWordsCleared({ challengeId: input.challengeId }) + ); const inventory = yield* listContactsService({ client: services.runtime.requireConnectedClient(), }); @@ -422,6 +445,12 @@ export function* verifyContactChallengeOp( updatedAt: failedAt, }); services.store.dispatch(challengeRecorded(record)); + services.store.dispatch( + storedChallengeWordsFailed({ + challengeId: input.challengeId, + updatedAt: failedAt, + }) + ); throw error; } } diff --git a/src/workflows/contacts.op.ts b/src/workflows/contacts.op.ts index 94bbc7b7..1aaaf92a 100644 --- a/src/workflows/contacts.op.ts +++ b/src/workflows/contacts.op.ts @@ -135,6 +135,10 @@ export const localIdentifierAids = ( return [...new Set(aids)]; }; +export const tombstonedExchangeSaids = ( + store: Pick +): string[] => store.getState().exchangeTombstones.saids; + const respondedChallengeKeys = ( store: Pick, inventory: ContactInventorySnapshot @@ -175,6 +179,7 @@ export function* syncSessionInventoryOp(): EffectionOperation { + const normalized = value.trim(); + if (normalized.length === 0) { + throw new Error(`${label} is required.`); + } + + return normalized; +}; + +export function* dismissExchangeNotificationOp( + input: DismissExchangeNotificationInput +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const client = services.runtime.requireConnectedClient(); + const notificationId = requireNonEmpty( + input.notificationId, + 'Notification id' + ); + const exnSaid = requireNonEmpty(input.exnSaid, 'EXN SAID'); + const route = requireNonEmpty(input.route, 'Route'); + const createdAt = new Date().toISOString(); + + services.store.dispatch( + exchangeTombstoneRecorded({ + exnSaid, + route, + notificationId, + reason: 'userDismissed', + createdAt, + }) + ); + + if (!isSyntheticExchangeNotificationId(notificationId)) { + try { + yield* callPromise(() => + client.notifications().delete(notificationId) + ); + } catch { + // Tombstones are authoritative local UI state; KERIA deletion is + // only a best-effort cleanup for real notification notes. + } + } + + try { + yield* syncSessionInventoryOp(); + } catch { + // The live sync loop will retry. The tombstone above is enough for + // immediate local suppression even when the refresh is unavailable. + } +} diff --git a/tests/contact-challenge-smoke.ts b/tests/contact-challenge-smoke.ts index 16a5f8ce..cb7559cb 100644 --- a/tests/contact-challenge-smoke.ts +++ b/tests/contact-challenge-smoke.ts @@ -434,6 +434,9 @@ const waitForChallengeNotificationCard = async (page: Page): Promise => { } }; +const visibleChallengeResponseTextarea = (scope: string): string => + `${scope} [data-testid="challenge-notification-response-input"] textarea:not([aria-hidden="true"])`; + const respondFromBellNotification = async ( page: Page, words: readonly string[] @@ -441,14 +444,47 @@ const respondFromBellNotification = async ( await waitForChallengeNotificationCard(page); await setInputValue( page, - '[data-testid="challenge-notification-card"] [data-testid="challenge-notification-response-input"] textarea', + visibleChallengeResponseTextarea( + '[data-testid="challenge-notification-card"]' + ), words.join(' ') ); - await page.click( + await clickEnabledChallengeResponseSubmit( + page, '[data-testid="challenge-notification-card"] [data-testid="challenge-notification-response-submit"]' ); }; +const clickEnabledChallengeResponseSubmit = async ( + page: Page, + selector: string +): Promise => { + try { + await page.waitForFunction( + (submitSelector) => { + const element = + globalThis.document.querySelector(submitSelector); + return ( + element instanceof globalThis.HTMLButtonElement && + !element.disabled + ); + }, + { timeout: 10_000 }, + selector + ); + } catch (error) { + const visibleText = await page.evaluate( + () => globalThis.document.body.textContent?.slice(0, 4000) ?? '' + ); + throw new Error( + `Challenge response submit did not become enabled for ${selector}. Visible text: ${visibleText}`, + { cause: error } + ); + } + + await page.click(selector); +}; + const respondFromNotificationDetail = async ( page: Page, words: readonly string[] @@ -461,7 +497,7 @@ const respondFromNotificationDetail = async ( ); try { await page.waitForSelector( - 'main [data-testid="challenge-notification-response-input"] textarea', + visibleChallengeResponseTextarea('main'), { timeout: 45_000 } ); } catch (error) { @@ -475,10 +511,11 @@ const respondFromNotificationDetail = async ( } await setInputValue( page, - 'main [data-testid="challenge-notification-response-input"] textarea', + visibleChallengeResponseTextarea('main'), words.join(' ') ); - await page.click( + await clickEnabledChallengeResponseSubmit( + page, 'main [data-testid="challenge-notification-response-submit"]' ); }; diff --git a/tests/unit/notificationsService.test.ts b/tests/unit/notificationsService.test.ts index 4e6ada55..c29b864e 100644 --- a/tests/unit/notificationsService.test.ts +++ b/tests/unit/notificationsService.test.ts @@ -76,12 +76,19 @@ const makeClient = ({ const runListNotifications = async ( client: SignifyClient, contacts: readonly ContactRecord[] = [], - localAids: readonly string[] = [] + localAids: readonly string[] = [], + tombstonedExnSaids: readonly string[] = [] ) => { const runtime = createAppRuntime({ storage: null }); try { return await runtime.runWorkflow( - () => listNotificationsService({ client, contacts, localAids }), + () => + listNotificationsService({ + client, + contacts, + localAids, + tombstonedExnSaids, + }), { scope: 'app', track: false } ); } finally { @@ -212,6 +219,76 @@ describe('notification service helpers', () => { ]); }); + it('filters tombstoned synthetic challenge request exchanges', async () => { + const { client } = makeClient({ + rawNotifications: { notes: [] }, + queryExchanges: [challengeExchange], + }); + + const snapshot = await runListNotifications( + client, + [contact], + [], + ['Eexn'] + ); + + expect(snapshot.notifications).toEqual([]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + + it('filters KERIA notifications with tombstoned anchors before hydration', async () => { + const { client, exchanges } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { r: '/exn', d: 'Eexn' }, + }, + ], + }, + }); + + const snapshot = await runListNotifications( + client, + [contact], + [], + ['Eexn'] + ); + + expect(exchanges.get).not.toHaveBeenCalled(); + expect(snapshot.notifications).toEqual([]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + + it('filters hydrated KERIA challenge requests with tombstoned EXN SAIDs', async () => { + const { client, exchanges } = makeClient({ + rawNotifications: { + notes: [ + { + i: 'note-1', + dt: loadedAt, + r: false, + a: { r: '/exn', d: 'Eanchor' }, + }, + ], + }, + exchange: challengeExchange, + }); + + const snapshot = await runListNotifications( + client, + [contact], + [], + ['Eexn'] + ); + + expect(exchanges.get).toHaveBeenCalledWith('Eanchor'); + expect(snapshot.notifications).toEqual([]); + expect(snapshot.unknownChallengeSenders).toEqual([]); + }); + it('ignores locally-authored challenge request exchanges', async () => { const outboundExchange = { exn: { diff --git a/tests/unit/persistence.test.ts b/tests/unit/persistence.test.ts index 42796a6d..13df364c 100644 --- a/tests/unit/persistence.test.ts +++ b/tests/unit/persistence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + clearAllPersistedAppStates, installAppStatePersistence, loadPersistedAppState, persistedAppStateKey, @@ -8,6 +9,8 @@ import { type AppStateStorage, } from '../../src/state/persistence'; import { createAppStore } from '../../src/state/store'; +import { storedChallengeWordsRecorded } from '../../src/state/challenges.slice'; +import { exchangeTombstoneRecorded } from '../../src/state/exchangeTombstones.slice'; import { operationStarted } from '../../src/state/operations.slice'; class MemoryStorage implements AppStateStorage { @@ -20,6 +23,18 @@ class MemoryStorage implements AppStateStorage { setItem(key: string, value: string): void { this.values.set(key, value); } + + removeItem(key: string): void { + this.values.delete(key); + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + get length(): number { + return this.values.size; + } } describe('app state persistence', () => { @@ -82,7 +97,11 @@ describe('app state persistence', () => { startedAt: '2026-04-21T00:00:00.000Z', }) ); - rehydratePersistedAppState(target, 'Econtroller-without-state', storage); + rehydratePersistedAppState( + target, + 'Econtroller-without-state', + storage + ); expect(target.getState().operations.order).toHaveLength(0); }); @@ -134,4 +153,98 @@ describe('app state persistence', () => { expect(loadPersistedAppState('Econtroller1', storage)).toBeNull(); }); + + it('accepts older persisted buckets without EXN tombstones or stored challenge words', () => { + const storage = new MemoryStorage(); + storage.setItem( + persistedAppStateKey('Econtroller1'), + JSON.stringify({ + version: 1, + operations: [], + appNotifications: [], + }) + ); + + expect(loadPersistedAppState('Econtroller1', storage)).toMatchObject({ + exchangeTombstones: [], + storedChallengeWords: [], + }); + }); + + it('persists and rehydrates EXN tombstones and stored challenge words', () => { + const source = createAppStore(); + const target = createAppStore(); + const storage = new MemoryStorage(); + + source.dispatch( + exchangeTombstoneRecorded({ + exnSaid: 'Eexn', + route: '/challenge/request', + notificationId: 'challenge-request:Eexn', + reason: 'userDismissed', + createdAt: '2026-04-21T00:00:00.000Z', + }) + ); + source.dispatch( + storedChallengeWordsRecorded({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: Array.from({ length: 12 }, (_, index) => `word${index}`), + wordsHash: 'hash-one', + strength: 128, + generatedAt: '2026-04-21T00:00:01.000Z', + updatedAt: '2026-04-21T00:00:01.000Z', + status: 'pending', + }) + ); + + savePersistedAppState(source.getState(), 'Econtroller1', storage); + + expect(loadPersistedAppState('Econtroller1', storage)).toMatchObject({ + exchangeTombstones: [ + expect.objectContaining({ + exnSaid: 'Eexn', + reason: 'userDismissed', + }), + ], + storedChallengeWords: [ + expect.objectContaining({ + challengeId: 'challenge-1', + wordsHash: 'hash-one', + status: 'pending', + }), + ], + }); + + rehydratePersistedAppState(target, 'Econtroller1', storage); + + expect(target.getState().exchangeTombstones.bySaid.Eexn).toMatchObject({ + route: '/challenge/request', + }); + expect( + target.getState().challenges.storedWordsById['challenge-1'] + ).toMatchObject({ + counterpartyAid: 'Econtact', + status: 'pending', + }); + }); + + it('clears all controller-scoped persisted buckets without touching other storage', () => { + const storage = new MemoryStorage(); + storage.setItem(persistedAppStateKey('Econtroller1'), '{"version":1}'); + storage.setItem(persistedAppStateKey('Econtroller2'), '{"version":1}'); + storage.setItem('unrelated-key', 'keep'); + + expect(clearAllPersistedAppStates(storage)).toBe(2); + expect( + storage.getItem(persistedAppStateKey('Econtroller1')) + ).toBeNull(); + expect( + storage.getItem(persistedAppStateKey('Econtroller2')) + ).toBeNull(); + expect(storage.getItem('unrelated-key')).toBe('keep'); + }); }); diff --git a/tests/unit/routeData.test.ts b/tests/unit/routeData.test.ts index 6442c9e7..56d20ee6 100644 --- a/tests/unit/routeData.test.ts +++ b/tests/unit/routeData.test.ts @@ -12,6 +12,7 @@ import { loadCredentials, loadDashboard, loadIdentifiers, + notificationsAction, rootAction, type RouteDataRuntime, } from '../../src/app/routeData'; @@ -117,6 +118,7 @@ const makeRuntime = ( requestId: 'verify-challenge-request-1', operationRoute: '/operations/verify-challenge-request-1', })), + dismissExchangeNotification: vi.fn(async () => undefined), ...overrides, }); @@ -558,6 +560,40 @@ describe('route actions', () => { ); }); + it('dismisses exchange notifications through the notifications action', async () => { + const runtime = makeRuntime(); + + await expect( + notificationsAction( + runtime, + makeRequest('/notifications', { + intent: 'dismissExchangeNotification', + requestId: 'dismiss-request-1', + notificationId: 'challenge-request:Eexn', + exnSaid: 'Eexn', + route: '/challenge/request', + }) + ) + ).resolves.toEqual({ + intent: 'dismissExchangeNotification', + ok: true, + message: 'Exchange notification dismissed.', + requestId: 'dismiss-request-1', + operationRoute: '/notifications', + }); + expect(runtime.dismissExchangeNotification).toHaveBeenCalledWith( + { + notificationId: 'challenge-request:Eexn', + exnSaid: 'Eexn', + route: '/challenge/request', + }, + expect.objectContaining({ + requestId: 'dismiss-request-1', + signal: expect.any(AbortSignal), + }) + ); + }); + it('rejects malformed challenge word submissions', async () => { const runtime = makeRuntime(); diff --git a/tests/unit/runtimeWorkflow.test.ts b/tests/unit/runtimeWorkflow.test.ts index 4cbd070d..0a0c939d 100644 --- a/tests/unit/runtimeWorkflow.test.ts +++ b/tests/unit/runtimeWorkflow.test.ts @@ -1,9 +1,96 @@ import { sleep } from 'effection'; import { describe, expect, it, vi } from 'vitest'; -import { createAppRuntime } from '../../src/app/runtime'; +import type { SignifyClient } from 'signify-ts'; +import { createAppRuntime, type AppRuntime } from '../../src/app/runtime'; +import { appNotificationRecorded } from '../../src/state/appNotifications.slice'; +import { storedChallengeWordsRecorded } from '../../src/state/challenges.slice'; +import { exchangeTombstoneRecorded } from '../../src/state/exchangeTombstones.slice'; +import { operationStarted } from '../../src/state/operations.slice'; +import { + persistedAppStateKey, + type AppStateStorage, +} from '../../src/state/persistence'; import { selectActiveOperations } from '../../src/state/selectors'; import { createAppStore } from '../../src/state/store'; +class MemoryStorage implements AppStateStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + get length(): number { + return this.values.size; + } +} + +const connectRuntimeForTest = ( + runtime: AppRuntime, + client: SignifyClient +): void => { + ( + runtime as unknown as { + snapshot: unknown; + } + ).snapshot = { + connection: { + status: 'connected', + client, + state: { + controllerPre: 'Econtroller', + agentPre: 'Eagent', + ridx: 0, + pidx: 0, + state: {}, + }, + error: null, + booted: true, + }, + }; +}; + +const makeWorkflowClient = ({ + rawNotifications = { notes: [] }, + queryExchanges = [], +}: { + rawNotifications?: unknown; + queryExchanges?: unknown[]; +} = {}) => { + const notifications = { + list: vi.fn(async () => rawNotifications), + mark: vi.fn(async () => ''), + delete: vi.fn(async () => undefined), + }; + const contacts = { + list: vi.fn(async () => []), + }; + const client = { + contacts: () => contacts, + notifications: () => notifications, + exchanges: () => ({ + get: vi.fn(), + }), + fetch: vi.fn(async () => ({ + json: async () => queryExchanges, + })), + } as unknown as SignifyClient; + + return { client, notifications, contacts }; +}; + describe('AppRuntime workflow bridge', () => { it('records successful Effection workflow completion', async () => { const store = createAppStore(); @@ -24,7 +111,9 @@ describe('AppRuntime workflow bridge', () => { ) ).resolves.toBe('done'); - expect(store.getState().operations.byId['workflow-success']).toMatchObject({ + expect( + store.getState().operations.byId['workflow-success'] + ).toMatchObject({ status: 'success', label: 'Test workflow...', }); @@ -54,7 +143,9 @@ describe('AppRuntime workflow bridge', () => { controller.abort(); await expect(promise).rejects.toThrow(); - expect(store.getState().operations.byId['workflow-canceled']).toMatchObject({ + expect( + store.getState().operations.byId['workflow-canceled'] + ).toMatchObject({ status: 'canceled', }); expect(selectActiveOperations(store.getState())).toHaveLength(0); @@ -90,13 +181,17 @@ describe('AppRuntime workflow bridge', () => { requestId: 'background-success', operationRoute: '/operations/background-success', }); - expect(store.getState().operations.byId['background-success']).toMatchObject({ + expect( + store.getState().operations.byId['background-success'] + ).toMatchObject({ status: 'running', resourceKeys: ['contact:alice'], }); await vi.waitFor(() => { - expect(store.getState().operations.byId['background-success']).toMatchObject({ + expect( + store.getState().operations.byId['background-success'] + ).toMatchObject({ status: 'success', notificationId: expect.any(String), }); @@ -106,6 +201,125 @@ describe('AppRuntime workflow bridge', () => { await runtime.destroy(); }); + it('clears persisted local buckets and current persisted projections', async () => { + const store = createAppStore(); + const storage = new MemoryStorage(); + const runtime = createAppRuntime({ store, storage }); + + storage.setItem( + persistedAppStateKey('Econtroller1'), + '{"version":1,"operations":[],"appNotifications":[]}' + ); + storage.setItem( + persistedAppStateKey('Econtroller2'), + '{"version":1,"operations":[],"appNotifications":[]}' + ); + store.dispatch( + operationStarted({ + requestId: 'op-1', + label: 'Working...', + title: 'Working operation', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + startedAt: '2026-04-21T00:00:00.000Z', + }) + ); + store.dispatch( + appNotificationRecorded({ + id: 'app-n-1', + severity: 'info', + status: 'unread', + title: 'Stored notice', + message: 'Stored notice', + createdAt: '2026-04-21T00:00:01.000Z', + readAt: null, + operationId: null, + links: [], + payloadDetails: [], + }) + ); + store.dispatch( + exchangeTombstoneRecorded({ + exnSaid: 'Eexn', + route: '/challenge/request', + notificationId: 'challenge-request:Eexn', + reason: 'userDismissed', + createdAt: '2026-04-21T00:00:02.000Z', + }) + ); + store.dispatch( + storedChallengeWordsRecorded({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: Array.from({ length: 12 }, (_, index) => `word${index}`), + wordsHash: 'hash-one', + strength: 128, + generatedAt: '2026-04-21T00:00:03.000Z', + updatedAt: '2026-04-21T00:00:03.000Z', + status: 'pending', + }) + ); + + expect(runtime.clearAllLocalState()).toBe(2); + + expect( + storage.getItem(persistedAppStateKey('Econtroller1')) + ).toBeNull(); + expect( + storage.getItem(persistedAppStateKey('Econtroller2')) + ).toBeNull(); + expect(store.getState().operations.order).toEqual([]); + expect(store.getState().appNotifications.ids).toEqual([]); + expect(store.getState().exchangeTombstones.saids).toEqual([]); + expect(store.getState().challenges.storedWordIds).toEqual([]); + + await runtime.destroy(); + }); + + it('tombstones synthetic exchange notifications without deleting KERIA notes', async () => { + const store = createAppStore(); + const runtime = createAppRuntime({ store, storage: null }); + const { client, notifications } = makeWorkflowClient(); + connectRuntimeForTest(runtime, client); + + await runtime.dismissExchangeNotification({ + notificationId: 'exchange:Eexn', + exnSaid: 'Eexn', + route: '/challenge/request', + }); + + expect(store.getState().exchangeTombstones.bySaid.Eexn).toMatchObject({ + route: '/challenge/request', + reason: 'userDismissed', + }); + expect(notifications.delete).not.toHaveBeenCalled(); + + await runtime.destroy(); + }); + + it('tombstones real exchange notifications and best-effort deletes KERIA notes', async () => { + const store = createAppStore(); + const runtime = createAppRuntime({ store, storage: null }); + const { client, notifications } = makeWorkflowClient(); + connectRuntimeForTest(runtime, client); + + await runtime.dismissExchangeNotification({ + notificationId: 'note-1', + exnSaid: 'Eexn', + route: '/challenge/request', + }); + + expect(store.getState().exchangeTombstones.bySaid.Eexn).toMatchObject({ + notificationId: 'note-1', + }); + expect(notifications.delete).toHaveBeenCalledWith('note-1'); + + await runtime.destroy(); + }); + it('enriches OOBI workflow operations and notifications with copyable payload details', async () => { const store = createAppStore(); const runtime = createAppRuntime({ store, storage: null }); @@ -136,7 +350,9 @@ describe('AppRuntime workflow bridge', () => { ); await vi.waitFor(() => { - expect(store.getState().operations.byId['oobi-success']).toMatchObject({ + expect( + store.getState().operations.byId['oobi-success'] + ).toMatchObject({ status: 'success', payloadDetails: [ expect.objectContaining({ diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 7310c4b3..208b6a05 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -20,6 +20,10 @@ import { selectChallenges, selectChallengesForContact, selectDashboardCounts, + selectActionableChallengeRequestNotifications, + selectChallengeRequestNotificationById, + selectKeriaNotifications, + selectStoredChallengeWordsForContact, } from '../../src/state/selectors'; import { sessionConnected, @@ -30,7 +34,11 @@ import { notificationRecorded } from '../../src/state/notifications.slice'; import { challengeRecorded, challengesLoaded, + storedChallengeWordsCleared, + storedChallengeWordsFailed, + storedChallengeWordsRecorded, } from '../../src/state/challenges.slice'; +import { exchangeTombstoneRecorded } from '../../src/state/exchangeTombstones.slice'; import { contactInventoryLoaded, generatedOobiRecorded, @@ -40,6 +48,10 @@ import { allAppNotificationsRead, appNotificationRecorded, } from '../../src/state/appNotifications.slice'; +import { credentialRecorded } from '../../src/state/credentials.slice'; +import { registryRecorded } from '../../src/state/registry.slice'; +import { roleRecorded } from '../../src/state/roles.slice'; +import { schemaRecorded } from '../../src/state/schema.slice'; describe('RTK state foundation', () => { it('records session connection facts without live capabilities', () => { @@ -178,6 +190,66 @@ describe('RTK state foundation', () => { ); }); + it('filters locally tombstoned EXN notifications from selectors', () => { + const store = createAppStore(); + + store.dispatch( + notificationRecorded({ + id: 'challenge-request:Eexn', + dt: '2026-04-21T00:00:00.000Z', + read: false, + route: '/challenge/request', + anchorSaid: 'Eexn', + status: 'unread', + message: 'Challenge request from Wan', + updatedAt: '2026-04-21T00:00:00.000Z', + challengeRequest: { + notificationId: 'challenge-request:Eexn', + exnSaid: 'Eexn', + senderAid: 'Econtact', + senderAlias: 'Wan', + recipientAid: 'Ealice', + challengeId: 'challenge-1', + wordsHash: 'hash-one', + strength: 128, + createdAt: '2026-04-21T00:00:00.000Z', + status: 'actionable', + }, + }) + ); + + expect(selectKeriaNotifications(store.getState())).toHaveLength(1); + expect( + selectActionableChallengeRequestNotifications(store.getState()) + ).toHaveLength(1); + expect( + selectChallengeRequestNotificationById('challenge-request:Eexn')( + store.getState() + ) + ).not.toBeNull(); + + store.dispatch( + exchangeTombstoneRecorded({ + exnSaid: 'Eexn', + route: '/challenge/request', + notificationId: 'challenge-request:Eexn', + reason: 'userDismissed', + createdAt: '2026-04-21T00:00:01.000Z', + }) + ); + + expect(selectUnreadNotifications(store.getState())).toHaveLength(0); + expect(selectKeriaNotifications(store.getState())).toHaveLength(0); + expect( + selectActionableChallengeRequestNotifications(store.getState()) + ).toHaveLength(0); + expect( + selectChallengeRequestNotificationById('challenge-request:Eexn')( + store.getState() + ) + ).toBeNull(); + }); + it('tracks contact inventory, generated OOBIs, components, and challenges', () => { const store = createAppStore(); @@ -330,6 +402,54 @@ describe('RTK state foundation', () => { ]); }); + it('tracks saved challenge words for failed verification recovery', () => { + const store = createAppStore(); + + store.dispatch( + storedChallengeWordsRecorded({ + challengeId: 'challenge-1', + counterpartyAid: 'Econtact', + counterpartyAlias: 'Wan', + localIdentifier: 'alice', + localAid: 'Ealice', + words: Array.from({ length: 12 }, (_, index) => `word${index}`), + wordsHash: 'hash-one', + strength: 128, + generatedAt: '2026-04-21T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', + status: 'pending', + }) + ); + + expect( + selectStoredChallengeWordsForContact('Econtact')(store.getState()) + ).toEqual([expect.objectContaining({ status: 'pending' })]); + + store.dispatch( + storedChallengeWordsFailed({ + challengeId: 'challenge-1', + updatedAt: '2026-04-21T00:00:01.000Z', + }) + ); + + expect( + selectStoredChallengeWordsForContact('Econtact')(store.getState()) + ).toEqual([ + expect.objectContaining({ + status: 'failed', + updatedAt: '2026-04-21T00:00:01.000Z', + }), + ]); + + store.dispatch( + storedChallengeWordsCleared({ challengeId: 'challenge-1' }) + ); + + expect( + selectStoredChallengeWordsForContact('Econtact')(store.getState()) + ).toEqual([]); + }); + it('clears session-scoped inventory when a new connection starts', () => { const store = createAppStore(); @@ -404,6 +524,43 @@ describe('RTK state foundation', () => { ], }) ); + store.dispatch( + roleRecorded({ + role: 'issuer', + alias: 'Alice', + aid: 'Ealice', + registryId: 'registry-1', + updatedAt: '2026-04-21T00:00:03.000Z', + }) + ); + store.dispatch( + schemaRecorded({ + said: 'schema-1', + oobi: 'http://127.0.0.1:3902/oobi/schema-1', + status: 'resolved', + error: null, + updatedAt: '2026-04-21T00:00:04.000Z', + }) + ); + store.dispatch( + registryRecorded({ + id: 'registry-1', + issuerAid: 'Ealice', + status: 'ready', + error: null, + updatedAt: '2026-04-21T00:00:05.000Z', + }) + ); + store.dispatch( + credentialRecorded({ + said: 'credential-1', + schemaSaid: 'schema-1', + issuerAid: 'Ealice', + holderAid: 'Eholder', + status: 'issued', + updatedAt: '2026-04-21T00:00:06.000Z', + }) + ); store.dispatch(sessionConnecting()); @@ -412,6 +569,11 @@ describe('RTK state foundation', () => { expect(selectUnreadNotifications(store.getState())).toHaveLength(0); expect(store.getState().contacts.generatedOobiIds).toEqual([]); expect(store.getState().identifiers.prefixes).toEqual([]); + expect(store.getState().roles.byRole.issuer.aid).toBeNull(); + expect(store.getState().roles.byRole.issuer.registryId).toBeNull(); + expect(store.getState().schema.saids).toEqual([]); + expect(store.getState().registry.ids).toEqual([]); + expect(store.getState().credentials.saids).toEqual([]); }); it('tracks unread app notifications separately from KERIA notifications', () => {