timesafari
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.

1324 lines
37 KiB

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";
7 months ago
import { NonsensitiveDexie } from "@/db/index";
import {
getAccount,
getPasskeyExpirationSeconds,
GiverReceiverInputInfo,
} 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<string, any>;
}
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<T extends GenericVerifiableCredential> {
"@context": string;
"@type": string;
claim: T;
claimType?: string;
handleId: string;
id: string;
issuedAt: string;
issuer: string;
publicUrls?: Record<string, string>; // only for IDs that want to be public
}
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;
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<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";
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;
}
const planCache: LRUCache<string, PlanSummaryRecord> = 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<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
*
* @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<CreateAndSubmitClaimResult> {
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<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,
);
}
/**
* 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) {
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<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/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<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);
}