forked from trent_larson/crowd-funder-for-time-pwa
Created extractOfferFulfillment utility in libs/util.ts to handle both array and single object cases for fulfills field. Updated ClaimView and ConfirmGiftView to use the shared utility, eliminating code duplication and improving maintainability.
1066 lines
33 KiB
TypeScript
1066 lines
33 KiB
TypeScript
// many of these are also found in endorser-mobile utility.ts
|
|
|
|
import axios, { AxiosResponse } from "axios";
|
|
import { Buffer } from "buffer";
|
|
import * as R from "ramda";
|
|
import { useClipboard } from "@vueuse/core";
|
|
|
|
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
|
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
|
import {
|
|
arrayBufferToBase64,
|
|
base64ToArrayBuffer,
|
|
deriveAddress,
|
|
generateSeed,
|
|
newIdentifier,
|
|
simpleDecrypt,
|
|
simpleEncrypt,
|
|
} from "../libs/crypto";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import { containsHiddenDid } from "../libs/endorserServer";
|
|
import {
|
|
GenericCredWrapper,
|
|
GenericVerifiableCredential,
|
|
KeyMetaWithPrivate,
|
|
} from "../interfaces/common";
|
|
import { GiveSummaryRecord } from "../interfaces/records";
|
|
import { OfferClaim } from "../interfaces/claims";
|
|
import { createPeerDid } from "../libs/crypto/vc/didPeer";
|
|
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
|
|
import { logger } from "../utils/logger";
|
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
|
|
|
|
// Consolidate this with src/utils/PlatformServiceMixin.mapQueryResultToValues
|
|
function mapQueryResultToValues(
|
|
record: { columns: string[]; values: unknown[][] } | undefined,
|
|
): Array<Record<string, unknown>> {
|
|
if (!record || !record.columns || !record.values) {
|
|
return [];
|
|
}
|
|
|
|
return record.values.map((row) => {
|
|
const obj: Record<string, unknown> = {};
|
|
record.columns.forEach((column, index) => {
|
|
obj[column] = row[index];
|
|
});
|
|
return obj;
|
|
});
|
|
}
|
|
|
|
// Platform service access for database operations
|
|
async function getPlatformService() {
|
|
return PlatformServiceFactory.getInstance();
|
|
}
|
|
|
|
export interface GiverReceiverInputInfo {
|
|
did?: string; // only for people
|
|
name?: string;
|
|
image?: string;
|
|
handleId?: string; // only for projects
|
|
}
|
|
|
|
export enum OnboardPage {
|
|
Home = "HOME",
|
|
Discover = "DISCOVER",
|
|
Create = "CREATE",
|
|
Contact = "CONTACT",
|
|
Account = "ACCOUNT",
|
|
}
|
|
|
|
export const PRIVACY_MESSAGE =
|
|
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
|
|
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
|
|
|
|
/* eslint-disable prettier/prettier */
|
|
export const UNIT_SHORT: Record<string, string> = {
|
|
"BTC": "BTC",
|
|
"BX": "BX",
|
|
"ETH": "ETH",
|
|
"HUR": "Hours",
|
|
"USD": "US $",
|
|
};
|
|
/* eslint-enable prettier/prettier */
|
|
|
|
/* eslint-disable prettier/prettier */
|
|
export const UNIT_LONG: Record<string, string> = {
|
|
"BTC": "Bitcoin",
|
|
"BX": "Buxbe",
|
|
"ETH": "Ethereum",
|
|
"HUR": "hours",
|
|
"USD": "dollars",
|
|
};
|
|
/* eslint-enable prettier/prettier */
|
|
|
|
const UNIT_CODES: Record<
|
|
string,
|
|
{ name: string; faIcon: string; decimals: number }
|
|
> = {
|
|
BTC: {
|
|
name: "Bitcoin",
|
|
faIcon: "bitcoin-sign",
|
|
decimals: 4,
|
|
},
|
|
HUR: {
|
|
name: "hours",
|
|
faIcon: "clock",
|
|
decimals: 0,
|
|
},
|
|
USD: {
|
|
name: "US Dollars",
|
|
faIcon: "dollar",
|
|
decimals: 2,
|
|
},
|
|
};
|
|
|
|
export function iconForUnitCode(unitCode: string) {
|
|
return UNIT_CODES[unitCode]?.faIcon || "question";
|
|
}
|
|
|
|
export function formattedAmount(amount: number, unitCode: string) {
|
|
const unit = UNIT_CODES[unitCode];
|
|
const amountStr = amount.toFixed(unit?.decimals ?? 4);
|
|
const unitName = unit?.name || "?";
|
|
return amountStr + " " + unitName;
|
|
}
|
|
|
|
// from https://stackoverflow.com/a/175787/845494
|
|
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
|
|
//
|
|
export function isNumeric(str: string): boolean {
|
|
// This ignore commentary is because typescript complains when you pass a string to isNaN.
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
return !isNaN(str) && !isNaN(parseFloat(str));
|
|
}
|
|
|
|
export function numberOrZero(str: string): number {
|
|
return isNumeric(str) ? +str : 0;
|
|
}
|
|
|
|
/**
|
|
* from https://tools.ietf.org/html/rfc3986#section-3
|
|
* also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
|
|
**/
|
|
export const isGlobalUri = (uri: string) => {
|
|
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
|
};
|
|
|
|
export const isGiveClaimType = (claimType?: string) => {
|
|
return claimType === "GiveAction";
|
|
};
|
|
|
|
export const isGiveAction = (
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
) => {
|
|
return isGiveClaimType(veriClaim.claimType);
|
|
};
|
|
|
|
export interface OfferFulfillment {
|
|
offerHandleId: string;
|
|
offerType: string;
|
|
}
|
|
|
|
/**
|
|
* Extract offer fulfillment information from the fulfills field
|
|
* Handles both array and single object cases
|
|
*/
|
|
export const extractOfferFulfillment = (fulfills: any): OfferFulfillment | null => {
|
|
if (!fulfills) {
|
|
return null;
|
|
}
|
|
|
|
// Handle both array and single object cases
|
|
let offerFulfill = null;
|
|
|
|
if (Array.isArray(fulfills)) {
|
|
// Find the Offer in the fulfills array
|
|
offerFulfill = fulfills.find((item) => item["@type"] === "Offer");
|
|
} else if (fulfills["@type"] === "Offer") {
|
|
// fulfills is a single Offer object
|
|
offerFulfill = fulfills;
|
|
}
|
|
|
|
if (offerFulfill) {
|
|
return {
|
|
offerHandleId: offerFulfill.identifier,
|
|
offerType: offerFulfill["@type"],
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const shortDid = (did: string) => {
|
|
if (did.startsWith("did:peer:")) {
|
|
return (
|
|
did.substring(0, "did:peer:".length + 2) +
|
|
"..." +
|
|
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
|
|
"..."
|
|
);
|
|
} else if (did.startsWith("did:ethr:")) {
|
|
return did.substring(0, "did:ethr:".length + 9) + "...";
|
|
} else {
|
|
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
|
|
}
|
|
};
|
|
|
|
export const nameForDid = (
|
|
activeDid: string,
|
|
contacts: Array<Contact>,
|
|
did: string,
|
|
): string => {
|
|
if (did === activeDid) {
|
|
return "You";
|
|
}
|
|
const contact = R.find((con) => con.did === did, contacts);
|
|
return nameForContact(contact);
|
|
};
|
|
|
|
export const nameForContact = (
|
|
contact?: Contact,
|
|
capitalize?: boolean,
|
|
): string => {
|
|
return (
|
|
(contact?.name as string) ||
|
|
(capitalize ? "This" : "this") + " unnamed user"
|
|
);
|
|
};
|
|
|
|
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
};
|
|
|
|
export interface ConfirmerData {
|
|
confirmerIdList: string[];
|
|
confsVisibleToIdList: string[];
|
|
numConfsNotVisible: number;
|
|
}
|
|
|
|
// // This is meant to be a second argument to JSON.stringify to avoid circular references.
|
|
// // Usage: JSON.stringify(error, getCircularReplacer())
|
|
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
|
|
// function getCircularReplacer() {
|
|
// const seen = new WeakSet();
|
|
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
// return (obj: any, key: string, value: any): any => {
|
|
// if (typeof value === "object" && value !== null) {
|
|
// if (seen.has(value)) {
|
|
// return "[circular ref]";
|
|
// }
|
|
// seen.add(value);
|
|
// }
|
|
// return value;
|
|
// };
|
|
// }
|
|
|
|
/**
|
|
* @return only confirmers, excluding the issuer and hidden DIDs
|
|
*/
|
|
export async function retrieveConfirmerIdList(
|
|
apiServer: string,
|
|
claimId: string,
|
|
claimIssuerId: string,
|
|
userDid: string,
|
|
): Promise<ConfirmerData | undefined> {
|
|
const confirmUrl =
|
|
apiServer +
|
|
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
|
const confirmHeaders = await serverUtil.getHeaders(userDid);
|
|
const response = await axios.get(confirmUrl, {
|
|
headers: confirmHeaders,
|
|
});
|
|
if (response.status === 200) {
|
|
const resultList1 = response.data.result || [];
|
|
//const publicUrls = resultList.publicUrls || [];
|
|
delete resultList1.publicUrls;
|
|
// exclude hidden DIDs
|
|
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
|
// exclude the issuer
|
|
const resultList3 = R.reject(
|
|
(did: string) => did === claimIssuerId,
|
|
resultList2,
|
|
);
|
|
const confirmerIdList = resultList3;
|
|
let numConfsNotVisible = resultList1.length - resultList2.length;
|
|
if (resultList3.length === resultList2.length) {
|
|
// the issuer was not in the "visible" list so they must be hidden
|
|
// so subtract them from the non-visible confirmers count
|
|
numConfsNotVisible = numConfsNotVisible - 1;
|
|
}
|
|
const confsVisibleToIdList = response.data.result.resultVisibleToDids || [];
|
|
const result: ConfirmerData = {
|
|
confirmerIdList,
|
|
confsVisibleToIdList,
|
|
numConfsNotVisible,
|
|
};
|
|
return result;
|
|
} else {
|
|
logger.error(
|
|
"Bad response status of",
|
|
response.status,
|
|
"for confirmers:",
|
|
response,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns true if the user can confirm the claim
|
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
|
*/
|
|
export function isGiveRecordTheUserCanConfirm(
|
|
isRegistered: boolean,
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
activeDid: string,
|
|
confirmerIdList: string[] = [],
|
|
): boolean {
|
|
return (
|
|
isRegistered &&
|
|
isGiveAction(veriClaim) &&
|
|
!confirmerIdList.includes(activeDid) &&
|
|
veriClaim.issuer !== activeDid &&
|
|
!containsHiddenDid(veriClaim.claim)
|
|
);
|
|
}
|
|
|
|
export function notifyWhyCannotConfirm(
|
|
notifyFun: (notification: NotificationIface, timeout: number) => void,
|
|
isRegistered: boolean,
|
|
claimType: string | undefined,
|
|
giveDetails: GiveSummaryRecord | undefined,
|
|
activeDid: string,
|
|
confirmerIdList: string[] = [],
|
|
) {
|
|
if (!isRegistered) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Not Registered",
|
|
text: "Someone needs to register you before you can confirm.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (!isGiveClaimType(claimType)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Not A Give",
|
|
text: "This is not a giving action to confirm.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (confirmerIdList.includes(activeDid)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Already Confirmed",
|
|
text: "You already confirmed this claim.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (giveDetails?.issuerDid == activeDid) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this because you issued this claim.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this because some people are hidden.",
|
|
},
|
|
3000,
|
|
);
|
|
} else {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.",
|
|
},
|
|
3000,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
|
|
// Extract the content type and the Base64 data
|
|
const [metadata, base64] = base64DataUrl.split(",");
|
|
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
|
|
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
|
|
|
|
const byteCharacters = atob(base64);
|
|
const byteArrays = [];
|
|
|
|
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
|
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
|
|
|
const byteNumbers = new Array(slice.length);
|
|
for (let i = 0; i < slice.length; i++) {
|
|
byteNumbers[i] = slice.charCodeAt(i);
|
|
}
|
|
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
byteArrays.push(byteArray);
|
|
}
|
|
return new Blob(byteArrays, { type: contentType });
|
|
}
|
|
|
|
/**
|
|
* @returns the DID of the person who offered, or undefined if hidden
|
|
* @param veriClaim is expected to have fields: claim and issuer
|
|
*/
|
|
export function offerGiverDid(
|
|
veriClaim: GenericCredWrapper<OfferClaim>,
|
|
): string | undefined {
|
|
const innerClaim = veriClaim.claim as OfferClaim;
|
|
let giver: string | undefined = undefined;
|
|
|
|
giver = innerClaim.offeredBy?.identifier;
|
|
if (giver && !serverUtil.isHiddenDid(giver)) {
|
|
return giver;
|
|
}
|
|
|
|
giver = veriClaim.issuer;
|
|
if (giver && !serverUtil.isHiddenDid(giver)) {
|
|
return giver;
|
|
}
|
|
return giver;
|
|
}
|
|
|
|
/**
|
|
* @returns true if the user can fulfill the offer
|
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
|
*/
|
|
export const canFulfillOffer = (
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
isRegistered: boolean,
|
|
) => {
|
|
return (
|
|
isRegistered &&
|
|
veriClaim.claimType === "Offer" &&
|
|
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferClaim>)
|
|
);
|
|
};
|
|
|
|
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
|
|
export function findAllVisibleToDids(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
input: any,
|
|
humanReadable = false,
|
|
): Record<string, Array<string>> {
|
|
if (Array.isArray(input)) {
|
|
const result: Record<string, Array<string>> = {};
|
|
for (let i = 0; i < input.length; i++) {
|
|
const inside = findAllVisibleToDids(input[i], humanReadable);
|
|
for (const key in inside) {
|
|
const pathKey = humanReadable
|
|
? "#" + (i + 1) + " " + key
|
|
: "[" + i + "]" + key;
|
|
result[pathKey] = inside[key];
|
|
}
|
|
}
|
|
return result;
|
|
} else if (input instanceof Object) {
|
|
// regular map (non-array) object
|
|
const result: Record<string, Array<string>> = {};
|
|
for (const key in input) {
|
|
if (key.endsWith("VisibleToDids")) {
|
|
const newKey = key.slice(0, -"VisibleToDids".length);
|
|
const pathKey = humanReadable ? newKey : "." + newKey;
|
|
result[pathKey] = input[key];
|
|
} else {
|
|
const inside = findAllVisibleToDids(input[key], humanReadable);
|
|
for (const insideKey in inside) {
|
|
const pathKey = humanReadable
|
|
? key + "'s " + insideKey
|
|
: "." + key + insideKey;
|
|
result[pathKey] = inside[insideKey];
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
} else {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test findAllVisibleToDids
|
|
*
|
|
|
|
pkgx +deno.land sh
|
|
|
|
deno
|
|
|
|
import * as R from 'ramda';
|
|
//import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function
|
|
|
|
console.log(R.equals(findAllVisibleToDids(null), {}));
|
|
console.log(R.equals(findAllVisibleToDids(9), {}));
|
|
console.log(R.equals(findAllVisibleToDids([]), {}));
|
|
console.log(R.equals(findAllVisibleToDids({}), {}));
|
|
console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {}));
|
|
console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] }));
|
|
|
|
*
|
|
**/
|
|
|
|
export type AccountKeyInfo = Account & KeyMetaWithPrivate;
|
|
|
|
export const retrieveAccountCount = async (): Promise<number> => {
|
|
let result = 0;
|
|
const platformService = await getPlatformService();
|
|
const dbResult = await platformService.dbQuery(
|
|
`SELECT COUNT(*) FROM accounts`,
|
|
);
|
|
if (dbResult?.values?.[0]?.[0]) {
|
|
result = dbResult.values[0][0] as number;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const retrieveAccountDids = async (): Promise<string[]> => {
|
|
const platformService = await getPlatformService();
|
|
const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`);
|
|
const allDids =
|
|
mapQueryResultToValues(dbAccounts)?.map((row) => row[0] as string) || [];
|
|
return allDids;
|
|
};
|
|
|
|
/**
|
|
* This is provided and recommended when the full key is not necessary so that
|
|
* future work could separate this info from the sensitive key material.
|
|
*
|
|
* If you need the private key data, use retrieveFullyDecryptedAccount instead.
|
|
*/
|
|
export const retrieveAccountMetadata = async (
|
|
activeDid: string,
|
|
): Promise<Account | undefined> => {
|
|
let result: Account | undefined = undefined;
|
|
const platformService = await getPlatformService();
|
|
const dbAccount = await platformService.dbQuery(
|
|
`SELECT * FROM accounts WHERE did = ?`,
|
|
[activeDid],
|
|
);
|
|
const account = mapQueryResultToValues(dbAccount)[0] as Account;
|
|
if (account) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { identity, mnemonic, ...metadata } = account;
|
|
result = metadata;
|
|
} else {
|
|
result = undefined;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* This contains sensitive data. If possible, use retrieveAccountMetadata instead.
|
|
*
|
|
* @param activeDid
|
|
* @returns account info with private key data decrypted
|
|
*/
|
|
export const retrieveFullyDecryptedAccount = async (
|
|
activeDid: string,
|
|
): Promise<Account | undefined> => {
|
|
let result: Account | undefined = undefined;
|
|
const platformService = await getPlatformService();
|
|
const dbSecrets = await platformService.dbQuery(
|
|
`SELECT secretBase64 from secret`,
|
|
);
|
|
if (
|
|
!dbSecrets ||
|
|
dbSecrets.values.length === 0 ||
|
|
dbSecrets.values[0].length === 0
|
|
) {
|
|
throw new Error(
|
|
"No secret found. We recommend you clear your data and start over.",
|
|
);
|
|
}
|
|
const secretBase64 = dbSecrets.values[0][0] as string;
|
|
const secret = base64ToArrayBuffer(secretBase64);
|
|
const dbAccount = await platformService.dbQuery(
|
|
`SELECT * FROM accounts WHERE did = ?`,
|
|
[activeDid],
|
|
);
|
|
if (
|
|
!dbAccount ||
|
|
dbAccount.values.length === 0 ||
|
|
dbAccount.values[0].length === 0
|
|
) {
|
|
throw new Error("Account not found.");
|
|
}
|
|
const fullAccountData = mapQueryResultToValues(
|
|
dbAccount,
|
|
)[0] as AccountEncrypted;
|
|
const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
|
|
const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
|
|
fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
|
|
fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
|
|
result = fullAccountData;
|
|
|
|
return result;
|
|
};
|
|
|
|
export const retrieveAllAccountsMetadata = async (): Promise<
|
|
AccountEncrypted[]
|
|
> => {
|
|
const platformService = await getPlatformService();
|
|
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
|
|
const accounts = mapQueryResultToValues(dbAccounts) as Account[];
|
|
const result = accounts.map((account) => {
|
|
return account as AccountEncrypted;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Saves a new identity to both SQL and Dexie databases
|
|
*/
|
|
export async function saveNewIdentity(
|
|
identity: IIdentifier,
|
|
mnemonic: string,
|
|
derivationPath: string,
|
|
): Promise<void> {
|
|
try {
|
|
// add to the new sql db
|
|
const platformService = await getPlatformService();
|
|
|
|
const secrets = await platformService.dbQuery(
|
|
`SELECT secretBase64 FROM secret`,
|
|
);
|
|
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
|
throw new Error(
|
|
"No initial encryption supported. We recommend you clear your data and start over.",
|
|
);
|
|
}
|
|
|
|
const secretBase64 = secrets.values[0][0] as string;
|
|
|
|
const secret = base64ToArrayBuffer(secretBase64);
|
|
const identityStr = JSON.stringify(identity);
|
|
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
|
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
|
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
|
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
|
|
|
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
|
VALUES (?, ?, ?, ?, ?, ?)`;
|
|
const params = [
|
|
new Date().toISOString(),
|
|
derivationPath,
|
|
identity.did,
|
|
encryptedIdentityBase64,
|
|
encryptedMnemonicBase64,
|
|
identity.keys[0].publicKeyHex,
|
|
];
|
|
await platformService.dbExec(sql, params);
|
|
|
|
await platformService.updateDefaultSettings({ activeDid: identity.did });
|
|
|
|
await platformService.insertDidSpecificSettings(identity.did);
|
|
} catch (error) {
|
|
logger.error("Failed to update default settings:", error);
|
|
throw new Error(
|
|
"Failed to set default settings. Please try again or restart the app.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a new identity, saves it to the database, and sets it as the active identity.
|
|
* @return {Promise<string>} with the DID of the new identity
|
|
*/
|
|
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
|
const mnemonic = generateSeed();
|
|
// address is 0x... ETH address, without "did:eth:"
|
|
const [address, privateHex, publicHex, derivationPath] =
|
|
deriveAddress(mnemonic);
|
|
|
|
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
|
|
|
await saveNewIdentity(newId, mnemonic, derivationPath);
|
|
const platformService = await getPlatformService();
|
|
await platformService.updateDidSpecificSettings(newId.did, {
|
|
isRegistered: false,
|
|
});
|
|
return newId.did;
|
|
};
|
|
|
|
export const registerAndSavePasskey = async (
|
|
keyName: string,
|
|
): Promise<Account> => {
|
|
const cred = await registerCredential(keyName);
|
|
const publicKeyBytes = cred.publicKeyBytes;
|
|
const did = createPeerDid(publicKeyBytes as Uint8Array);
|
|
const passkeyCredIdHex = cred.credIdHex as string;
|
|
|
|
const account = {
|
|
dateCreated: new Date().toISOString(),
|
|
did,
|
|
passkeyCredIdHex,
|
|
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
|
|
};
|
|
const platformService = await getPlatformService();
|
|
const insertStatement = platformService.generateInsertStatement(
|
|
account,
|
|
"accounts",
|
|
);
|
|
await platformService.dbExec(insertStatement.sql, insertStatement.params);
|
|
return account;
|
|
};
|
|
|
|
export const registerSaveAndActivatePasskey = async (
|
|
keyName: string,
|
|
): Promise<Account> => {
|
|
const account = await registerAndSavePasskey(keyName);
|
|
const platformService = await getPlatformService();
|
|
await platformService.updateDefaultSettings({ activeDid: account.did });
|
|
await platformService.updateDidSpecificSettings(account.did, {
|
|
isRegistered: false,
|
|
});
|
|
return account;
|
|
};
|
|
|
|
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
|
|
const platformService = await getPlatformService();
|
|
const settings = await platformService.retrieveSettingsForActiveAccount();
|
|
return (
|
|
((settings?.passkeyExpirationMinutes ??
|
|
DEFAULT_PASSKEY_EXPIRATION_MINUTES) as number) * 60
|
|
);
|
|
};
|
|
|
|
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
|
|
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
|
|
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
|
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
|
|
|
export const sendTestThroughPushServer = async (
|
|
subscriptionJSON: PushSubscriptionJSON,
|
|
skipFilter: boolean,
|
|
): Promise<AxiosResponse> => {
|
|
const platformService = await getPlatformService();
|
|
const settings = await platformService.retrieveSettingsForActiveAccount();
|
|
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
|
|
if (settings?.webPushServer) {
|
|
pushUrl = settings.webPushServer as string;
|
|
}
|
|
|
|
const newPayload = {
|
|
...subscriptionJSON,
|
|
// ... overridden with the following
|
|
// eslint-disable-next-line prettier/prettier
|
|
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
|
|
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
|
};
|
|
logger.log("Sending a test web push message:", newPayload);
|
|
const payloadStr = JSON.stringify(newPayload);
|
|
const response = await axios.post(
|
|
pushUrl + "/web-push/send-test",
|
|
payloadStr,
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
|
|
logger.log("Got response from web push server:", response);
|
|
return response;
|
|
};
|
|
|
|
/**
|
|
* Converts a Contact object to a CSV line string following the established format.
|
|
* The format matches CONTACT_CSV_HEADER: "name,did,pubKeyBase64,seesMe,registered,contactMethods"
|
|
* where contactMethods is stored as a stringified JSON array.
|
|
*
|
|
* @param contact - The Contact object to convert
|
|
* @returns A CSV-formatted string representing the contact
|
|
* @throws {Error} If the contact object is missing required fields
|
|
*/
|
|
export const contactToCsvLine = (contact: Contact): string => {
|
|
if (!contact.did) {
|
|
throw new Error("Contact must have a did field");
|
|
}
|
|
|
|
// Escape fields that might contain commas or quotes
|
|
const escapeField = (field: string | boolean | undefined): string => {
|
|
if (field === undefined) return "";
|
|
const str = String(field);
|
|
if (str.includes(",") || str.includes('"')) {
|
|
return `"${str.replace(/"/g, '""')}"`;
|
|
}
|
|
return str;
|
|
};
|
|
|
|
// Handle contactMethods array by stringifying it
|
|
const contactMethodsStr = contact.contactMethods
|
|
? escapeField(JSON.stringify(contact.contactMethods))
|
|
: "";
|
|
|
|
const fields = [
|
|
escapeField(contact.name),
|
|
escapeField(contact.did),
|
|
escapeField(contact.publicKeyBase64),
|
|
escapeField(contact.seesMe),
|
|
escapeField(contact.registered),
|
|
contactMethodsStr,
|
|
];
|
|
|
|
return fields.join(",");
|
|
};
|
|
|
|
/**
|
|
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
|
|
* @param lineRaw - The CSV line to parse
|
|
* @returns A Contact object
|
|
*/
|
|
export const csvLineToContact = (lineRaw: string): Contact => {
|
|
// Note that Endorser Mobile puts name first, then did, etc.
|
|
let line = lineRaw.trim();
|
|
let did, publicKeyInput, seesMe, registered;
|
|
let name;
|
|
let commaPos1 = -1;
|
|
if (line.startsWith('"')) {
|
|
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
|
|
if (doubleDoubleQuotePos === -1) {
|
|
doubleDoubleQuotePos = 1;
|
|
}
|
|
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
|
|
if (quote2Pos > -1) {
|
|
commaPos1 = line.indexOf(",", quote2Pos);
|
|
name = line.substring(1, quote2Pos).trim();
|
|
name = name.replace(/""/g, '"');
|
|
} else {
|
|
// something is weird with one " to start, so ignore it and start after "
|
|
line = line.substring(1);
|
|
commaPos1 = line.indexOf(",");
|
|
name = line.substring(0, commaPos1).trim();
|
|
}
|
|
} else {
|
|
commaPos1 = line.indexOf(",");
|
|
name = line.substring(0, commaPos1).trim();
|
|
}
|
|
if (commaPos1 > -1) {
|
|
did = line.substring(commaPos1 + 1).trim();
|
|
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
|
if (commaPos2 > -1) {
|
|
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
|
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
|
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
|
if (commaPos3 > -1) {
|
|
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
|
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
|
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
|
if (commaPos4 > -1) {
|
|
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
|
registered = line.substring(commaPos4 + 1).trim() == "true";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// help with potential mistakes while this sharing requires copy-and-paste
|
|
let publicKeyBase64 = publicKeyInput;
|
|
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
|
// it must be all hex (compressed public key), so convert
|
|
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
|
}
|
|
const newContact: Contact = {
|
|
did: did || "",
|
|
name,
|
|
publicKeyBase64,
|
|
seesMe,
|
|
registered,
|
|
};
|
|
return newContact;
|
|
};
|
|
|
|
/**
|
|
* Interface for the JSON export format of database tables
|
|
*/
|
|
export interface TableExportData {
|
|
tableName: string;
|
|
rows: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
/**
|
|
* Interface for the complete database export format
|
|
*/
|
|
export interface DatabaseExport {
|
|
data: {
|
|
data: Array<TableExportData>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converts an array of contacts to the export JSON format.
|
|
* This format is used for data migration and backup purposes.
|
|
*
|
|
* @param contacts - Array of Contact objects to convert
|
|
* @returns DatabaseExport object in the standardized format
|
|
*/
|
|
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
tableName: "contacts",
|
|
rows: contacts,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Imports an account from a mnemonic phrase
|
|
* @param mnemonic - The seed phrase to import from
|
|
* @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
|
|
* @param shouldErase - Whether to erase existing accounts before importing
|
|
* @returns Promise that resolves when import is complete
|
|
* @throws Error if mnemonic is invalid or import fails
|
|
*/
|
|
export async function importFromMnemonic(
|
|
mnemonic: string,
|
|
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
|
shouldErase: boolean = false,
|
|
): Promise<void> {
|
|
const mne: string = mnemonic.trim().toLowerCase();
|
|
|
|
// Check if this is Test User #0
|
|
const TEST_USER_0_MNEMONIC =
|
|
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
|
|
const isTestUser0 = mne === TEST_USER_0_MNEMONIC;
|
|
|
|
// Derive address and keys from mnemonic
|
|
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
|
|
|
// Create new identifier
|
|
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
|
|
|
// Handle erasures
|
|
if (shouldErase) {
|
|
const platformService = await getPlatformService();
|
|
await platformService.dbExec("DELETE FROM accounts");
|
|
}
|
|
|
|
// Save the new identity
|
|
await saveNewIdentity(newId, mne, derivationPath);
|
|
|
|
// Set up Test User #0 specific settings
|
|
if (isTestUser0) {
|
|
// Set up Test User #0 specific settings with enhanced error handling
|
|
const platformService = await getPlatformService();
|
|
|
|
try {
|
|
// First, ensure the DID-specific settings record exists
|
|
await platformService.insertDidSpecificSettings(newId.did);
|
|
|
|
// Then update with Test User #0 specific settings
|
|
await platformService.updateDidSpecificSettings(newId.did, {
|
|
firstName: "User Zero",
|
|
isRegistered: true,
|
|
});
|
|
|
|
// Verify the settings were saved correctly
|
|
const verificationResult = await platformService.dbQuery(
|
|
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
|
[newId.did],
|
|
);
|
|
|
|
if (verificationResult?.values?.length) {
|
|
const settings = verificationResult.values[0];
|
|
const firstName = settings[0];
|
|
const isRegistered = settings[1];
|
|
|
|
logger.info("[importFromMnemonic] Test User #0 settings verification", {
|
|
did: newId.did,
|
|
firstName,
|
|
isRegistered,
|
|
expectedFirstName: "User Zero",
|
|
expectedIsRegistered: true,
|
|
});
|
|
|
|
// If settings weren't saved correctly, try individual updates
|
|
if (firstName !== "User Zero" || isRegistered !== 1) {
|
|
logger.warn(
|
|
"[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates",
|
|
);
|
|
|
|
await platformService.dbExec(
|
|
"UPDATE settings SET firstName = ? WHERE accountDid = ?",
|
|
["User Zero", newId.did],
|
|
);
|
|
|
|
await platformService.dbExec(
|
|
"UPDATE settings SET isRegistered = ? WHERE accountDid = ?",
|
|
[1, newId.did],
|
|
);
|
|
|
|
// Verify again
|
|
const retryResult = await platformService.dbQuery(
|
|
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
|
[newId.did],
|
|
);
|
|
|
|
if (retryResult?.values?.length) {
|
|
const retrySettings = retryResult.values[0];
|
|
logger.info(
|
|
"[importFromMnemonic] Test User #0 settings after retry",
|
|
{
|
|
firstName: retrySettings[0],
|
|
isRegistered: retrySettings[1],
|
|
},
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
logger.error(
|
|
"[importFromMnemonic] Failed to verify Test User #0 settings - no record found",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
"[importFromMnemonic] Error setting up Test User #0 settings:",
|
|
error,
|
|
);
|
|
// Don't throw - allow the import to continue even if settings fail
|
|
}
|
|
}
|
|
}
|