You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							1189 lines
						
					
					
						
							33 KiB
						
					
					
				
			
		
		
		
			
			
			
				
					
				
				
					
				
			
		
		
	
	
							1189 lines
						
					
					
						
							33 KiB
						
					
					
				| import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; | |
| import { LRUCache } from "lru-cache"; | |
| import * as R from "ramda"; | |
| 
 | |
| import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; | |
| import { Contact } from "@/db/tables/contacts"; | |
| import { accessToken } from "@/libs/crypto"; | |
| import { NonsensitiveDexie } from "@/db/index"; | |
| import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util"; | |
| import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; | |
| 
 | |
| export const SCHEMA_ORG_CONTEXT = "https://schema.org"; | |
| // the object in RegisterAction claims | |
| export const SERVICE_ID = "endorser.ch"; | |
| // the header line for contacts exported via Endorser Mobile | |
| export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered"; | |
| // the prefix for the contact URL | |
| export const CONTACT_URL_PREFIX = "https://endorser.ch"; | |
| // the suffix for the contact URL | |
| export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt="; | |
| // the prefix for handle IDs, the permanent ID for claims on Endorser | |
| export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/"; | |
| 
 | |
| export interface AgreeVerifiableCredential { | |
|   "@context": string; | |
|   "@type": string; | |
|   // "any" because arbitrary objects can be subject of agreement | |
|   // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
|   object: Record<string, any>; | |
| } | |
| 
 | |
| export interface GiverReceiverInputInfo { | |
|   did?: string; | |
|   name?: string; | |
| } | |
| 
 | |
| export interface GiverOutputInfo { | |
|   action: string; | |
|   giver?: GiverReceiverInputInfo; | |
|   description?: string; | |
|   amount?: number; | |
|   unitCode?: string; | |
| } | |
| 
 | |
| export interface ClaimResult { | |
|   success: { claimId: string; handleId: string }; | |
|   error: { code: string; message: string }; | |
| } | |
| 
 | |
