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