Files
crowd-funder-for-time-pwa/src/libs/endorserServer.ts
Matthew Raymer e5518cd47c fix: update Vue template syntax and improve Vite config
- Fix Vue template syntax in App.vue by using proper event handler format
- Update Vite config to properly handle ESM imports and crypto modules
- Add manual chunks for better code splitting
- Improve environment variable handling in vite-env.d.ts
- Fix TypeScript linting errors in App.vue
2025-04-18 09:59:33 +00:00

1409 lines
41 KiB
TypeScript

/**
* @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<GenericVerifiableCredential> =
{
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<string, any> = {}
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<string, PlanSummaryRecord> = 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<PlanSummaryRecord|undefined>} 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<PlanSummaryRecord | undefined> {
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<void> {
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<OfferSummaryRecord>, 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<OfferToPlanSummaryRecord>, 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<CreateAndSubmitClaimResult> {
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<GiveVerifiableCredential>,
issuerDid: string,
fromDid?: string,
toDid?: string,
description?: string,
amount?: number,
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false,
imageUrl?: string,
providerPlanHandleId?: string
): Promise<CreateAndSubmitClaimResult> {
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<CreateAndSubmitClaimResult> {
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<OfferVerifiableCredential>,
issuerDid: string,
itemDescription: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
validThrough?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string
): Promise<CreateAndSubmitClaimResult> {
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<CreateAndSubmitClaimResult> {
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<string, any>) => {
return (
claim &&
claim['@context'] === SCHEMA_ORG_CONTEXT &&
claim['@type'] === 'AcceptAction'
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isOffer = (claim: Record<string, any>) => {
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<GenericVerifiableCredential>
) => {
if (!claim) {
// to differentiate from "something" above
return 'something'
}
let specificClaim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential> = 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<GenericVerifiableCredential>,
activeDid: string,
identifiers: Array<string>,
contacts: Array<Contact>
) => {
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<GenericVerifiableCredential>)
)
}
}
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<string> {
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<AxiosResponse>} 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<AxiosResponse>} 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)
}