| export interface GenericVerifiableCredential { | |
|   "@context"?: string; | |
|   "@type": string; | |
|   [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any | |
| } | |
| 
 | |
| export interface GenericCredWrapper<T extends GenericVerifiableCredential> { | |
|   "@context": string; | |
|   "@type": string; | |
|   claim: T; | |
|   claimType?: string; | |
|   handleId: string; | |
|   id: string; | |
|   issuedAt: string; | |
|   issuer: string; | |
| } | |
| export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = | |
|   { | |
|     "@context": SCHEMA_ORG_CONTEXT, | |
|     "@type": "", | |
|     claim: { "@type": "" }, | |
|     handleId: "", | |
|     id: "", | |
|     issuedAt: "", | |
|     issuer: "", | |
|   }; | |
| 
 | |
| // a summary record; the VC is found the fullClaim field | |
| export interface GiveSummaryRecord { | |
|   agentDid: string; | |
|   amount: number; | |
|   amountConfirmed: number; | |
|   description: string; | |
|   fullClaim: GiveVerifiableCredential; | |
|   fulfillsPlanHandleId: string; | |
|   handleId: string; | |
|   issuedAt: string; | |
|   jwtId: string; | |
|   recipientDid: string; | |
|   unit: string; | |
| } | |
| 
 | |
| // a summary record; the VC is found the fullClaim field | |
| export interface OfferSummaryRecord { | |
|   amount: number; | |
|   amountGiven: number; | |
|   amountGivenConfirmed: number; | |
|   fullClaim: OfferVerifiableCredential; | |
|   fulfillsPlanHandleId: string; | |
|   handleId: string; | |
|   jwtId: string; | |
|   nonAmountGivenConfirmed: number; | |
|   objectDescription: string; | |
|   offeredByDid: string; | |
|   recipientDid: string; | |
|   requirementsMet: boolean; | |
|   unit: string; | |
|   validThrough: string; | |
| } | |
| 
 | |
| // a summary record; the VC is not currently part of this record | |
| export interface PlanSummaryRecord { | |
|   agentDid?: string; // optional, if the issuer wants someone else to manage as well | |
|   description: string; | |
|   endTime?: string; | |
|   fulfillsPlanHandleId: string; | |
|   handleId: string; | |
|   image?: string; | |
|   issuerDid: string; | |
|   locLat?: number; | |
|   locLon?: number; | |
|   name?: string; | |
|   startTime?: string; | |
|   url?: string; | |
| } | |
| 
 | |
| // Note that previous VCs may have additional fields. | |
| // https://endorser.ch/doc/html/transactions.html#id4 | |
| export interface GiveVerifiableCredential extends GenericVerifiableCredential { | |
|   "@context"?: string; // optional when embedded, eg. in an Agree | |
|   "@type": "GiveAction"; | |
|   agent?: { identifier: string }; | |
|   description?: string; | |
|   fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; | |
|   identifier?: string; | |
|   image?: string; | |
|   object?: { amountOfThisGood: number; unitCode: string }; | |
|   recipient?: { identifier: string }; | |
| } | |
| 
 | |
| // Note that previous VCs may have additional fields. | |
| // https://endorser.ch/doc/html/transactions.html#id8 | |
| export interface OfferVerifiableCredential { | |
|   "@context"?: string; // optional when embedded, eg. in an Agree | |
|   "@type": "Offer"; | |
|   description?: string; | |
|   includesObject?: { amountOfThisGood: number; unitCode: string }; | |
|   itemOffered?: { | |
|     description?: string; | |
|     isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; | |
|   }; | |
|   offeredBy?: { identifier: string }; | |
|   recipient?: { identifier: string }; | |
|   validThrough?: string; | |
| } | |
| 
 | |
| // Note that previous VCs may have additional fields. | |
| // https://endorser.ch/doc/html/transactions.html#id7 | |
| export interface PlanVerifiableCredential { | |
|   "@context": "https://schema.org"; | |
|   "@type": "PlanAction"; | |
|   name: string; | |
|   agent?: { identifier: string }; | |
|   description?: string; | |
|   identifier?: string; | |
|   lastClaimId?: string; | |
|   location?: { | |
|     geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; | |
|   }; | |
| } | |
| 
 | |
| /** | |
|  * Represents data about a project | |
|  * | |
|  * @deprecated | |
|  * We should use PlanSummaryRecord instead. | |
|  **/ | |
| export interface PlanData { | |
|   /** | |
|    * Name of the project | |
|    **/ | |
|   name: string; | |
|   /** | |
|    * Description of the project | |
|    **/ | |
|   description: string; | |
|   /** | |
|    * URL referencing information about the project | |
|    **/ | |
|   handleId: string; | |
|   image?: string; | |
|   /** | |
|    * The DID of the issuer | |
|    */ | |
|   issuerDid: string; | |
|   /** | |
|    * The identifier of the project -- different from jwtId, needs to be fixed | |
|    **/ | |
|   rowid?: string; | |
| } | |
| 
 | |
| export interface EndorserRateLimits { | |
|   doneClaimsThisWeek: string; | |
|   doneRegistrationsThisMonth: string; | |
|   maxClaimsPerWeek: string; | |
|   maxRegistrationsPerMonth: string; | |
|   nextMonthBeginDateTime: string; | |
|   nextWeekBeginDateTime: string; | |
| } | |
| 
 | |
| export interface ImageRateLimits { | |
|   doneImagesThisWeek: string; | |
|   maxImagesPerWeek: string; | |
|   nextWeekBeginDateTime: string; | |
| } | |
| 
 | |
| export interface VerifiableCredential { | |
|   "@context": string; | |
|   "@type": string; | |
|   name: string; | |
|   description: string; | |
|   identifier?: string; | |
| } | |
| 
 | |
| export interface WorldProperties { | |
|   startTime?: string; | |
|   endTime?: string; | |
| } | |
| 
 | |
| export interface RegisterVerifiableCredential { | |
|   "@context": string; | |
|   "@type": string; | |
|   agent: { identifier: string }; | |
|   object: string; | |
|   participant: { identifier: string }; | |
| } | |
| 
 | |
| // now for some of the error & other wrapper types | |
|  | |
| export interface ResultWithType { | |
|   type: string; | |
| } | |
| 
 | |
| export interface SuccessResult extends ResultWithType { | |
|   type: "success"; | |
|   response: AxiosResponse<ClaimResult>; | |
| } | |
| 
 | |
| export interface ErrorResponse { | |
|   error?: { | |
|     message?: string; | |
|   }; | |
| } | |
| 
 | |
| export interface InternalError { | |
|   error: string; // for system logging | |
|   userMessage?: string; // for user display | |
| } | |
| 
 | |
| export interface ErrorResult extends ResultWithType { | |
|   type: "error"; | |
|   error: InternalError; | |
| } | |
| 
 | |
| export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; | |
| 
 | |
| // 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"; | |
| 
 | |
| const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({ | |
|   max: 500, | |
| }); | |
| 
 | |
| export function isDid(did: string) { | |
|   return did.startsWith("did:"); | |
| } | |
| 
 | |
| export function isHiddenDid(did: string) { | |
|   return did === HIDDEN_DID; | |
| } | |
| 
 | |
| export function isEmptyOrHiddenDid(did?: string) { | |
|   return !did || did === HIDDEN_DID; // catching empty string as well | |
| } | |
| 
 | |
| /** | |
|  * @return true for any string within this primitive/object/array where func(input) === true | |
|  * | |
|  * Similar logic is found in endorser-mobile. | |
|  */ | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) { | |
|   if (Object.prototype.toString.call(input) === "[object String]") { | |
|     return func(input); | |
|   } else if (input instanceof Object) { | |
|     if (!Array.isArray(input)) { | |
|       // it's an object | |
|       for (const key in input) { | |
|         if (testRecursivelyOnStrings(func, input[key])) { | |
|           return true; | |
|         } | |
|       } | |
|     } else { | |
|       // it's an array | |
|       for (const value of input) { | |
|         if (testRecursivelyOnStrings(func, value)) { | |
|           return true; | |
|         } | |
|       } | |
|     } | |
|     return false; | |
|   } else { | |
|     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[] = [], | |
|   // 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: !!contact, | |
|       profileImageUrl: contact.profileImageUrl, | |
|     }; | |
|   } else { | |
|     const myId = R.find(R.equals(did), allMyDids); | |
|     return myId | |
|       ? { displayName: "You (Alt ID)", known: true } | |
|       : isHiddenDid(did) | |
|         ? { displayName: "Someone Totally Outside Your View", known: false } | |
|         : { | |
|             displayName: "Someone Visible But Outside Your Contact List", | |
|             known: false, | |
|           }; | |
|   } | |
| } | |
| 
 | |
