diff --git a/.eslintrc.json b/.eslintrc.json index 23c23c32b0..5f8f463e0e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -44,7 +44,10 @@ "@typescript-eslint/no-duplicate-enum-values": "error", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-non-null-assertion": "warn", - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], "react/no-unescaped-entities": 0, "react/react-in-jsx-scope": "off", "no-console": "error", diff --git a/src/core/__fixtures__/agent/ipexCommunicationFixtures.ts b/src/core/__fixtures__/agent/ipexCommunicationFixtures.ts index 7cbc851854..5bfc20e0cb 100644 --- a/src/core/__fixtures__/agent/ipexCommunicationFixtures.ts +++ b/src/core/__fixtures__/agent/ipexCommunicationFixtures.ts @@ -431,7 +431,9 @@ const multisigExnGrant = { d: "EE8_Xc0ZUh_sUJLtmBpVSEr-RFS2mRUIpFyL-pmvtPvx", }, }, - pathed: {}, + pathed: { + exn: "d", + }, }; const credentialRecord = { diff --git a/src/core/__fixtures__/agent/keriaNotificationFixtures.ts b/src/core/__fixtures__/agent/keriaNotificationFixtures.ts index 7aa6d68a80..3be9db1eeb 100644 --- a/src/core/__fixtures__/agent/keriaNotificationFixtures.ts +++ b/src/core/__fixtures__/agent/keriaNotificationFixtures.ts @@ -269,7 +269,9 @@ const multisigExnGrant = { d: "EE8_Xc0ZUh_sUJLtmBpVSEr-RFS2mRUIpFyL-pmvtPvx", }, }, - pathed: {}, + pathed: { + exn: "d", + }, }; const notificationMultisigRpyProp = { diff --git a/src/core/__fixtures__/agent/multiSigFixtures.ts b/src/core/__fixtures__/agent/multiSigFixtures.ts index 3a3b05b5d1..65baca2ea3 100644 --- a/src/core/__fixtures__/agent/multiSigFixtures.ts +++ b/src/core/__fixtures__/agent/multiSigFixtures.ts @@ -1,4 +1,4 @@ -import { CreateIdentifierBody, Tier } from "signify-ts"; +import { CreateIdentifierBody, HabState, Tier } from "signify-ts"; import { ConnectionStatus, CreationStatus } from "../../agent/agent.types"; import { IdentifierMetadataRecord, @@ -862,7 +862,7 @@ const linkedContacts = [ const queuedIdentifier: QueuedGroupCreation & { initiator: true } = { name: "1.2.0.2:0:Identifier 2", - data: inceptionDataFix, + data: inceptionDataFix as CreateIdentifierBody & { group: HabState }, initiator: true, groupConnections: linkedContacts, threshold: { @@ -873,7 +873,7 @@ const queuedIdentifier: QueuedGroupCreation & { initiator: true } = { const queuedJoin: QueuedGroupCreation & { initiator: false } = { name: "0:testUser", - data: inceptionDataFix, + data: inceptionDataFix as CreateIdentifierBody & { group: HabState }, initiator: false, notificationId: "notification-id", notificationSaid: "notification-said", diff --git a/src/core/agent/agent.types.ts b/src/core/agent/agent.types.ts index 9dfd7cb397..ccefb96587 100644 --- a/src/core/agent/agent.types.ts +++ b/src/core/agent/agent.types.ts @@ -120,15 +120,12 @@ interface ExnMessageA { }; d?: string; r?: string; - exn?: { - r: string; - p: string; - }; + exn?: unknown; } // Define types for the 'e' property in ExnMessage interface ExnMessageE { - acdc: { + acdc?: { d: string; i: string; s: string; @@ -152,10 +149,7 @@ interface ExnMessageE { icp: { i: string; }; - exn: { - r: string; - p: string; - }; + exn: unknown; [key: string]: unknown; } @@ -181,6 +175,13 @@ type ExnMessage = { }; }; +// Type guard to check if ExnMessageE has acdc +function exnHasAcdc( + e: ExnMessageE +): e is ExnMessageE & { acdc: NonNullable } { + return e.acdc !== undefined && e.acdc !== null; +} + type ConnectionNoteProps = Pick; interface ConnectionDetailsExtras { @@ -264,6 +265,10 @@ export const OOBI_AGENT_ONLY_RE = export const DOOBI_RE = /^\/oobi\/(?[^/]+)$/i; export const WOOBI_RE = /^\/\.well-known\/keri\/oobi\/(?[^/]+)$/; +// Common error messages +export const SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED = + "Signify client manager not initialized"; + export { ConnectionStatus, MiscRecordId, @@ -271,6 +276,7 @@ export { CreationStatus, isRegularConnectionDetails, isMultisigConnectionDetails, + exnHasAcdc, }; export type { diff --git a/src/core/agent/services/connectionService.test.ts b/src/core/agent/services/connectionService.test.ts index dba97ce864..9a1aeda52b 100644 --- a/src/core/agent/services/connectionService.test.ts +++ b/src/core/agent/services/connectionService.test.ts @@ -1911,7 +1911,7 @@ describe("Connection service of agent", () => { }); expect(result.historyItems).toHaveLength(2); expect( - result.historyItems?.some( + result.historyItems.some( (item) => item.type === ConnectionHistoryType.IPEX_AGREE_COMPLETE ) ).toBe(true); @@ -1991,7 +1991,7 @@ describe("Connection service of agent", () => { }); expect(result.historyItems).toHaveLength(1); expect( - result.historyItems?.some( + result.historyItems.some( (item) => item.type === ConnectionHistoryType.IPEX_AGREE_COMPLETE ) ).toBe(false); diff --git a/src/core/agent/services/connectionService.ts b/src/core/agent/services/connectionService.ts index 1babc6666c..25b9161a37 100644 --- a/src/core/agent/services/connectionService.ts +++ b/src/core/agent/services/connectionService.ts @@ -258,18 +258,22 @@ class ConnectionService extends AgentService { } const contact = contactRecordMap.get(connectionPair.contactId); - if (contact?.alias && contact?.oobi) { - connections.push({ - id: connectionPair.contactId, - alias: contact.alias, - createdAt: connectionPair.createdAt, - oobi: contact.oobi, - groupId: contact.groupId, - creationStatus: connectionPair.creationStatus, - pendingDeletion: connectionPair.pendingDeletion, - identifier: connectionPair.identifier, // Include identifier from connection pair - }); + if (!contact) { + throw new Error( + `Contact missing from map for contactId: ${connectionPair.contactId}` + ); } + + connections.push({ + id: connectionPair.contactId, + alias: contact.alias, + createdAt: connectionPair.createdAt, + oobi: contact.oobi, + groupId: contact.groupId, + creationStatus: connectionPair.creationStatus, + pendingDeletion: connectionPair.pendingDeletion, + identifier: connectionPair.identifier, // Include identifier from connection pair + }); } return connections.map((connection) => @@ -312,17 +316,20 @@ class ConnectionService extends AgentService { status = ConnectionStatus.FAILED; } - return { + const baseDetails = { id: record.id, label: record.alias, createdAtUTC: record.createdAt.toISOString(), status, oobi: record.oobi, contactId: record.id, - ...(record.groupId - ? { groupId: record.groupId } - : { identifier: record.identifier || "" }), }; + + if (record.groupId !== undefined) { + return { ...baseDetails, groupId: record.groupId }; + } + + return { ...baseDetails, identifier: record.identifier || "" }; } async getConnectionById( diff --git a/src/core/agent/services/credentialService.types.ts b/src/core/agent/services/credentialService.types.ts index e3e874f304..870f83ed95 100644 --- a/src/core/agent/services/credentialService.types.ts +++ b/src/core/agent/services/credentialService.types.ts @@ -8,6 +8,21 @@ enum CredentialStatus { type CredentialShortDetails = Omit; +interface ACDC { + v: string; + d: string; + i: string; + ri: string; + s: string; + a: { + d: string; + i: string; + dt: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + interface ACDCDetails extends Omit { i: string; @@ -45,4 +60,4 @@ interface KeriaCredential { } export { CredentialStatus }; -export type { CredentialShortDetails, ACDCDetails, KeriaCredential }; +export type { CredentialShortDetails, ACDCDetails, KeriaCredential, ACDC }; diff --git a/src/core/agent/services/identifier.types.ts b/src/core/agent/services/identifier.types.ts index 3e8a745f53..7a5e9818ba 100644 --- a/src/core/agent/services/identifier.types.ts +++ b/src/core/agent/services/identifier.types.ts @@ -1,4 +1,4 @@ -import { CreateIdentifierBody } from "signify-ts"; +import { CreateIdentifierBody, HabState } from "signify-ts"; import { MultisigConnectionDetails, CreationStatus, @@ -61,29 +61,46 @@ enum IdentifierType { Group = "group", } -type QueuedIdentifierCreation = { - name: string; - data: CreateIdentifierBody; -}; - interface MultisigThresholds { signingThreshold: number; rotationThreshold: number; } -type QueuedGroupProps = +type QueuedGroupCreation = | { initiator: true; + name: string; + data: CreateIdentifierBody & { group: HabState }; groupConnections: MultisigConnectionDetails[]; threshold: number | MultisigThresholds; } | { initiator: false; + name: string; + data: CreateIdentifierBody & { group: HabState }; notificationId: string; notificationSaid: string; }; -type QueuedGroupCreation = QueuedIdentifierCreation & QueuedGroupProps; +// Type guard to check if CreateIdentifierBody has group data +function isGroupInceptionData( + data: CreateIdentifierBody +): data is CreateIdentifierBody & { group: HabState } { + return data.group !== undefined; +} + +// Helper type used in multiSigService for generating inception data +type QueuedGroupProps = + | { + initiator: true; + groupConnections: MultisigConnectionDetails[]; + threshold: number | MultisigThresholds; + } + | { + initiator: false; + notificationId: string; + notificationSaid: string; + }; interface GroupParticipants { ourIdentifier: IdentifierMetadataRecord; @@ -110,6 +127,8 @@ interface RemoteSignRequest { payload: JSONObject; } +export { IdentifierType, isGroupInceptionData }; + export type { IdentifierShortDetails, IdentifierDetails, @@ -117,12 +136,7 @@ export type { CreateIdentifierInputs, CreateIdentifierResult, MultisigThresholds, -}; - -export { IdentifierType }; -export type { GroupMetadata, - QueuedIdentifierCreation, QueuedGroupProps, QueuedGroupCreation, GroupParticipants, diff --git a/src/core/agent/services/identifierService.ts b/src/core/agent/services/identifierService.ts index 0830f3d507..ed46c210fb 100644 --- a/src/core/agent/services/identifierService.ts +++ b/src/core/agent/services/identifierService.ts @@ -230,6 +230,10 @@ class IdentifierService extends AgentService { metadata: Omit, backgroundTask = false ): Promise { + if (!this.props.signifyClient.agent) { + throw new Error("Agent not initialized"); + } + const { toad, witnesses } = await this.getAvailableWitnesses(); if (!UI_THEMES.includes(metadata.theme)) { @@ -303,9 +307,6 @@ class IdentifierService extends AgentService { .identifiers() .get(identifier)) as HabState; - if (!this.props.signifyClient.agent) { - throw new Error("Agent not initialized"); - } const addRoleOperation = await this.props.signifyClient .identifiers() .addEndRole(identifier, "agent", this.props.signifyClient.agent.pre); @@ -658,16 +659,7 @@ class IdentifierService extends AgentService { } async syncKeriaIdentifiers(): Promise { - const cloudIdentifiers: Array<{ - prefix: string; - name: string; - group?: { - mhab: { - name: string; - prefix: string; - }; - }; - }> = []; + const cloudIdentifiers: HabState[] = []; let returned = -1; let iteration = 0; @@ -683,8 +675,10 @@ class IdentifierService extends AgentService { const localIdentifiers = await this.identifierStorage.getAllIdentifiers(); - const unSyncedDataWithGroup = []; - const unSyncedDataWithoutGroup = []; + const unSyncedDataWithGroup: (HabState & { + group: NonNullable; + })[] = []; + const unSyncedDataWithoutGroup: HabState[] = []; for (const identifier of cloudIdentifiers) { if (localIdentifiers.find((item) => item.id === identifier.prefix)) { continue; @@ -693,7 +687,9 @@ class IdentifierService extends AgentService { if (identifier.group === undefined) { unSyncedDataWithoutGroup.push(identifier); } else { - unSyncedDataWithGroup.push(identifier); + unSyncedDataWithGroup.push( + identifier as HabState & { group: NonNullable } + ); } } @@ -762,9 +758,6 @@ class IdentifierService extends AgentService { .identifiers() .get(identifier.prefix)) as HabState; - if (!identifier.group) { - throw new Error("Group identifier missing group data"); - } const nameToParse = identifier.name.startsWith( IdentifierService.DELETED_IDENTIFIER_THEME ) diff --git a/src/core/agent/services/ipexCommunicationService.test.ts b/src/core/agent/services/ipexCommunicationService.test.ts index 0b4c43b806..792af1a94f 100644 --- a/src/core/agent/services/ipexCommunicationService.test.ts +++ b/src/core/agent/services/ipexCommunicationService.test.ts @@ -1332,21 +1332,20 @@ describe("Offer ACDC individual actions", () => { }); markNotificationMock.mockResolvedValueOnce({ status: "done" }); - const credentialProps: CredentialMetadataRecordProps = { - id: "credential-id", - issuanceDate: "2024-01-01T00:00:00.000Z", - credentialType: "Test Credential", - status: CredentialStatus.CONFIRMED, - connectionId: "connection-id", - schema: "schema-said", - identifierId: "identifier-id", - identifierType: IdentifierType.Individual, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - isArchived: false, - pendingDeletion: false, + const acdcData = { + v: "ACDC10JSON000197_", + d: "credential-id", + i: "issuer-aid", + ri: "registry-aid", + s: "schema-said", + a: { + d: "attribute-said", + i: "holder-aid", + dt: "2024-01-01T00:00:00.000Z", + }, }; - await ipexCommunicationService.offerAcdcFromApply(id, credentialProps); + await ipexCommunicationService.offerAcdcFromApply(id, acdcData); expect(operationPendingStorage.save).toBeCalledWith({ id: "opName", @@ -1355,7 +1354,7 @@ describe("Offer ACDC individual actions", () => { expect(ipexOfferMock).toBeCalledWith({ senderName: "abc123", recipient: "i", - acdc: new Serder(credentialProps), + acdc: new Serder(acdcData), applySaid: "d", }); expect(ipexSubmitOfferMock).toBeCalledWith( @@ -1388,22 +1387,21 @@ describe("Offer ACDC individual actions", () => { eventEmitter.emit = jest.fn(); notificationStorage.findById.mockResolvedValueOnce(null); - const credentialProps: CredentialMetadataRecordProps = { - id: "credential-id", - issuanceDate: "2024-01-01T00:00:00.000Z", - credentialType: "Test Credential", - status: CredentialStatus.CONFIRMED, - connectionId: "connection-id", - schema: "schema-said", - identifierId: "identifier-id", - identifierType: IdentifierType.Individual, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - isArchived: false, - pendingDeletion: false, + const acdcData = { + v: "ACDC10JSON000197_", + d: "credential-id", + i: "issuer-aid", + ri: "registry-aid", + s: "schema-said", + a: { + d: "attribute-said", + i: "holder-aid", + dt: "2024-01-01T00:00:00.000Z", + }, }; await expect( - ipexCommunicationService.offerAcdcFromApply(id, credentialProps) + ipexCommunicationService.offerAcdcFromApply(id, acdcData) ).rejects.toThrowError( `${IpexCommunicationService.NOTIFICATION_NOT_FOUND} ${id}` ); @@ -1462,26 +1460,25 @@ describe("Offer ACDC group actions", () => { recordType: OperationPendingRecordType.ExchangeOfferCredential, }); - const credentialProps: CredentialMetadataRecordProps = { - id: "credential-id", - issuanceDate: "2024-01-01T00:00:00.000Z", - credentialType: "Test Credential", - status: CredentialStatus.CONFIRMED, - connectionId: "connection-id", - schema: "schema-said", - identifierId: "identifier-id", - identifierType: IdentifierType.Group, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - isArchived: false, - pendingDeletion: false, + const acdcData = { + v: "ACDC10JSON000197_", + d: "credential-id", + i: "issuer-aid", + ri: "registry-aid", + s: "schema-said", + a: { + d: "attribute-said", + i: "holder-aid", + dt: "2024-01-01T00:00:00.000Z", + }, }; - await ipexCommunicationService.offerAcdcFromApply(id, credentialProps); + await ipexCommunicationService.offerAcdcFromApply(id, acdcData); expect(ipexOfferMock).toBeCalledWith({ senderName: "EC1cyV3zLnGs4B9AYgoGNjXESyQZrBWygz3jLlRD30bR", recipient: "ECS7jn05fIP_JK1Ub4E6hPviRKEdC55QhxZToxDIHo_E", - acdc: new Serder(credentialProps), + acdc: new Serder(acdcData), applySaid: "EIDUavcmyHBseNZAdAHR3SF8QMfX1kSJ3Ct0OqS0-HCW", message: "", datetime: expect.any(String), @@ -1525,22 +1522,21 @@ describe("Offer ACDC group actions", () => { }; notificationStorage.findById.mockResolvedValue(applyNoteRecord); - const credentialProps: CredentialMetadataRecordProps = { - id: "credential-id", - issuanceDate: "2024-01-01T00:00:00.000Z", - credentialType: "Test Credential", - status: CredentialStatus.CONFIRMED, - connectionId: "connection-id", - schema: "schema-said", - identifierId: "identifier-id", - identifierType: IdentifierType.Individual, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - isArchived: false, - pendingDeletion: false, + const acdcData = { + v: "ACDC10JSON000197_", + d: "credential-id", + i: "issuer-aid", + ri: "registry-aid", + s: "schema-said", + a: { + d: "attribute-said", + i: "holder-aid", + dt: "2024-01-01T00:00:00.000Z", + }, }; await expect( - ipexCommunicationService.offerAcdcFromApply("id", credentialProps) + ipexCommunicationService.offerAcdcFromApply("id", acdcData) ).rejects.toThrowError(IpexCommunicationService.IPEX_ALREADY_REPLIED); expect(ipexOfferMock).not.toBeCalled(); @@ -2539,22 +2535,21 @@ describe("IPEX communication service of agent", () => { receivingPre: "EGR7Jm38EcsXRIidKDZBYDm_xox6eapfU1tqxdAUzkFA", }; - const credentialProps: CredentialMetadataRecordProps = { - id: "credential-id", - issuanceDate: "2024-01-01T00:00:00.000Z", - credentialType: "Test Credential", - status: CredentialStatus.CONFIRMED, - connectionId: "connection-id", - schema: "schema-said", - identifierId: "identifier-id", - identifierType: IdentifierType.Individual, - createdAt: new Date("2024-01-01T00:00:00.000Z"), - isArchived: false, - pendingDeletion: false, + const acdcData = { + v: "ACDC10JSON000197_", + d: "credential-id", + i: "issuer-aid", + ri: "registry-aid", + s: "schema-said", + a: { + d: "attribute-said", + i: "holder-aid", + dt: "2024-01-01T00:00:00.000Z", + }, }; await expect( - ipexCommunicationService.offerAcdcFromApply(noti.id, credentialProps) + ipexCommunicationService.offerAcdcFromApply(noti.id, acdcData) ).rejects.toThrowError(Agent.KERIA_CONNECTION_BROKEN); await expect( ipexCommunicationService.grantAcdcFromAgree(noti.a.d) diff --git a/src/core/agent/services/ipexCommunicationService.ts b/src/core/agent/services/ipexCommunicationService.ts index 2cd91c6811..7550400d0b 100644 --- a/src/core/agent/services/ipexCommunicationService.ts +++ b/src/core/agent/services/ipexCommunicationService.ts @@ -9,8 +9,11 @@ import { Siger, } from "signify-ts"; import type { ExnMessage, AgentServicesProps } from "../agent.types"; +import { + exnHasAcdc, + SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED, +} from "../agent.types"; import type { KeriaNotification } from "./keriaNotificationService.types"; -import type { IpexGrantMultiSigExn } from "./multiSig.types"; import { ExchangeRoute } from "./keriaNotificationService.types"; import { CredentialStorage, @@ -24,6 +27,7 @@ import { import type { CredentialMetadataRecordProps } from "../records/credentialMetadataRecord.types"; import { AgentService } from "./agentService"; import { getCredentialShortDetails, OnlineOnly } from "./utils"; +import type { ACDC } from "./credentialService.types"; import { CredentialStatus, ACDCDetails, @@ -36,7 +40,11 @@ import { } from "./ipexCommunicationService.types"; import { OperationPendingRecordType } from "../records/operationPendingRecord.type"; import { MultiSigService } from "./multiSigService"; -import { GrantToJoinMultisigExnPayload, MultiSigRoute } from "./multiSig.types"; +import { + GrantToJoinMultisigExnPayload, + MultiSigRoute, + isMultiSigExn, +} from "./multiSig.types"; import { AcdcStateChangedEvent, EventTypes } from "../event.types"; import { ConnectionService } from "./connectionService"; import { IdentifierType } from "./identifier.types"; @@ -211,10 +219,7 @@ class IpexCommunicationService extends AgentService { } @OnlineOnly - async offerAcdcFromApply( - notificationId: string, - acdc: CredentialMetadataRecordProps - ): Promise { + async offerAcdcFromApply(notificationId: string, acdc: ACDC): Promise { const applyNoteRecord = await this.notificationStorage.findById( notificationId ); @@ -531,7 +536,11 @@ class IpexCommunicationService extends AgentService { } let schemaSaid; - if (message.exn.r === ExchangeRoute.IpexGrant && message.exn.e.acdc) { + // Type narrowing: IpexGrant route requires acdc + if ( + message.exn.r === ExchangeRoute.IpexGrant && + exnHasAcdc(message.exn.e) + ) { schemaSaid = message.exn.e.acdc.s; } else if (message.exn.r === ExchangeRoute.IpexApply) { schemaSaid = message.exn.a.s; @@ -542,6 +551,12 @@ class IpexCommunicationService extends AgentService { const previousExchange = await this.props.signifyClient .exchanges() .get(message.exn.p); + // Type narrowing: IpexAgree and IpexAdmit routes require acdc in previous exchange + if (!exnHasAcdc(previousExchange.exn.e)) { + throw new Error( + `${message.exn.r} message's previous exchange must have e.acdc` + ); + } schemaSaid = previousExchange.exn.e.acdc.s; } @@ -558,8 +573,13 @@ class IpexCommunicationService extends AgentService { let key; switch (historyType) { case ConnectionHistoryType.CREDENTIAL_REVOKED: + // Type narrowing: CREDENTIAL_REVOKED messages must have acdc + if (!exnHasAcdc(message.exn.e)) { + throw new Error("CREDENTIAL_REVOKED message must have e.acdc"); + } prefix = KeriaContactKeyPrefix.HISTORY_REVOKE; - key = message.exn.e.acdc?.d; + // TypeScript now knows message.exn.e.acdc exists + key = message.exn.e.acdc.d; break; case ConnectionHistoryType.CREDENTIAL_ISSUANCE: case ConnectionHistoryType.CREDENTIAL_REQUEST_PRESENT: @@ -726,11 +746,15 @@ class IpexCommunicationService extends AgentService { throw new Error(IpexCommunicationService.NO_CURRENT_IPEX_MSG_TO_JOIN); } - const grantExn = multiSigExn.exn.e.exn as unknown as { - i: string; - p: string; - e: { acdc: { i: string; d: string; s: string } }; - }; + // Type narrowing: Validate multiSigExn structure first + if (!isMultiSigExn(multiSigExn)) { + throw new Error( + "Invalid multisig exchange structure for joinMultisigGrant: missing required fields" + ); + } + + // After type narrowing, TypeScript knows the structure is correct + const grantExn = multiSigExn.exn.e.exn; // Create the credential structure expected by submitMultisigGrant const credentialData = { @@ -745,8 +769,8 @@ class IpexCommunicationService extends AgentService { grantExn.p, credentialData, { - grantExn: multiSigExn.exn.e.exn as unknown as IpexGrantMultiSigExn, - atc: multiSigExn.pathed.exn as string, + grantExn: multiSigExn.exn.e.exn, + atc: multiSigExn.pathed.exn, } ); @@ -765,10 +789,14 @@ class IpexCommunicationService extends AgentService { private async submitMultisigOffer( multisigId: string, notificationSaid: string, - acdcDetail: CredentialMetadataRecordProps, + acdcDetail: ACDC, discloseePrefix: string, - offerExnToJoin?: { ked: unknown } + offerExnToJoin?: Record ): Promise { + if (!this.props.signifyClient.manager) { + throw new Error(SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED); + } + let exn: Serder; let sigsMes: string[]; let mend: string; @@ -790,9 +818,6 @@ class IpexCommunicationService extends AgentService { if (offerExnToJoin) { const [, ked] = Saider.saidify(offerExnToJoin); const offer = new Serder(ked); - if (!this.props.signifyClient.manager) { - throw new Error("Signify client manager not initialized"); - } const keeper = this.props.signifyClient.manager.get(gHab); const sigs = await keeper.sign(b(new Serder(offerExnToJoin).raw)); @@ -883,6 +908,10 @@ class IpexCommunicationService extends AgentService { }, grantToJoin?: GrantToJoinMultisigExnPayload ): Promise { + if (!this.props.signifyClient.manager) { + throw new Error(SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED); + } + let exn: Serder; let sigsMes: string[]; let mend: string; @@ -902,9 +931,6 @@ class IpexCommunicationService extends AgentService { const { grantExn, atc } = grantToJoin; const [, ked] = Saider.saidify(grantExn); const grant = new Serder(ked); - if (!this.props.signifyClient.manager) { - throw new Error("Signify client manager not initialized"); - } const keeper = this.props.signifyClient.manager.get(gHab); const sigs = await keeper.sign(b(new Serder(grantExn).raw)); const mstateNew = gHab["state"]; @@ -1039,8 +1065,12 @@ class IpexCommunicationService extends AgentService { multisigId: string, grantExn: ExnMessage, schemaSaids: string[], - admitExnToJoin?: { ked: unknown } + admitExnToJoin?: Record ): Promise { + if (!this.props.signifyClient.manager) { + throw new Error(SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED); + } + let exn: Serder; let sigsMes: string[]; let mend: string; @@ -1075,9 +1105,6 @@ class IpexCommunicationService extends AgentService { if (admitExnToJoin) { const [, ked] = Saider.saidify(admitExnToJoin); const admit = new Serder(ked); - if (!this.props.signifyClient.manager) { - throw new Error("Signify client manager not initialized"); - } const keeper = this.props.signifyClient.manager.get(gHab); const sigs = await keeper.sign(b(new Serder(admitExnToJoin).raw)); diff --git a/src/core/agent/services/ipexCommunicationService.types.ts b/src/core/agent/services/ipexCommunicationService.types.ts index 225433b13d..a0c4c90571 100644 --- a/src/core/agent/services/ipexCommunicationService.types.ts +++ b/src/core/agent/services/ipexCommunicationService.types.ts @@ -2,6 +2,8 @@ import { Operation } from "signify-ts"; import { LinkedRequest } from "../records/notificationRecord.types"; import { JSONObject } from "../agent.types"; import { MultisigThresholds } from "./identifier.types"; +import { ACDC } from "./credentialService.types"; + interface CredentialsMatchingApply { schema: { name: string; @@ -9,7 +11,7 @@ interface CredentialsMatchingApply { }; credentials: { connectionId: string; - acdc: Record; + acdc: ACDC; }[]; attributes: JSONObject; identifier: string; diff --git a/src/core/agent/services/keriaNotificationService.ts b/src/core/agent/services/keriaNotificationService.ts index e532205173..fdb8eb4c21 100644 --- a/src/core/agent/services/keriaNotificationService.ts +++ b/src/core/agent/services/keriaNotificationService.ts @@ -5,6 +5,7 @@ import { ConnectionStatus, MiscRecordId, CreationStatus, + exnHasAcdc, } from "../agent.types"; import { KeriaNotificationMarker, @@ -48,6 +49,20 @@ import { ConnectionHistoryType, ExnMessage } from "./connectionService.types"; import { NotificationAttempts } from "../records/notificationRecord.types"; import { StorageMessage } from "../../storage/storage.types"; import { IdentifierService } from "./identifierService"; + +// Type guard for exchange data with route information +function isExnWithRoute( + exn: unknown +): exn is { r: string; p: string; [key: string]: unknown } { + return ( + typeof exn === "object" && + exn !== null && + "r" in exn && + "p" in exn && + typeof (exn as { r: unknown }).r === "string" && + typeof (exn as { p: unknown }).p === "string" + ); +} import { ConnectionService } from "./connectionService"; import { LATEST_CONTACT_VERSION } from "../../storage/sqliteStorage/cloudMigrations"; @@ -510,6 +525,10 @@ class KeriaNotificationService extends AgentService { exchange: ExnMessage ): Promise { // Only consider issuances for now + // Type narrowing: IpexGrant route must have e.acdc + if (!exnHasAcdc(exchange.exn.e)) { + throw new Error(`IpexGrant notification must have e.acdc: ${notif.i}`); + } const ourIdentifier = await this.identifierStorage .getIdentifierMetadata(exchange.exn.e.acdc.a.i) @@ -717,6 +736,12 @@ class KeriaNotificationService extends AgentService { ): Promise { const exnData = exchange.exn.e?.exn; + if (!isExnWithRoute(exnData)) { + throw new Error( + `Invalid exn data structure in processMultiSigExnNotification: ${notif.i}` + ); + } + switch (exnData.r) { case ExchangeRoute.IpexAdmit: { const grantNotificationRecords = @@ -1270,7 +1295,9 @@ class KeriaNotificationService extends AgentService { connectionPairRecord.contactId ); if (!contact) { - continue; + throw new Error( + `Contact not found for connection pair: ${connectionPairRecord.contactId}` + ); } await this.props.signifyClient diff --git a/src/core/agent/services/multiSig.types.ts b/src/core/agent/services/multiSig.types.ts index be445b2fd9..19fa1c08a0 100644 --- a/src/core/agent/services/multiSig.types.ts +++ b/src/core/agent/services/multiSig.types.ts @@ -95,7 +95,12 @@ interface IpexGrantMultiSigExn { m: string; }; e: { - acdc: unknown; // @TODO - foconnor: We can type these. + acdc: { + d: string; + i: string; + s: string; + [key: string]: unknown; + }; iss: unknown; anc: unknown; d: string; @@ -104,6 +109,45 @@ interface IpexGrantMultiSigExn { d: string; }; }; + pathed: { + exn: string; + }; +} + +// Type guard for multisig exchange messages +function isMultiSigExn(obj: unknown): obj is IpexGrantMultiSigExn { + if (typeof obj !== "object" || obj === null) return false; + + const candidate = obj as { + exn?: unknown; + pathed?: unknown; + }; + + // Check pathed.exn is string + if ( + typeof candidate.pathed !== "object" || + candidate.pathed === null || + typeof (candidate.pathed as { exn?: unknown }).exn !== "string" + ) { + return false; + } + + // Check exn structure + if (typeof candidate.exn !== "object" || candidate.exn === null) { + return false; + } + + const exn = candidate.exn as { + e?: unknown; + }; + + // Check exn.e.exn exists + if (typeof exn.e !== "object" || exn.e === null) { + return false; + } + + const e = exn.e as { exn?: unknown }; + return typeof e.exn === "object" && e.exn !== null; } interface IpexAdmitMultiSigRequest { @@ -128,8 +172,11 @@ interface IpexAdmitMultiSigRequest { }; } +// Extract the inner exn type from IpexGrantMultiSigExn +type InnerGrantExn = IpexGrantMultiSigExn["exn"]["e"]["exn"]; + interface GrantToJoinMultisigExnPayload { - grantExn: IpexGrantMultiSigExn; + grantExn: InnerGrantExn; atc: string; } @@ -144,7 +191,7 @@ interface GroupInformation { members: GroupMemberInfo[]; } -export { MultiSigRoute }; +export { MultiSigRoute, isMultiSigExn }; export type { RotationMultiSigExnMessage, diff --git a/src/core/agent/services/multiSigService.ts b/src/core/agent/services/multiSigService.ts index d095851953..a3ac6dd09d 100644 --- a/src/core/agent/services/multiSigService.ts +++ b/src/core/agent/services/multiSigService.ts @@ -15,6 +15,7 @@ import { AgentServicesProps, MiscRecordId, CreationStatus, + SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED, } from "../agent.types"; import { NotificationRoute } from "./keriaNotificationService.types"; import type { @@ -35,6 +36,7 @@ import { MultiSigIcpRequestDetails, QueuedGroupCreation, QueuedGroupProps, + isGroupInceptionData, } from "./identifier.types"; import type { MultisigThresholds } from "./identifier.types"; import { @@ -77,6 +79,8 @@ class MultiSigService extends AgentService { "Cannot retry creating group identifier if retry data is missing from the DB"; static readonly MULTI_SIG_INCEPTION_EXCHANGE_MESSAGE_NOT_FOUND = "Cannot find inception exchange message for multisigId"; + static readonly GROUP_DATA_MISSING_FOR_INITIATOR = + "Group data missing for initiator"; protected readonly identifierStorage: IdentifierStorage; protected readonly operationPendingStorage: OperationPendingStorage; @@ -307,7 +311,27 @@ class MultiSigService extends AgentService { rstates: states, }); - queued.push({ name: groupName, data: inceptionData, ...queuedProps }); + if (!isGroupInceptionData(inceptionData)) { + throw new Error(MultiSigService.GROUP_DATA_MISSING_FOR_INITIATOR); + } + + if (queuedProps.initiator) { + queued.push({ + initiator: true, + name: groupName, + data: inceptionData, + groupConnections: queuedProps.groupConnections, + threshold: queuedProps.threshold, + }); + } else { + queued.push({ + initiator: false, + name: groupName, + data: inceptionData, + notificationId: queuedProps.notificationId, + notificationSaid: queuedProps.notificationSaid, + }); + } await this.basicStorage.createOrUpdateBasicRecord( new BasicRecord({ @@ -370,10 +394,11 @@ class MultiSigService extends AgentService { mHab: HabState, groupConnections: ConnectionShortDetails[] ): Promise { - const { witnesses } = await this.identifiers.getAvailableWitnesses(); if (!this.props.signifyClient.manager) { - throw new Error("Signify client manager not initialized"); + throw new Error(SIGNIFY_CLIENT_MANAGER_NOT_INITIALIZED); } + + const { witnesses } = await this.identifiers.getAvailableWitnesses(); const keeper = this.props.signifyClient.manager.get(mHab); for (const witness of witnesses) { @@ -797,14 +822,10 @@ class MultiSigService extends AgentService { for (const queued of pendingGroupsRecord.content .queued as QueuedGroupCreation[]) { if (queued.initiator) { - if (!queued.data.group) { - throw new Error("Group data missing for initiator"); - } - const threshold = queued.threshold; await this.createGroup( queued.data.group.mhab.prefix, - queued.groupConnections as MultisigConnectionDetails[], + queued.groupConnections, threshold, true ); diff --git a/src/core/storage/ionicStorage/ionicStorage.ts b/src/core/storage/ionicStorage/ionicStorage.ts index 4463a88820..e991d9b093 100644 --- a/src/core/storage/ionicStorage/ionicStorage.ts +++ b/src/core/storage/ionicStorage/ionicStorage.ts @@ -5,6 +5,7 @@ import { StorageService, BaseRecordConstructor, StorageMessage, + Tags, } from "../storage.types"; import { deserializeRecord } from "../utils"; import { BasicRecord } from "../../agent/records"; @@ -114,7 +115,7 @@ class IonicStorage implements StorageService { } private checkRecordIsValidWithQuery( - record: Record, + record: unknown, query: Query ): boolean { for (const [queryKey, queryVal] of Object.entries(query)) { @@ -141,7 +142,7 @@ class IonicStorage implements StorageService { } else { if (Array.isArray(queryVal) && queryVal.length > 0) { // compare them item by item - const tags = record.tags as Record; + const tags = (record as { tags: Tags }).tags; const check = queryVal.every( (element) => Array.isArray(tags?.[queryKey]) && @@ -151,7 +152,7 @@ class IonicStorage implements StorageService { return false; } } else { - const tags = record.tags as Record; + const tags = (record as { tags: Tags }).tags; if (tags[queryKey] !== queryVal) { // If you query for an unknown tag `m` that is not a part of the record with `{ m: undefined }` all items will match this query. This behaves differently in SQLite storage - this risk is accepted since we will only query for known tags and Ionic Storage is only used in development. return false; diff --git a/src/core/storage/sqliteStorage/migrations/migrationUtils.ts b/src/core/storage/sqliteStorage/migrations/migrationUtils.ts index 4977914952..a684c1a0be 100644 --- a/src/core/storage/sqliteStorage/migrations/migrationUtils.ts +++ b/src/core/storage/sqliteStorage/migrations/migrationUtils.ts @@ -1,3 +1,5 @@ +import { Tags } from "../../storage.types"; + /** * Utility functions for migrations */ @@ -9,7 +11,7 @@ */ export function createInsertItemTagsStatements(itemRecord: { id: string; - tags?: Record; + tags?: Tags; }): { statement: string; values?: unknown[] }[] { const statements: { statement: string; values?: unknown[] }[] = []; const statement = diff --git a/src/core/storage/sqliteStorage/migrations/v1.2.0.1-connections-per-account.ts b/src/core/storage/sqliteStorage/migrations/v1.2.0.1-connections-per-account.ts index 55ebb7537d..f62673c6e3 100644 --- a/src/core/storage/sqliteStorage/migrations/v1.2.0.1-connections-per-account.ts +++ b/src/core/storage/sqliteStorage/migrations/v1.2.0.1-connections-per-account.ts @@ -1,3 +1,5 @@ +import { Tags } from "../../storage.types"; +import { IdentifierMetadataRecordProps } from "../../../agent/records/identifierMetadataRecord"; import { MigrationType, TsMigration } from "./migrations.types"; import { createInsertItemTagsStatements, @@ -15,9 +17,12 @@ export const DATA_V1201: TsMigration = { let identifiers = identifierResult.values; identifiers = identifiers - ?.map((identifier: { value: string }) => JSON.parse(identifier.value)) + ?.map( + (row: { value: string }): IdentifierMetadataRecordProps => + JSON.parse(row.value) as IdentifierMetadataRecordProps + ) .filter( - (identifier: { isDeleted?: boolean; pendingDeletion?: boolean }) => + (identifier: IdentifierMetadataRecordProps) => !identifier.isDeleted && !identifier.pendingDeletion ); @@ -67,7 +72,7 @@ export const DATA_V1201: TsMigration = { identifier: string; creationStatus: string; pendingDeletion: boolean; - tags: Record; + tags: Tags; type: string; }> = []; diff --git a/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.test.tsx b/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.test.tsx index 1b5c727ea2..a93c8a386f 100644 --- a/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.test.tsx +++ b/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.test.tsx @@ -4,7 +4,10 @@ import { IonReactMemoryRouter } from "@ionic/react-router"; import { act, fireEvent, render, waitFor } from "@testing-library/react"; import { createMemoryHistory } from "history"; import { Provider } from "react-redux"; -import { CredentialStatus } from "../../../../../../core/agent/services/credentialService.types"; +import { + ACDC, + CredentialStatus, +} from "../../../../../../core/agent/services/credentialService.types"; import { KeriaNotification } from "../../../../../../core/agent/services/keriaNotificationService.types"; import { KeyStoreKeys, SecureStorage } from "../../../../../../core/storage"; import EN_TRANSLATIONS from "../../../../../../locales/en/en.json"; @@ -21,7 +24,6 @@ import { } from "../../../../../utils/formatters"; import { makeTestStore } from "../../../../../utils/makeTestStore"; import { passcodeFiller } from "../../../../../utils/passcodeFiller"; -import { ACDC } from "../CredentialRequest.types"; import { ChooseCredential } from "./ChooseCredential"; const deleteNotificationMock = jest.fn((id: string) => Promise.resolve(id)); diff --git a/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.tsx b/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.tsx index 86963242f2..bfbe15ec81 100644 --- a/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.tsx +++ b/src/ui/pages/NotificationDetails/components/CredentialRequest/ChooseCredential/ChooseCredential.tsx @@ -33,7 +33,6 @@ import { formatShortDate, formatTimeToSec, } from "../../../../../utils/formatters"; -import type { ACDC } from "../CredentialRequest.types"; import { ChooseCredentialProps, RequestCredential, @@ -65,18 +64,17 @@ const ChooseCredential = ({ const mappedCredentials = credentialRequest.credentials.map( (cred): CardItem => { - const acdc = cred.acdc as unknown as ACDC; const connection = connections?.find((c) => c.id === cred.connectionId)?.label || i18n.t("tabs.connections.unknown").toString(); return { - id: acdc.d, + id: cred.acdc.d, title: connection, - subtitle: `${formatShortDate(String(acdc.a.dt))} - ${formatTimeToSec( - String(acdc.a.dt) - )}`, - data: { connectionId: cred.connectionId, acdc }, + subtitle: `${formatShortDate( + String(cred.acdc.a.dt) + )} - ${formatTimeToSec(String(cred.acdc.a.dt))}`, + data: { connectionId: cred.connectionId, acdc: cred.acdc }, }; } ); diff --git a/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.tsx b/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.tsx index d7dfa7e5c5..9f82dcce1d 100644 --- a/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.tsx +++ b/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.tsx @@ -1,7 +1,6 @@ import { IonSpinner } from "@ionic/react"; import { useCallback, useMemo, useState } from "react"; import { Agent } from "../../../../../core/agent/agent"; -import type { CredentialMetadataRecordProps } from "../../../../../core/agent/records/credentialMetadataRecord.types"; import { CredentialStatus } from "../../../../../core/agent/services/credentialService.types"; import { IdentifierType } from "../../../../../core/agent/services/identifier.types"; import { CredentialsMatchingApply } from "../../../../../core/agent/services/ipexCommunicationService.types"; @@ -23,11 +22,7 @@ import { showError } from "../../../../utils/error"; import { NotificationDetailsProps } from "../../NotificationDetails.types"; import { ChooseCredential } from "./ChooseCredential"; import "./CredentialRequest.scss"; -import { - ACDC, - LinkedGroup, - RequestCredential, -} from "./CredentialRequest.types"; +import { LinkedGroup, RequestCredential } from "./CredentialRequest.types"; import { CredentialRequestInformation } from "./CredentialRequestInformation"; const CredentialRequest = ({ @@ -136,7 +131,7 @@ const CredentialRequest = ({ const mappedCredentials = credentialRequest.credentials.map( (cred): RequestCredential => ({ connectionId: cred.connectionId, - acdc: cred.acdc as unknown as ACDC, + acdc: cred.acdc, }) ); @@ -156,7 +151,7 @@ const CredentialRequest = ({ await Agent.agent.ipexCommunications.offerAcdcFromApply( notificationDetails.id, - credential.acdc as unknown as CredentialMetadataRecordProps + credential.acdc ); if (!linkedGroup) { diff --git a/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.types.ts b/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.types.ts index 4bc31b95c8..1c11841daf 100644 --- a/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.types.ts +++ b/src/ui/pages/NotificationDetails/components/CredentialRequest/CredentialRequest.types.ts @@ -4,6 +4,7 @@ import { } from "../../../../../core/agent/services/ipexCommunicationService.types"; import { KeriaNotification } from "../../../../../core/agent/services/keriaNotificationService.types"; import { BackReason } from "../../../../components/CredentialDetailModule/CredentialDetailModule.types"; +import { ACDC } from "../../../../../core/agent/services/credentialService.types"; interface MemberInfo { aid: string; @@ -40,22 +41,6 @@ interface ChooseCredentialProps { notificationDetails: KeriaNotification; } -interface ACDC { - v: string; - d: string; - i: string; - ri: string; - s: string; - a: Attendee; -} - -interface Attendee { - d: string; - i: string; - dt: Date; - attendeeName: string; -} - interface RequestCredential { connectionId: string; acdc: ACDC; @@ -86,7 +71,6 @@ interface MembersModalProps { } export type { - ACDC, ChooseCredentialProps, CredentialRequestProps, JoinedMemberProps,