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.
612 lines
19 KiB
612 lines
19 KiB
// 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 {
|
|
accountsDBPromise,
|
|
retrieveSettingsForActiveAccount,
|
|
updateAccountSettings,
|
|
updateDefaultSettings,
|
|
} from "../db/index";
|
|
import { Account } from "../db/tables/accounts";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
|
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import {
|
|
containsHiddenDid,
|
|
GenericCredWrapper,
|
|
GenericVerifiableCredential,
|
|
GiveSummaryRecord,
|
|
OfferVerifiableCredential,
|
|
} from "../libs/endorserServer";
|
|
import { KeyMeta } from "../libs/crypto/vc";
|
|
import { createPeerDid } from "../libs/crypto/vc/didPeer";
|
|
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
|
|
|
|
export interface GiverReceiverInputInfo {
|
|
did?: string;
|
|
name?: string;
|
|
}
|
|
|
|
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, Record<string, string>> = {
|
|
BTC: {
|
|
name: "Bitcoin",
|
|
faIcon: "bitcoin-sign",
|
|
},
|
|
HUR: {
|
|
name: "hours",
|
|
faIcon: "clock",
|
|
},
|
|
USD: {
|
|
name: "US Dollars",
|
|
faIcon: "dollar",
|
|
},
|
|
};
|
|
|
|
export function iconForUnitCode(unitCode: string) {
|
|
return UNIT_CODES[unitCode]?.faIcon || "question";
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 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 {
|
|
console.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<OfferVerifiableCredential>,
|
|
): string | undefined {
|
|
let giver;
|
|
if (
|
|
veriClaim.claim.offeredBy?.identifier &&
|
|
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string)
|
|
) {
|
|
giver = veriClaim.claim.offeredBy.identifier;
|
|
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
|
|
giver = veriClaim.issuer;
|
|
}
|
|
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>,
|
|
) => {
|
|
return (
|
|
veriClaim.claimType === "Offer" &&
|
|
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
|
|
);
|
|
};
|
|
|
|
// 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 interface AccountKeyInfo extends Account, KeyMeta {}
|
|
|
|
export const retrieveAccountCount = async (): Promise<number> => {
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
return await accountsDB.accounts.count();
|
|
};
|
|
|
|
export const retrieveAccountDids = async (): Promise<string[]> => {
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
const allAccounts = await accountsDB.accounts.toArray();
|
|
const allDids = allAccounts.map((acc) => acc.did);
|
|
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.
|
|
export const retrieveAccountMetadata = async (
|
|
activeDid: string,
|
|
): Promise<AccountKeyInfo | undefined> => {
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
const account = (await accountsDB.accounts
|
|
.where("did")
|
|
.equals(activeDid)
|
|
.first()) as Account;
|
|
if (account) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { identity, mnemonic, ...metadata } = account;
|
|
return metadata;
|
|
} else {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
const array = await accountsDB.accounts.toArray();
|
|
return array.map((account) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { identity, mnemonic, ...metadata } = account;
|
|
return metadata;
|
|
});
|
|
};
|
|
|
|
export const retrieveFullyDecryptedAccount = async (
|
|
activeDid: string,
|
|
): Promise<AccountKeyInfo | undefined> => {
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
const account = (await accountsDB.accounts
|
|
.where("did")
|
|
.equals(activeDid)
|
|
.first()) as Account;
|
|
return account;
|
|
};
|
|
|
|
// let's try and eliminate this
|
|
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
|
|
Array<AccountKeyInfo>
|
|
> => {
|
|
const accountsDB = await accountsDBPromise;
|
|
const allAccounts = await accountsDB.accounts.toArray();
|
|
return allAccounts;
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
const identity = JSON.stringify(newId);
|
|
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
await accountsDB.accounts.add({
|
|
dateCreated: new Date().toISOString(),
|
|
derivationPath: derivationPath,
|
|
did: newId.did,
|
|
identity: identity,
|
|
mnemonic: mnemonic,
|
|
publicKeyHex: newId.keys[0].publicKeyHex,
|
|
});
|
|
|
|
await updateDefaultSettings({ activeDid: newId.did });
|
|
//console.log("Updated default settings in util");
|
|
await updateAccountSettings(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"),
|
|
};
|
|
// one of the few times we use accountsDBPromise directly; try to avoid more usage
|
|
const accountsDB = await accountsDBPromise;
|
|
await accountsDB.accounts.add(account);
|
|
return account;
|
|
};
|
|
|
|
export const registerSaveAndActivatePasskey = async (
|
|
keyName: string,
|
|
): Promise<Account> => {
|
|
const account = await registerAndSavePasskey(keyName);
|
|
await updateDefaultSettings({ activeDid: account.did });
|
|
await updateAccountSettings(account.did, { isRegistered: false });
|
|
return account;
|
|
};
|
|
|
|
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
|
|
const settings = await retrieveSettingsForActiveAccount();
|
|
return (
|
|
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
|
|
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 settings = await retrieveSettingsForActiveAccount();
|
|
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
|
|
if (settings?.webPushServer) {
|
|
pushUrl = settings.webPushServer;
|
|
}
|
|
|
|
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",
|
|
};
|
|
console.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",
|
|
},
|
|
},
|
|
);
|
|
|
|
console.log("Got response from web push server:", response);
|
|
return response;
|
|
};
|
|
|