| /** | |
|  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; | |
| } | |
| 
 | |
| 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) { | |
|   const headers: { "Content-Type": string; Authorization?: string } = { | |
|     "Content-Type": "application/json", | |
|   }; | |
|   if (did) { | |
|     let token; | |
|     const account = await getAccount(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; | |
|   } else { | |
|     // it's often OK to request without auth; we assume necessary checks are done earlier | |
|   } | |
|   return headers; | |
| } | |
| 
 | |
| /** | |
|  * @param handleId nullable, in which case "undefined" will be returned | |
|  * @param requesterDid optional, in which case no private info will be returned | |
|  * @param axios | |
|  * @param apiServer | |
|  */ | |
| export async function getPlanFromCache( | |
|   handleId: string | null, | |
|   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 { | |
|         console.error( | |
|           "Failed to load plan with handle", | |
|           handleId, | |
|           " Got data:", | |
|           resp.data, | |
|         ); | |
|       } | |
|     } catch (error) { | |
|       console.error( | |
|         "Failed to load plan with handle", | |
|         handleId, | |
|         " Got error:", | |
|         error, | |
|       ); | |
|     } | |
|   } | |
|   return cred; | |
| } | |
| 
 | |
| export async function setPlanInCache( | |
|   handleId: string, | |
|   planSummary: PlanSummaryRecord, | |
| ) { | |
|   planCache.set(handleId, planSummary); | |
| } | |
| 
 | |
