/** * @fileoverview Endorser Server Interface and Utilities * @author Matthew Raymer * * This module provides the interface and utilities for interacting with the Endorser server. * It handles authentication, data validation, and server communication for claims, contacts, * and other core functionality. * * Key Features: * - Deep link URL path constants * - DID validation and handling * - Contact management utilities * - Server authentication * - Plan caching * * @module endorserServer */ import { Axios, AxiosRequestConfig } from 'axios' import { Buffer } from 'buffer' import { sha256 } from 'ethereum-cryptography/sha256' import { LRUCache } from 'lru-cache' import * as R from 'ramda' import { DEFAULT_IMAGE_API_SERVER, NotificationIface, APP_SERVER } from '../constants/app' import { Contact } from '../db/tables/contacts' import { accessToken, deriveAddress, nextDerivationPath } from '../libs/crypto' import { logConsoleAndDb, NonsensitiveDexie } from '../db/index' import { retrieveAccountMetadata, retrieveFullyDecryptedAccount, getPasskeyExpirationSeconds } from '../libs/util' import { createEndorserJwtForKey, KeyMeta } from '../libs/crypto/vc' import { GiveVerifiableCredential, OfferVerifiableCredential, RegisterVerifiableCredential, GenericVerifiableCredential, GenericCredWrapper, PlanSummaryRecord, UserInfo, CreateAndSubmitClaimResult } from '../interfaces' import { logger } from '../utils/logger' /** * Standard context for schema.org data * @constant {string} */ export const SCHEMA_ORG_CONTEXT = 'https://schema.org' /** * Service identifier for RegisterAction claims * @constant {string} */ export const SERVICE_ID = 'endorser.ch' /** * Header line format for contacts exported via Endorser Mobile * @constant {string} */ export const CONTACT_CSV_HEADER = 'name,did,pubKeyBase64,seesMe,registered' /** * URL path suffix for contact confirmation before import * @constant {string} */ export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = '/contact-import/' /** * URL path suffix for the contact URL in this app where a single one gets imported automatically * @constant {string} */ export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = '/contacts?contactJwt=' /** * URL path suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server * @constant {string} */ export const CONTACT_URL_PATH_ENDORSER_CH_OLD = '/contact?jwt=' /** * The prefix for handle IDs, the permanent ID for claims on Endorser * @constant {string} */ export const ENDORSER_CH_HANDLE_PREFIX = 'https://endorser.ch/entity/' export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = { claim: { '@type': '' }, handleId: '', id: '', issuedAt: '', issuer: '' } // This is used to check for hidden info. // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 const HIDDEN_DID = 'did:none:HIDDEN' /** * Validates if a string is a valid DID * @param {string} did - The DID string to validate * @returns {boolean} True if string is a valid DID format */ export function isDid(did: string): boolean { return did.startsWith('did:') } /** * Checks if a DID is the special hidden DID value * @param {string} did - The DID to check * @returns {boolean} True if DID is hidden */ export function isHiddenDid(did: string): boolean { return did === HIDDEN_DID } /** * Checks if a DID is empty or hidden * @param {string} [did] - The DID to check * @returns {boolean} True if DID is empty or hidden */ export function isEmptyOrHiddenDid(did?: string): boolean { return !did || did === HIDDEN_DID } /** * Recursively tests strings within an object/array against a test function * @param {Function} func - Test function to apply to strings * @param {any} input - Object/array to recursively test * @returns {boolean} True if any string passes the test function * * @example * testRecursivelyOnStrings(isDid, { user: { id: "did:example:123" } }) * // Returns: true */ /** * Recursively tests strings within a nested object/array structure against a test function * * This function traverses through objects and arrays to find all string values and applies * a test function to each string found. It handles: * - Direct string values * - Strings in objects (at any depth) * - Strings in arrays (at any depth) * - Mixed nested structures (objects containing arrays containing objects, etc) * * @param {Function} func - Test function that takes a string and returns boolean * @param {any} input - Value to recursively search (can be string, object, array, or other) * @returns {boolean} True if any string in the structure passes the test function * * @example * // Test if any string is a DID * const obj = { * user: { * id: "did:example:123", * details: ["name", "did:example:456"] * } * }; * testRecursivelyOnStrings(isDid, obj); // Returns: true * * @example * // Test for hidden DIDs * const obj = { * visible: "did:example:123", * hidden: ["did:none:HIDDEN"] * }; * testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true */ function testRecursivelyOnStrings( func: (arg0: unknown) => boolean, input: unknown ): boolean { // Test direct string values if (Object.prototype.toString.call(input) === '[object String]') { return func(input) } // Recursively test objects and arrays else if (input instanceof Object) { if (!Array.isArray(input)) { // Handle plain objects for (const key in input) { if (testRecursivelyOnStrings(func, input[key])) { return true } } } else { // Handle arrays for (const value of input) { if (testRecursivelyOnStrings(func, value)) { return true } } } return false } else { // Non-string, non-object values can't contain strings return false } } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function containsHiddenDid(obj: any) { return testRecursivelyOnStrings(isHiddenDid, obj) } // eslint-disable-next-line @typescript-eslint/no-explicit-any export const containsNonHiddenDid = (obj: any) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return testRecursivelyOnStrings((s: any) => isDid(s) && !isHiddenDid(s), obj) } export function stripEndorserPrefix(claimId: string) { if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) { return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length) } else { return claimId } } // similar logic is found in endorser-mobile // eslint-disable-next-line @typescript-eslint/no-explicit-any export function removeSchemaContext(obj: any) { return obj['@context'] === SCHEMA_ORG_CONTEXT ? R.omit(['@context'], obj) : obj } // similar logic is found in endorser-mobile export function addLastClaimOrHandleAsIdIfMissing( // eslint-disable-next-line @typescript-eslint/no-explicit-any obj: any, lastClaimId?: string, handleId?: string ) { if (!obj.identifier && lastClaimId) { const result = R.clone(obj) result.lastClaimId = lastClaimId return result } else if (!obj.identifier && handleId) { const result = R.clone(obj) result.identifier = handleId return result } else { return obj } } // return clone of object without any nested *VisibleToDids keys // similar code is also contained in endorser-mobile // eslint-disable-next-line @typescript-eslint/no-explicit-any export function removeVisibleToDids(input: any): any { if (input instanceof Object) { if (!Array.isArray(input)) { // it's an object // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = {} for (const key in input) { if (!key.endsWith('VisibleToDids')) { result[key] = removeVisibleToDids(R.clone(input[key])) } } return result } else { // it's an array return R.map(removeVisibleToDids, input) } } else { return input } } export function contactForDid( did: string | undefined, contacts: Contact[] ): Contact | undefined { return isEmptyOrHiddenDid(did) ? undefined : R.find((c) => c.did === did, contacts) } /** * * Similar logic is found in endorser-mobile. * * @param did * @param activeDid * @param contact * @param allMyDids * @return { known: boolean, displayName: string, profileImageUrl?: string } * where 'known' is true if they are in the contacts */ export function didInfoForContact( did: string | undefined, activeDid: string | undefined, contact?: Contact, allMyDids: string[] = [], showDidForVisible: boolean = false // eslint-disable-next-line @typescript-eslint/no-explicit-any ): { known: boolean; displayName: string; profileImageUrl?: string } { if (!did) return { displayName: 'Someone Unnamed/Unknown', known: false } if (did === activeDid) { return { displayName: 'You', known: true } } else if (contact) { return { displayName: contact.name || 'Contact With No Name', known: true, profileImageUrl: contact.profileImageUrl } } else { const myId = R.find(R.equals(did), allMyDids) return myId ? { displayName: 'You (Alt ID)', known: true } : isHiddenDid(did) ? { displayName: 'Someone Outside Your View', known: false } : { displayName: showDidForVisible ? did : 'Someone Visible But Not In Your Contact List', known: false } } } /** * @returns full contact info object (never undefined), where did is searched in contacts and allMyDids */ export function didInfoObject( did: string | undefined, activeDid: string | undefined, allMyDids: string[], contacts: Contact[] ): { known: boolean; displayName: string; profileImageUrl?: string } { const contact = contactForDid(did, contacts) return didInfoForContact(did, activeDid, contact, allMyDids) } /** always returns text, maybe something like "unnamed" or "unknown" Now that we're using more informational didInfoForContact under the covers, we might want to consolidate. **/ export function didInfo( did: string | undefined, activeDid: string | undefined, allMyDids: string[], contacts: Contact[] ): string { const contact = contactForDid(did, contacts) return didInfoForContact(did, activeDid, contact, allMyDids).displayName } /** * return text description without any references to "you" as user */ export function didInfoForCertificate( did: string | undefined, contacts: Contact[] ): string { return didInfoForContact( did, undefined, contactForDid(did, contacts), [], true ).displayName } let passkeyAccessToken: string = '' let passkeyTokenExpirationEpochSeconds: number = 0 export function clearPasskeyToken() { passkeyAccessToken = '' passkeyTokenExpirationEpochSeconds = 0 } export function tokenExpiryTimeDescription() { if ( !passkeyAccessToken || passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000 ) { return 'Token has expired' } else { return ( 'Token expires at ' + new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString() ) } } /** * Get the headers for a request, potentially including Authorization */ export async function getHeaders( did?: string, $notify?: (notification: NotificationIface, timeout?: number) => void, failureMessage?: string ) { const headers: { 'Content-Type': string; Authorization?: string } = { 'Content-Type': 'application/json' } if (did) { try { let token const account = await retrieveAccountMetadata(did) if (account?.passkeyCredIdHex) { if ( passkeyAccessToken && passkeyTokenExpirationEpochSeconds > Date.now() / 1000 ) { // there's an active current passkey token token = passkeyAccessToken } else { // there's no current passkey token or it's expired token = await accessToken(did) passkeyAccessToken = token const passkeyExpirationSeconds = await getPasskeyExpirationSeconds() passkeyTokenExpirationEpochSeconds = Date.now() / 1000 + passkeyExpirationSeconds } } else { token = await accessToken(did) } headers['Authorization'] = 'Bearer ' + token } catch (error) { // This rarely happens: we've seen it when they have account info but the // encryption secret got lost. But in most cases we want users to at // least see their feed -- and anything else that returns results for // anonymous users. // We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know. logConsoleAndDb( 'Something failed in getHeaders call (will proceed anonymously' + ($notify ? ' and notify user' : '') + '): ' + // IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'. //JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON error, true ) if ($notify) { // remember: only want to do this if they supplied a DID, expecting personal results const notifyMessage = failureMessage || 'Showing anonymous data. See the Help page for help with personal data.' $notify( { group: 'alert', type: 'danger', title: 'Personal Data Error', text: notifyMessage }, 3000 ) } } } else { // it's usually OK to request without auth; we assume we're only here when allowed } return headers } /** * Cache for storing plan data * @constant {LRUCache} */ const planCache: LRUCache = new LRUCache({ max: 500 }) /** * Retrieves plan data from cache or server * @param {string} handleId - Plan handle ID * @param {Axios} axios - Axios instance * @param {string} apiServer - API server URL * @param {string} [requesterDid] - Optional requester DID for private info * @returns {Promise} Plan data or undefined if not found * * @throws {Error} If server request fails */ export async function getPlanFromCache( handleId: string | undefined, axios: Axios, apiServer: string, requesterDid?: string ): Promise { if (!handleId) { return undefined } let cred = planCache.get(handleId) if (!cred) { const url = apiServer + '/api/v2/report/plans?handleId=' + encodeURIComponent(handleId) const headers = await getHeaders(requesterDid) try { const resp = await axios.get(url, { headers }) if (resp.status === 200 && resp.data?.data?.length > 0) { cred = resp.data.data[0] planCache.set(handleId, cred) } else { logger.log( '[EndorserServer] Plan cache is empty for handle', handleId, ' Got data:', JSON.stringify(resp.data) ) } } catch (error) { logger.error( '[EndorserServer] Failed to load plan with handle', handleId, ' Got error:', JSON.stringify(error) ) } } return cred } /** * Updates plan data in cache * @param {string} handleId - Plan handle ID * @param {PlanSummaryRecord} planSummary - Plan data to cache */ export async function setPlanInCache( handleId: string, planSummary: PlanSummaryRecord ): Promise { planCache.set(handleId, planSummary) } /** * Extracts user-friendly message from server error * @param {any} error - Error thrown from Endorser server call * @returns {string|undefined} User-friendly message or undefined if none found */ export function serverMessageForUser(error: unknown): string | undefined { return error?.response?.data?.error?.message } /** * Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify * It works with AxiosError, eg handling an error.response intelligently. * * @param error */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function errorStringForLog(error: unknown) { let stringifiedError = '' + error try { stringifiedError = JSON.stringify(error) } catch (e) { // can happen with Dexie, eg: // TypeError: Converting circular structure to JSON // --> starting at object with constructor 'DexieError2' // | property '_promise' -> object with constructor 'DexiePromise' // --- property '_value' closes the circle } let fullError = '' + error + ' - JSON: ' + stringifiedError const errorResponseText = JSON.stringify(error.response) // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { // add error.response stuff if (R.equals(error?.config, error?.response?.config)) { // but exclude "config" because it's already in there const newErrorResponseText = JSON.stringify( R.omit(['config'] as never[], error.response) ) fullError += ' - .response w/o same config JSON: ' + newErrorResponseText } else { fullError += ' - .response JSON: ' + errorResponseText } } return fullError } /** * * @returns { data: Array, hitLimit: boolean true if maximum was hit and there may be more } */ export async function getNewOffersToUser( axios: Axios, apiServer: string, activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string ) { let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}` if (afterOfferJwtId) { url += '&afterId=' + afterOfferJwtId } if (beforeOfferJwtId) { url += '&beforeId=' + beforeOfferJwtId } const headers = await getHeaders(activeDid) const response = await axios.get(url, { headers }) return response.data } /** * * @returns { data: Array, hitLimit: boolean true if maximum was hit and there may be more } */ export async function getNewOffersToUserProjects( axios: Axios, apiServer: string, activeDid: string, afterOfferJwtId?: string, beforeOfferJwtId?: string ) { let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe` if (afterOfferJwtId) { url += '?afterId=' + afterOfferJwtId } if (beforeOfferJwtId) { url += afterOfferJwtId ? '&' : '?' url += 'beforeId=' + beforeOfferJwtId } const headers = await getHeaders(activeDid) const response = await axios.get(url, { headers }) return response.data } /** * Construct GiveAction VC for submission to server * * @param lastClaimId supplied when editing a previous claim */ export function hydrateGive( vcClaimOrig?: GiveVerifiableCredential, fromDid?: string, toDid?: string, description?: string, amount?: number, unitCode?: string, fulfillsProjectHandleId?: string, fulfillsOfferHandleId?: string, isTrade: boolean = false, imageUrl?: string, providerPlanHandleId?: string, lastClaimId?: string ): GiveVerifiableCredential { // Remember: replace values or erase if it's null const vcClaim: GiveVerifiableCredential = vcClaimOrig ? R.clone(vcClaimOrig) : { '@context': SCHEMA_ORG_CONTEXT, '@type': 'GiveAction' } if (lastClaimId) { // this is an edit vcClaim.lastClaimId = lastClaimId delete vcClaim.identifier } vcClaim.agent = fromDid ? { identifier: fromDid } : undefined vcClaim.recipient = toDid ? { identifier: toDid } : undefined vcClaim.description = description || undefined vcClaim.object = amount && !isNaN(amount) ? { amountOfThisGood: amount, unitCode: unitCode || 'HUR' } : undefined // ensure fulfills is an array if (!Array.isArray(vcClaim.fulfills)) { vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [] } // ... and replace or add each element, ending with Trade or Donate // I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action. vcClaim.fulfills = vcClaim.fulfills.filter( (elem) => elem['@type'] !== 'PlanAction' ) if (fulfillsProjectHandleId) { vcClaim.fulfills.push({ '@type': 'PlanAction', identifier: fulfillsProjectHandleId }) } vcClaim.fulfills = vcClaim.fulfills.filter( (elem) => elem['@type'] !== 'Offer' ) if (fulfillsOfferHandleId) { vcClaim.fulfills.push({ '@type': 'Offer', identifier: fulfillsOfferHandleId }) } // do Trade/Donate last because current endorser.ch only looks at the first for plans & offers vcClaim.fulfills = vcClaim.fulfills.filter( (elem) => elem['@type'] !== 'DonateAction' && elem['@type'] !== 'TradeAction' ) vcClaim.fulfills.push({ '@type': isTrade ? 'TradeAction' : 'DonateAction' }) vcClaim.image = imageUrl || undefined vcClaim.provider = providerPlanHandleId ? { '@type': 'PlanAction', identifier: providerPlanHandleId } : undefined return vcClaim } /** * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * * @param fromDid may be null * @param toDid * @param description may be null * @param amount may be null */ export async function createAndSubmitGive( axios: Axios, apiServer: string, issuerDid: string, fromDid?: string, toDid?: string, description?: string, amount?: number, unitCode?: string, fulfillsProjectHandleId?: string, fulfillsOfferHandleId?: string, isTrade: boolean = false, imageUrl?: string, providerPlanHandleId?: string ): Promise { const vcClaim = hydrateGive( undefined, fromDid, toDid, description, amount, unitCode, fulfillsProjectHandleId, fulfillsOfferHandleId, isTrade, imageUrl, providerPlanHandleId, undefined ) return createAndSubmitClaim( vcClaim as GenericVerifiableCredential, issuerDid, apiServer, axios ) } /** * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * * @param fromDid may be null * @param toDid may be null if project is provided * @param description may be null * @param amount may be null */ export async function editAndSubmitGive( axios: Axios, apiServer: string, fullClaim: GenericCredWrapper, issuerDid: string, fromDid?: string, toDid?: string, description?: string, amount?: number, unitCode?: string, fulfillsProjectHandleId?: string, fulfillsOfferHandleId?: string, isTrade: boolean = false, imageUrl?: string, providerPlanHandleId?: string ): Promise { const vcClaim = hydrateGive( fullClaim.claim, fromDid, toDid, description, amount, unitCode, fulfillsProjectHandleId, fulfillsOfferHandleId, isTrade, imageUrl, providerPlanHandleId, fullClaim.id ) return createAndSubmitClaim( vcClaim as GenericVerifiableCredential, issuerDid, apiServer, axios ) } /** * Construct Offer VC for submission to server * * @param lastClaimId supplied when editing a previous claim */ export function hydrateOffer( vcClaimOrig?: OfferVerifiableCredential, fromDid?: string, toDid?: string, itemDescription?: string, amount?: number, unitCode?: string, conditionDescription?: string, fulfillsProjectHandleId?: string, validThrough?: string, lastClaimId?: string ): OfferVerifiableCredential { // Remember: replace values or erase if it's null const vcClaim: OfferVerifiableCredential = vcClaimOrig ? R.clone(vcClaimOrig) : { '@context': SCHEMA_ORG_CONTEXT, '@type': 'Offer' } if (lastClaimId) { // this is an edit vcClaim.lastClaimId = lastClaimId delete vcClaim.identifier } vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined vcClaim.recipient = toDid ? { identifier: toDid } : undefined vcClaim.description = conditionDescription || undefined vcClaim.includesObject = amount && !isNaN(amount) ? { amountOfThisGood: amount, unitCode: unitCode || 'HUR' } : undefined if (itemDescription || fulfillsProjectHandleId) { vcClaim.itemOffered = vcClaim.itemOffered || {} vcClaim.itemOffered.description = itemDescription || undefined if (fulfillsProjectHandleId) { vcClaim.itemOffered.isPartOf = { '@type': 'PlanAction', identifier: fulfillsProjectHandleId } } } vcClaim.validThrough = validThrough || undefined return vcClaim } /** * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * * @param identity * @param description may be null * @param amount may be null * @param validThrough ISO 8601 date string YYYY-MM-DD (may be null) * @param fulfillsProjectHandleId ID of project to which this contributes (may be null) */ export async function createAndSubmitOffer( axios: Axios, apiServer: string, issuerDid: string, itemDescription: string, amount?: number, unitCode?: string, conditionDescription?: string, validThrough?: string, recipientDid?: string, fulfillsProjectHandleId?: string ): Promise { const vcClaim = hydrateOffer( undefined, issuerDid, recipientDid, itemDescription, amount, unitCode, conditionDescription, fulfillsProjectHandleId, validThrough, undefined ) return createAndSubmitClaim( vcClaim as OfferVerifiableCredential, issuerDid, apiServer, axios ) } export async function editAndSubmitOffer( axios: Axios, apiServer: string, fullClaim: GenericCredWrapper, issuerDid: string, itemDescription: string, amount?: number, unitCode?: string, conditionDescription?: string, validThrough?: string, recipientDid?: string, fulfillsProjectHandleId?: string ): Promise { const vcClaim = hydrateOffer( fullClaim.claim, issuerDid, recipientDid, itemDescription, amount, unitCode, conditionDescription, fulfillsProjectHandleId, validThrough, fullClaim.id ) return createAndSubmitClaim( vcClaim as OfferVerifiableCredential, issuerDid, apiServer, axios ) } // similar logic is found in endorser-mobile export const createAndSubmitConfirmation = async ( issuerDid: string, claim: GenericVerifiableCredential, lastClaimId: string, // used to set the lastClaimId handleId: string | undefined, apiServer: string, axios: Axios ) => { const goodClaim = removeSchemaContext( removeVisibleToDids( addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId) ) ) const confirmationClaim: GenericVerifiableCredential = { '@context': SCHEMA_ORG_CONTEXT, '@type': 'AgreeAction', object: goodClaim } return createAndSubmitClaim(confirmationClaim, issuerDid, apiServer, axios) } export async function createAndSubmitClaim( vcClaim: GenericVerifiableCredential, issuerDid: string, apiServer: string, axios: Axios ): Promise { try { const vcPayload = { vc: { '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential'], credentialSubject: vcClaim } } const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload) // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }) const url = `${apiServer}/api/v2/claim` const response = await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' } }) return { type: 'success', response } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { logger.error('Error submitting claim:', error) const errorMessage: string = serverMessageForUser(error) || error.message || 'Got some error submitting the claim. Check your permissions, network, and error logs.' return { type: 'error', error: { error: errorMessage } } } } export async function generateEndorserJwtUrlForAccount( account: KeyMeta, isRegistered: boolean, givenName: string, profileImageUrl: string, isContact: boolean ) { const publicKeyHex = account.publicKeyHex const publicEncKey = Buffer.from(publicKeyHex, 'hex').toString('base64') const contactInfo = { iat: Date.now(), iss: account.did, own: { did: account.did, name: givenName ?? '', publicEncKey, registered: !!isRegistered } as UserInfo } if (profileImageUrl) { contactInfo.own.profileImageUrl = profileImageUrl } // Add the next key -- not recommended for the QR code for such a high resolution if (isContact && account?.mnemonic && account?.derivationPath) { const newDerivPath = nextDerivationPath(account.derivationPath as string) const nextPublicHex = deriveAddress( account.mnemonic as string, newDerivPath )[2] const nextPublicEncKey = Buffer.from(nextPublicHex, 'hex') const nextPublicEncKeyHash = sha256(nextPublicEncKey) const nextPublicEncKeyHashBase64 = Buffer.from(nextPublicEncKeyHash).toString('base64') contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64 } const vcJwt = await createEndorserJwtForDid(account.did, contactInfo) const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI return viewPrefix + vcJwt } export async function createEndorserJwtForDid( issuerDid: string, payload: object, expiresIn?: number ) { const account = await retrieveFullyDecryptedAccount(issuerDid) return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn) } /** * An AcceptAction is when someone accepts some contract or pledge. * * @param claim has properties '@context' & '@type' * @return true if the claim is a schema.org AcceptAction */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const isAccept = (claim: Record) => { return ( claim && claim['@context'] === SCHEMA_ORG_CONTEXT && claim['@type'] === 'AcceptAction' ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any export const isOffer = (claim: Record) => { return ( claim && claim['@context'] === SCHEMA_ORG_CONTEXT && claim['@type'] === 'Offer' ) } export function currencyShortWordForCode(unitCode: string, single: boolean) { return unitCode === 'HUR' ? (single ? 'hour' : 'hours') : unitCode } export function displayAmount(code: string, amt: number) { return '' + amt + ' ' + currencyShortWordForCode(code, amt === 1) } // insert a space before any capital letters except the initial letter // (and capitalize initial letter, just in case) export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => { return !text ? '' : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, ' $1') } /** return readable summary of claim, or something generic similar code is also contained in endorser-mobile **/ // eslint-disable-next-line @typescript-eslint/no-explicit-any const claimSummary = ( claim: GenericCredWrapper ) => { if (!claim) { // to differentiate from "something" above return 'something' } let specificClaim: | GenericVerifiableCredential | GenericCredWrapper = claim if (claim.claim) { // probably a Verified Credential // eslint-disable-next-line @typescript-eslint/no-explicit-any specificClaim = claim.claim } if (Array.isArray(specificClaim)) { if (specificClaim.length === 1) { specificClaim = specificClaim[0] } else { return 'multiple claims' } } const type = specificClaim['@type'] if (!type) { return 'a claim' } else { let typeExpl = capitalizeAndInsertSpacesBeforeCaps(type) if (typeExpl === 'Person') { typeExpl += ' claim' } return 'a ' + typeExpl } } /** return readable description of claim if possible, as a past-tense action identifiers is a list of objects with a 'did' field, each representing the user contacts is a list of objects with a 'did' field for others and a 'name' field for their name similar code is also contained in endorser-mobile **/ export const claimSpecialDescription = ( record: GenericCredWrapper, activeDid: string, identifiers: Array, contacts: Array ) => { let claim = record.claim if (claim.claim) { // it's probably a Verified Credential claim = claim.claim } const issuer = didInfo(record.issuer, activeDid, identifiers, contacts) const type = claim['@type'] || 'UnknownType' if (type === 'AgreeAction') { return issuer + ' agreed with ' + claimSummary(claim.object) } else if (isAccept(claim)) { return issuer + ' accepted ' + claimSummary(claim.object) } else if (type === 'GiveAction') { // agent.did is for legacy data, before March 2023 const giver = claim.agent?.identifier || claim.agent?.did const giverInfo = didInfo(giver, activeDid, identifiers, contacts) let gaveAmount = claim.object?.amountOfThisGood ? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) : '' if (claim.description) { if (gaveAmount) { gaveAmount = gaveAmount + ', and also: ' } gaveAmount = gaveAmount + claim.description } if (!gaveAmount) { gaveAmount = 'something not described' } // recipient.did is for legacy data, before March 2023 const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did const gaveRecipientInfo = gaveRecipientId ? ' to ' + didInfo(gaveRecipientId, activeDid, identifiers, contacts) : '' return giverInfo + ' gave' + gaveRecipientInfo + ': ' + gaveAmount } else if (type === 'JoinAction') { // agent.did is for legacy data, before March 2023 const agent = claim.agent?.identifier || claim.agent?.did const contactInfo = didInfo(agent, activeDid, identifiers, contacts) let eventOrganizer = claim.event && claim.event.organizer && claim.event.organizer.name eventOrganizer = eventOrganizer || '' let eventName = claim.event && claim.event.name eventName = eventName ? ' ' + eventName : '' let fullEvent = eventOrganizer + eventName fullEvent = fullEvent ? ' attended the ' + fullEvent : '' let eventDate = claim.event && claim.event.startTime eventDate = eventDate ? ' at ' + eventDate : '' return contactInfo + fullEvent + eventDate } else if (isOffer(claim)) { const offerer = claim.offeredBy?.identifier const contactInfo = didInfo(offerer, activeDid, identifiers, contacts) let offering = '' if (claim.includesObject) { offering += ' ' + displayAmount( claim.includesObject.unitCode, claim.includesObject.amountOfThisGood ) } if (claim.itemOffered?.description) { offering += ', saying: ' + claim.itemOffered?.description } // recipient.did is for legacy data, before March 2023 const offerRecipientId = claim.recipient?.identifier || claim.recipient?.did const offerRecipientInfo = offerRecipientId ? ' to ' + didInfo(offerRecipientId, activeDid, identifiers, contacts) : '' return contactInfo + ' offered' + offering + offerRecipientInfo } else if (type === 'PlanAction') { const claimer = claim.agent?.identifier || record.issuer const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts) return claimerInfo + ' announced a project: ' + claim.name } else if (type === 'Tenure') { // party.did is for legacy data, before March 2023 const claimer = claim.party?.identifier || claim.party?.did const contactInfo = didInfo(claimer, activeDid, identifiers, contacts) const polygon = claim.spatialUnit?.geo?.polygon || '' return ( contactInfo + ' possesses [' + polygon.substring(0, polygon.indexOf(' ')) + '...]' ) } else { return ( issuer + ' declared ' + claimSummary(claim as GenericCredWrapper) ) } } export const BVC_MEETUPS_PROJECT_CLAIM_ID = import.meta.env.VITE_BVC_MEETUPS_PROJECT_CLAIM_ID || 'https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H' // production value, which seems like the safest value if forgotten export const bvcMeetingJoinClaim = (did: string, startTime: string) => { return { '@context': SCHEMA_ORG_CONTEXT, '@type': 'JoinAction', agent: { identifier: did }, event: { organizer: { name: 'Bountiful Voluntaryist Community' }, name: 'Saturday Morning Meeting', startTime: startTime } } } export async function createEndorserJwtVcFromClaim( issuerDid: string, claim: object ) { // Make a payload for the claim const vcPayload = { vc: { '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential'], credentialSubject: claim } } return createEndorserJwtForDid(issuerDid, vcPayload) } export async function createInviteJwt( activeDid: string, contact?: Contact, inviteId?: string, expiresIn?: number ): Promise { const vcClaim: RegisterVerifiableCredential = { '@context': SCHEMA_ORG_CONTEXT, '@type': 'RegisterAction', agent: { identifier: activeDid }, object: SERVICE_ID } if (contact) { vcClaim.participant = { identifier: contact.did } } if (inviteId) { vcClaim.identifier = inviteId } // Make a payload for the claim const vcPayload = { vc: { '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential'], credentialSubject: vcClaim } } // Create a signature using private key of identity const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn) return vcJwt } export async function register( activeDid: string, apiServer: string, axios: Axios, contact: Contact ): Promise<{ success?: boolean; error?: string }> { const vcJwt = await createInviteJwt(activeDid, contact) const url = apiServer + '/api/v2/claim' const resp = await axios.post(url, { jwtEncoded: vcJwt }) if (resp.data?.success?.handleId) { return { success: true } } else if (resp.data?.success?.embeddedRecordError) { let message = 'There was some problem with the registration and so it may not be complete.' if (typeof resp.data.success.embeddedRecordError == 'string') { message += ' ' + resp.data.success.embeddedRecordError } return { error: message } } else { logger.error(resp) return { error: 'Got a server error when registering.' } } } export async function setVisibilityUtil( activeDid: string, apiServer: string, axios: Axios, db: NonsensitiveDexie, contact: Contact, visibility: boolean ) { if (!activeDid) { return { error: 'Cannot set visibility without an identifier.' } } const url = apiServer + '/api/report/' + (visibility ? 'canSeeMe' : 'cannotSeeMe') const headers = await getHeaders(activeDid) const payload = JSON.stringify({ did: contact.did }) try { const resp = await axios.post(url, payload, { headers }) if (resp.status === 200) { const success = resp.data.success if (success) { db.contacts.update(contact.did, { seesMe: visibility }) } return { success } } else { logger.error( 'Got some bad server response when setting visibility: ', resp.status, resp ) const message = resp.data.error?.message || 'Got some error setting visibility.' return { error: message } } } catch (err) { logger.error('Got some error when setting visibility:', err) return { error: 'Check connectivity and try again.' } } } /** * Fetches rate limits from the Endorser server. * * @param apiServer endorser server URL string * @param axios Axios instance * @param {string} issuerDid - The DID for which to check rate limits. * @returns {Promise} The Axios response object. */ export async function fetchEndorserRateLimits( apiServer: string, axios: Axios, issuerDid: string ) { const url = `${apiServer}/api/report/rateLimits` const headers = await getHeaders(issuerDid) return await axios.get(url, { headers } as AxiosRequestConfig) } /** * Fetches rate limits from the image server. * * @param apiServer image server URL string * @param axios Axios instance * @param {string} issuerDid - The DID for which to check rate limits. * @returns {Promise} The Axios response object. */ export async function fetchImageRateLimits(axios: Axios, issuerDid: string) { const url = DEFAULT_IMAGE_API_SERVER + '/image-limits' const headers = await getHeaders(issuerDid) return await axios.get(url, { headers } as AxiosRequestConfig) }