import { Axios, AxiosRequestConfig, AxiosResponse } 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 } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; import { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; import { getAccount, getPasskeyExpirationSeconds } from "@/libs/util"; import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; import { Account } from "@/db/tables/accounts"; 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; } 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; // optional when embedded, eg. in an Agree "@type": string; [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } export interface GenericCredWrapper { "@context": string; "@type": string; claim: T; claimType?: string; handleId: string; id: string; issuedAt: string; issuer: string; publicUrls?: Record; // only for IDs that want to be public } export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper = { "@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; issuerDid: 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; issuerDid: 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 extends GenericVerifiableCredential { "@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer "@type": "Offer"; description?: string; // conditions for the offer includesObject?: { amountOfThisGood: number; unitCode: string }; itemOffered?: { description?: string; // description of the item 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 extends GenericVerifiableCredential { "@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; } 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"; 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 = {}; 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; } const planCache: LRUCache = new LRUCache({ max: 500, }); /** * @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 { 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 * * @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, 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; 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, ): Promise { const vcClaim = hydrateGive( undefined, fromDid, toDid, description, amount, unitCode, fulfillsProjectHandleId, fulfillsOfferHandleId, isTrade, imageUrl, 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, ): Promise { const vcClaim = hydrateGive( fullClaim.claim, fromDid, toDid, description, amount, unitCode, fulfillsProjectHandleId, fulfillsOfferHandleId, isTrade, imageUrl, 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) { 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 generateEndorserJwtForAccount( account: Account, isRegistered?: boolean, name?: string, profileImageUrl?: string, ) { const publicKeyHex = account.publicKeyHex; const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); interface UserInfo { name: string; publicEncKey: string; registered: boolean; profileImageUrl?: string; nextPublicEncKeyHash?: string; } const contactInfo = { iat: Date.now(), iss: account.did, own: { name: name ?? "", publicEncKey, registered: !!isRegistered, } as UserInfo, }; if (profileImageUrl) { contactInfo.own.profileImageUrl = profileImageUrl; } if (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 = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; return viewPrefix + vcJwt; } 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) => { 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/01HWE8FWHQ1YGP7GFZYYPS272F"; // 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} 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); }