| /** | |
|  * Construct GiveAction VC for submission to server | |
|  */ | |
| export function hydrateGive( | |
|   vcClaimOrig?: GiveVerifiableCredential, | |
|   fromDid?: string, | |
|   toDid?: string, | |
|   description?: string, | |
|   amount?: number, | |
|   unitCode?: string, | |
|   fulfillsProjectHandleId?: string, | |
|   fulfillsOfferHandleId?: string, | |
|   isTrade: boolean = false, | |
|   imageUrl?: 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) { | |
|     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 | |
|     ? { 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 this 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; | |
| 
 | |
|   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; should have this or amount | |
|  * @param amount may be null; should have this or description | |
|  */ | |
| 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, | |
| ): Promise<CreateAndSubmitClaimResult> { | |
|   const vcClaim = hydrateGive( | |
|     undefined, | |
|     fromDid, | |
|     toDid, | |
|     description, | |
|     amount, | |
|     unitCode, | |
|     fulfillsProjectHandleId, | |
|     fulfillsOfferHandleId, | |
|     isTrade, | |
|     imageUrl, | |
|   ); | |
|   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 | |
|  * @param description may be null; should have this or amount | |
|  * @param amount may be null; should have this or description | |
|  */ | |
| 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, | |
| ): Promise<CreateAndSubmitClaimResult> { | |
|   const vcClaim = hydrateGive( | |
|     fullClaim.claim, | |
|     fromDid, | |
|     toDid, | |
|     description, | |
|     amount, | |
|     unitCode, | |
|     fulfillsProjectHandleId, | |
|     fulfillsOfferHandleId, | |
|     isTrade, | |
|     imageUrl, | |
|     fullClaim.id, | |
|   ); | |
|   return createAndSubmitClaim( | |
|     vcClaim as GenericVerifiableCredential, | |
|     issuerDid, | |
|     apiServer, | |
|     axios, | |
|   ); | |
| } | |
| 
 | |
| /** | |
|  * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim | |
|  * | |
|  * @param identity | |
|  * @param description may be null; should have this or amount | |
|  * @param amount may be null; should have this or description | |
|  * @param expirationDate 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, | |
|   description?: string, | |
|   amount?: number, | |
|   unitCode?: string, | |
|   expirationDate?: string, | |
|   recipientDid?: string, | |
|   fulfillsProjectHandleId?: string, | |
| ): Promise<CreateAndSubmitClaimResult> { | |
|   const vcClaim: OfferVerifiableCredential = { | |
|     "@context": SCHEMA_ORG_CONTEXT, | |
|     "@type": "Offer", | |
|     offeredBy: { identifier: issuerDid }, | |
|     validThrough: expirationDate || undefined, | |
|   }; | |
|   if (amount) { | |
|     vcClaim.includesObject = { | |
|       amountOfThisGood: amount, | |
|       unitCode: unitCode || "HUR", | |
|     }; | |
|   } | |
|   if (description) { | |
|     vcClaim.itemOffered = { description }; | |
|   } | |
|   if (recipientDid) { | |
|     vcClaim.recipient = { identifier: recipientDid }; | |
|   } | |
|   if (fulfillsProjectHandleId) { | |
|     vcClaim.itemOffered = vcClaim.itemOffered || {}; | |
|     vcClaim.itemOffered.isPartOf = { | |
|       "@type": "PlanAction", | |
|       identifier: fulfillsProjectHandleId, | |
|     }; | |
|   } | |
|   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) { | |
|     console.error("Error submitting claim:", error); | |
|     const errorMessage: string = | |
|       error.response?.data?.error?.message || | |
|       error.message || | |
|       "Got some error submitting the claim. Check your permissions, network, and error logs."; | |
| 
 | |
|     return { | |
|       type: "error", | |
|       error: { | |
|         error: errorMessage, | |
|       }, | |
|     }; | |
|   } | |
| } | |
| 
 | |
| export async function createEndorserJwtForDid( | |
|   issuerDid: string, | |
|   payload: object, | |
| ) { | |
|   const account = await getAccount(issuerDid); | |
|   return createEndorserJwtForKey(account as KeyMeta, payload); | |
| } | |
| 
 | |
| /** | |
|  * 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/01HNTZYJJXTGT0EZS3VEJGX7AK"; // this won't resolve as a URL on production; it's a URN only found in the test system | |
|  | |
| 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 register( | |
|   activeDid: string, | |
|   apiServer: string, | |
|   axios: Axios, | |
|   contact: Contact, | |
| ) { | |
|   const vcClaim: RegisterVerifiableCredential = { | |
|     "@context": SCHEMA_ORG_CONTEXT, | |
|     "@type": "RegisterAction", | |
|     agent: { identifier: activeDid }, | |
|     object: SERVICE_ID, | |
|     participant: { identifier: contact.did }, | |
|   }; | |
|   // 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); | |
| 
 | |
|   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 { | |
|     console.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 { | |
|       console.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) { | |
|     console.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); | |
| }
 | |
| 
 |