diff --git a/.eslintrc.js b/.eslintrc.js index adb5b185..03703124 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { "import/prefer-default-export": "error", "no-console": "error", "no-shadow": "off", // @typescript-eslint/no-shadow - "react-native/no-raw-text": ["error", { skip: ["Button"] }], + "react-native/no-raw-text": ["error", { skip: ["Button", "Heading", "SizableText"] }], "unicorn/filename-case": "off", // use default export name "unicorn/prefer-top-level-await": "off", // unsupported in react-native }, diff --git a/app/card.tsx b/app/card.tsx new file mode 100644 index 00000000..5f85977c --- /dev/null +++ b/app/card.tsx @@ -0,0 +1,36 @@ +import { AntDesign, Entypo, Feather } from "@expo/vector-icons"; +import React from "react"; +import { Text, YStack, XStack, Button, Heading } from "tamagui"; + +export default function Card() { + return ( + + + Balance + + + + $100.00 + + + + + + + + $12.93 (8.71%) Last 7 days + + + + + + + + + My Cards + + + ); +} diff --git a/app/create-card.tsx b/app/create-card.tsx new file mode 100644 index 00000000..1d5c0137 --- /dev/null +++ b/app/create-card.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useCallback, useState } from "react"; +import { Button, YStack, Text } from "tamagui"; + +import type { Card } from "../pomelo/utils/types"; +import { getCards, createCard } from "../utils/pomelo/client"; + +export default function Cards() { + const [cards, setCards] = useState(); + const [x, setX] = useState(); + const [loading, setLoading] = useState(true); + async function fetchCards() { + setLoading(true); + try { + const c = await getCards(); + + setCards(c); + } catch (error) { + setX(error instanceof Error ? error.message : "error"); + } finally { + setLoading(false); + } + } + useEffect(() => { + void fetchCards(); + }, []); + const handleClick = useCallback(async () => { + const card = await createCard(); + setX(JSON.stringify(card)); + await fetchCards(); + }, []); + + return ( + + + {loading ? "loading" : ""} + {x} + + {cards?.map((card) => ( + + + {card.card_type} {card.provider} + + {card.last_four} + + ))} + + + ); +} diff --git a/app/home.tsx b/app/home.tsx new file mode 100644 index 00000000..f0c0a0af --- /dev/null +++ b/app/home.tsx @@ -0,0 +1,47 @@ +import { Feather } from "@expo/vector-icons"; +import React from "react"; +import { Tabs, SizableText, YStack, XStack } from "tamagui"; +import { useDisconnect } from "wagmi"; + +import Auth from "./auth"; +import Card from "./create-card"; +import RegisterUser from "./register-user"; +import ExaLogo from "../assets/exa-logo.svg"; + +export default function Home() { + const { disconnect } = useDisconnect(); + + return ( + + + + + + disconnect()} size={24} color="black" /> + + + + + + + + + + + + + + + Card + + + auth + + + User + + + + + ); +} diff --git a/app/index.tsx b/app/index.tsx index 5e4fc1f4..476ce254 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,168 +1,14 @@ -import Auditor from "@exactly/protocol/deployments/goerli/Auditor.json"; -import MarketUSDC from "@exactly/protocol/deployments/goerli/MarketUSDC.json"; -import MarketWETH from "@exactly/protocol/deployments/goerli/MarketWETH.json"; -import { ApiKeyStamper } from "@turnkey/api-key-stamper"; -import { TurnkeyClient, createActivityPoller, getWebAuthnAttestation } from "@turnkey/http"; -import { deviceName } from "expo-device"; -import React, { useCallback } from "react"; -import { Button, Spinner, Text, XStack, YStack } from "tamagui"; -import { UAParser } from "ua-parser-js"; -import { formatEther, getAddress, parseUnits, zeroAddress } from "viem"; -import { - useAccount, - useConnect, - useDisconnect, - useReadContract, - useSimulateContract, - useWaitForTransactionReceipt, - useWriteContract, -} from "wagmi"; +import React from "react"; +import { useAccount } from "wagmi"; -import base64URLEncode from "../utils/base64URLEncode"; -import { rpId, turnkeyAPIPrivateKey, turnkeyAPIPublicKey, turnkeyOrganizationId } from "../utils/constants"; -import generateRandomBuffer from "../utils/generateRandomBuffer"; -import handleError from "../utils/handleError"; +import Home from "./home"; +import Welcome from "./welcome"; -export default function Home() { - const { - connect, - isPending: isConnecting, - connectors: [connector], - } = useConnect(); - const { address } = useAccount(); - const { disconnect } = useDisconnect(); - const { data: enterWETHSimulation } = useSimulateContract({ - abi: Auditor.abi, - address: getAddress(Auditor.address), - functionName: "enterMarket", - args: [MarketWETH.address], - query: { enabled: !!address }, - }); - const { data: borrowUSDCSimulation } = useSimulateContract({ - abi: MarketUSDC.abi, - address: getAddress(MarketUSDC.address), - functionName: "borrow", - args: [parseUnits("1", 6), address, address], - query: { enabled: !!address }, - }); - const { data: accountLiquidity } = useReadContract({ - abi: [ - { - name: "accountLiquidity", - type: "function", - stateMutability: "view", - inputs: [ - { internalType: "address", name: "account", type: "address" }, - { internalType: "contract Market", name: "marketToSimulate", type: "address" }, - { internalType: "uint256", name: "withdrawAmount", type: "uint256" }, - ], - outputs: [ - { internalType: "uint256", name: "sumCollateral", type: "uint256" }, - { internalType: "uint256", name: "sumDebtPlusEffects", type: "uint256" }, - ], - }, - ], - address: getAddress(Auditor.address), - functionName: "accountLiquidity", - args: [address ?? zeroAddress, zeroAddress, 0n], - query: { enabled: !!address }, - }); - const { writeContract, data: txHash, isPending: isSending } = useWriteContract(); - const { isSuccess, isLoading: isWaiting } = useWaitForTransactionReceipt({ hash: txHash }); +export default function Index() { + const { isConnecting, isReconnecting, isDisconnected } = useAccount(); - const createAccount = useCallback(() => { - const name = `exactly, ${new Date().toISOString()}`; - const challenge = generateRandomBuffer(); - getWebAuthnAttestation({ - publicKey: { - rp: { id: rpId, name: "exactly" }, - user: { id: challenge, name, displayName: name }, - pubKeyCredParams: [{ alg: -7, type: "public-key" }], - authenticatorSelection: { requireResidentKey: true, residentKey: "required", userVerification: "required" }, - challenge, - }, - }) - .then(async (attestation) => { - const client = new TurnkeyClient( - { baseUrl: "https://api.turnkey.com" }, - new ApiKeyStamper({ apiPublicKey: turnkeyAPIPublicKey, apiPrivateKey: turnkeyAPIPrivateKey }), - ); - const activityPoller = createActivityPoller({ client, requestFn: client.createSubOrganization }); - const { - result: { createSubOrganizationResultV4 }, - } = await activityPoller({ - type: "ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V4", - timestampMs: String(Date.now()), - organizationId: turnkeyOrganizationId, - parameters: { - subOrganizationName: attestation.credentialId, - rootQuorumThreshold: 1, - rootUsers: [ - { - apiKeys: [], - userName: "account", - authenticators: [ - { - authenticatorName: deviceName ?? new UAParser(navigator.userAgent).getBrowser().name ?? "unknown", - challenge: base64URLEncode(challenge), - attestation, - }, - ], - }, - ], - wallet: { - walletName: "default", - accounts: [ - { - curve: "CURVE_SECP256K1", - addressFormat: "ADDRESS_FORMAT_ETHEREUM", - pathFormat: "PATH_FORMAT_BIP32", - path: "m/44'/60'/0'/0/0", - }, - ], - }, - }, - }); - if (!createSubOrganizationResultV4?.wallet?.addresses[0]) throw new Error("sub-org creation failed"); - }) - .catch(handleError); - }, []); + if (isConnecting || isReconnecting) return "loading"; - const connectAccount = useCallback(() => { - if (!connector) throw new Error("no connector"); - connect({ connector }); - }, [connect, connector]); - - const disconnectAccount = useCallback(() => { - disconnect(); - }, [disconnect]); - - const enterWETH = useCallback(() => { - if (!enterWETHSimulation) throw new Error("no simulation"); - writeContract(enterWETHSimulation.request); - }, [enterWETHSimulation, writeContract]); - - const borrowUSDC = useCallback(() => { - if (!borrowUSDCSimulation) throw new Error("no simulation"); - writeContract(borrowUSDCSimulation.request); - }, [borrowUSDCSimulation, writeContract]); - - return ( - - - {txHash && `${txHash} ${isSuccess ? "✅" : ""}`} - {accountLiquidity && accountLiquidity.map((v) => formatEther(v)).join(", ")} - - - - - - - ); + if (isDisconnected) return ; + return ; } diff --git a/app/register-user.tsx b/app/register-user.tsx new file mode 100644 index 00000000..308d1f62 --- /dev/null +++ b/app/register-user.tsx @@ -0,0 +1,274 @@ +import type { FieldApi } from "@tanstack/react-form"; +import { useForm } from "@tanstack/react-form"; +import { zodValidator } from "@tanstack/zod-form-adapter"; +import React, { useCallback, useState } from "react"; +import { Button, Input, YStack, Spinner, Text } from "tamagui"; + +import type { CreateUserRequest, User } from "../pomelo/utils/types"; +import { createUser, getUser } from "../utils/pomelo/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function FieldInfo({ field }: { field: FieldApi }) { + return ( + <> + {field.state.meta.touchedErrors ? {field.state.meta.touchedErrors} : undefined} + {field.state.meta.isValidating ? "Validating..." : undefined} + + ); +} +export default function Register() { + const [loading, setLoading] = useState(false); + const [user, setUser] = useState(); + + const form = useForm({ + validatorAdapter: zodValidator, + defaultValues: { + email: "s@s.com", + name: "James", + surname: "Balboa", + // yyyy-MM-dd + birthdate: "2002-01-01", + street_name: "Calle Piola", + zip_code: "1111", + city: "Guadalajara", + region: "Guadalajara", + country: "MEX", + identification_type: "INE", + identification_value: "123456789", + gender: "MALE", + }, + onSubmit: async ({ value: { street_name, zip_code, city, region, country, ...rest } }) => { + setLoading(true); + try { + const body = { + ...rest, + legal_address: { street_name, zip_code: Number(zip_code), city, region, country }, + } satisfies Omit; + + const newUser = await createUser(body); + console.log(newUser); + setUser(newUser); + } catch (error) { + console.log(error); + } finally { + setLoading(false); + } + }, + }); + const logIn = useCallback(async () => { + const usr = await getUser(); + setUser(usr); + }, []); + if (user) return {JSON.stringify(user)}; + + return ( + + +
{ + event.preventDefault(); + event.stopPropagation(); + void form.handleSubmit(); + }} + > + + ( + { + field.handleChange(value); + }} + /> + )} + /> + ( + { + field.handleChange(value); + }} + /> + )} + /> + ( + { + field.handleChange(value); + }} + /> + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + ( + <> + { + field.handleChange(value); + }} + /> + + + )} + /> + + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + +
+ +
+ ); +} diff --git a/app/test.tsx b/app/test.tsx new file mode 100644 index 00000000..2a4a41a9 --- /dev/null +++ b/app/test.tsx @@ -0,0 +1,103 @@ +import Auditor from "@exactly/protocol/deployments/goerli/Auditor.json"; +import MarketUSDC from "@exactly/protocol/deployments/goerli/MarketUSDC.json"; +import MarketWETH from "@exactly/protocol/deployments/goerli/MarketWETH.json"; +import React, { useCallback } from "react"; +import { Button, Spinner, Text, XStack, YStack } from "tamagui"; +import { formatEther, getAddress, parseUnits, zeroAddress } from "viem"; +import { + useAccount, + useConnect, + useDisconnect, + useReadContract, + useSimulateContract, + useWaitForTransactionReceipt, + useWriteContract, +} from "wagmi"; + +import createAccount from "../utils/createAccount"; + +export default function Home() { + const { + connect, + isPending: isConnecting, + connectors: [connector], + } = useConnect(); + const { address } = useAccount(); + const { disconnect } = useDisconnect(); + const { data: enterWETHSimulation } = useSimulateContract({ + abi: Auditor.abi, + address: getAddress(Auditor.address), + functionName: "enterMarket", + args: [MarketWETH.address], + query: { enabled: !!address }, + }); + const { data: borrowUSDCSimulation } = useSimulateContract({ + abi: MarketUSDC.abi, + address: getAddress(MarketUSDC.address), + functionName: "borrow", + args: [parseUnits("1", 6), address, address], + query: { enabled: !!address }, + }); + const { data: accountLiquidity } = useReadContract({ + abi: [ + { + name: "accountLiquidity", + type: "function", + stateMutability: "view", + inputs: [ + { internalType: "address", name: "account", type: "address" }, + { internalType: "contract Market", name: "marketToSimulate", type: "address" }, + { internalType: "uint256", name: "withdrawAmount", type: "uint256" }, + ], + outputs: [ + { internalType: "uint256", name: "sumCollateral", type: "uint256" }, + { internalType: "uint256", name: "sumDebtPlusEffects", type: "uint256" }, + ], + }, + ], + address: getAddress(Auditor.address), + functionName: "accountLiquidity", + args: [address ?? zeroAddress, zeroAddress, 0n], + query: { enabled: !!address }, + }); + const { writeContract, data: txHash, isPending: isSending } = useWriteContract(); + const { isSuccess, isLoading: isWaiting } = useWaitForTransactionReceipt({ hash: txHash }); + + const connectAccount = useCallback(() => { + if (!connector) throw new Error("no connector"); + connect({ connector }); + }, [connect, connector]); + + const disconnectAccount = useCallback(() => { + disconnect(); + }, [disconnect]); + + const enterWETH = useCallback(() => { + if (!enterWETHSimulation) throw new Error("no simulation"); + writeContract(enterWETHSimulation.request); + }, [enterWETHSimulation, writeContract]); + + const borrowUSDC = useCallback(() => { + if (!borrowUSDCSimulation) throw new Error("no simulation"); + writeContract(borrowUSDCSimulation.request); + }, [borrowUSDCSimulation, writeContract]); + + return ( + + + {txHash && `${txHash} ${isSuccess ? "✅" : ""}`} + {accountLiquidity && accountLiquidity.map((v) => formatEther(v)).join(", ")} + + + + + + + ); +} diff --git a/app/welcome.tsx b/app/welcome.tsx new file mode 100644 index 00000000..75cf76c5 --- /dev/null +++ b/app/welcome.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useState } from "react"; +import { YStack, Button, Text, Spinner, Heading } from "tamagui"; +import { useConnect } from "wagmi"; + +import createAccount from "../utils/createAccount"; + +export default function Welcome() { + const [loading, setLoading] = useState(false); + + const { + connect, + connectors: [connector], + } = useConnect(); + + const connectAccount = useCallback(() => { + if (!connector) throw new Error("no connector"); + connect({ connector }); + }, [connect, connector]); + + const handleGetStartedClick = useCallback(async () => { + setLoading(true); + await createAccount(); + setLoading(false); + }, []); + + if (loading) + return ( + + + + ); + + return ( + + + + Buy now, pay later and hold your crypto. + + + Use our self-custodial digital credit card and pay with USDC in up to 12 installments without having to swap + your ETH or BTC. + + + + + + Already have an account? Recover passkey + + + + ); +} diff --git a/assets/exa-logo.svg b/assets/exa-logo.svg new file mode 100644 index 00000000..6a11346c --- /dev/null +++ b/assets/exa-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/types.d.ts b/assets/types.d.ts index 2211c5cb..5965237a 100644 --- a/assets/types.d.ts +++ b/assets/types.d.ts @@ -2,3 +2,9 @@ declare module "*.otf" { const value: FontSource; export default value; } +declare module "*.svg" { + import type React from "react"; + import type { SvgProps } from "react-native-svg"; + const content: React.FC; + export default content; +} diff --git a/assets/welcome-pos.svg b/assets/welcome-pos.svg new file mode 100644 index 00000000..6065040a --- /dev/null +++ b/assets/welcome-pos.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bun.lockb b/bun.lockb index 395d25b3..d2ac3750 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/metro.config.js b/metro.config.js index 8973f411..25416866 100644 --- a/metro.config.js +++ b/metro.config.js @@ -7,7 +7,8 @@ module.exports = { ...defaultConfig, resolver: { ...defaultConfig.resolver, - sourceExts: [...(defaultConfig.resolver?.sourceExts ?? []), "mjs"], + sourceExts: [...(defaultConfig.resolver?.sourceExts ?? []), "mjs", "svg"], + assetExts: defaultConfig.resolver.assetExts.filter((extension) => extension !== "svg"), blockList: [ ...(Array.isArray(defaultConfig.resolver?.blockList) ? defaultConfig.resolver.blockList @@ -20,4 +21,8 @@ module.exports = { stream: require.resolve("stream-browserify"), }, }, + transformer: { + ...defaultConfig.transformer, + babelTransformerPath: require.resolve("react-native-svg-transformer"), + }, }; diff --git a/package.json b/package.json index 1fae5cb7..fdcd009f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "@peculiar/webcrypto": "^1.4.3", "@react-native-async-storage/async-storage": "1.18.2", "@sentry/react-native": "^5.16.0", + "@simplewebauthn/browser": "^8.3.4", + "@simplewebauthn/types": "^9.0.1", + "@tanstack/react-form": "^0.13.3", + "@tanstack/zod-form-adapter": "^0.13.3", "@tamagui/config": "^1.85.12", "@tanstack/react-query": "5.8.4", "@turnkey/api-key-stamper": "^0.3.1", @@ -65,6 +69,7 @@ "react-native-quick-crypto": "^0.6.1", "react-native-safe-area-context": "4.6.3", "react-native-screens": "~3.22.0", + "react-native-svg": "13.9.0", "react-native-web": "^0.19.10", "react-onesignal": "^3.0.1", "sentry-expo": "^7.1.1", @@ -106,8 +111,10 @@ "expo-build-properties": "~0.8.3", "prettier": "^3.2.2", "react-devtools": "^5.0.0", + "react-native-svg-transformer": "^1.3.0", "sharp-cli": "^2.1.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "@simplewebauthn/typescript-types": "^8.3.4" }, "overrides": { "react-native-reanimated": "~3.5.4", diff --git a/pomelo/.gitignore b/pomelo/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/pomelo/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/pomelo/api/auth/generate-authentication-options.ts b/pomelo/api/auth/generate-authentication-options.ts new file mode 100644 index 00000000..40f8da57 --- /dev/null +++ b/pomelo/api/auth/generate-authentication-options.ts @@ -0,0 +1,19 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +import { saveChallenge } from "../../utils/auth.js"; +import allowCors from "../../utils/cors.js"; +import rpId from "../../utils/rpId.js"; + +async function handler(request: VercelRequest, response: VercelResponse) { + const { challengeID } = request.query as { challengeID: string }; + const options = await generateAuthenticationOptions({ + timeout: 60_000, + userVerification: "preferred", + rpID: rpId, + }); + await saveChallenge({ challenge: options.challenge, challengeID }); + response.send(options); +} + +export default allowCors(handler); diff --git a/pomelo/api/auth/generate-registration-options.ts b/pomelo/api/auth/generate-registration-options.ts new file mode 100644 index 00000000..fbb8e143 --- /dev/null +++ b/pomelo/api/auth/generate-registration-options.ts @@ -0,0 +1,32 @@ +import type { GenerateRegistrationOptionsOpts } from "@simplewebauthn/server"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +import { saveChallenge } from "../../utils/auth.js"; +import allowCors from "../../utils/cors.js"; +import rpId from "../../utils/rpId.js"; + +const ES256 = -7; + +async function handler(request: VercelRequest, response: VercelResponse) { + const { challengeID } = request.query as { challengeID: string }; + const registrationOptions: GenerateRegistrationOptionsOpts = { + rpName: "exactly", + rpID: rpId, + userID: challengeID, + // TODO: change username + userName: "username", + timeout: 60_000, + attestationType: "none", + authenticatorSelection: { + residentKey: "discouraged", + userVerification: "preferred", + }, + supportedAlgorithmIDs: [ES256], + }; + const options = await generateRegistrationOptions(registrationOptions); + await saveChallenge({ challenge: options.challenge, challengeID }); + response.send(options); +} + +export default allowCors(handler); diff --git a/pomelo/api/auth/verify-authentication.ts b/pomelo/api/auth/verify-authentication.ts new file mode 100644 index 00000000..02348daa --- /dev/null +++ b/pomelo/api/auth/verify-authentication.ts @@ -0,0 +1,75 @@ +import { + verifyAuthenticationResponse, + type VerifiedAuthenticationResponse, + type VerifyAuthenticationResponseOpts, +} from "@simplewebauthn/server"; +import { isoBase64URL, isoUint8Array } from "@simplewebauthn/server/helpers"; +import type { AuthenticationResponseJSON } from "@simplewebauthn/typescript-types"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import jwt from "jsonwebtoken"; + +import { base64URLEncode, getChallenge, getCredentialsByID, ORIGIN } from "../../utils/auth.js"; +import allowCors from "../../utils/cors.js"; +import rpId from "../../utils/rpId.js"; + +async function handler(request: VercelRequest, response: VercelResponse) { + const body = request.body as AuthenticationResponseJSON; + const { challengeID } = request.query as { challengeID: string }; + const credentials = await getCredentialsByID(body.id); + const challenge = await getChallenge(challengeID); + + if (!challenge) { + response.send({ verified: false }); + return; + } + + const { value: expectedChallenge } = challenge; + let databaseAuthenticator; + const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); + + for (const credential of credentials) { + if (isoUint8Array.areEqual(credential.id, bodyCredIDBuffer)) { + databaseAuthenticator = credential; + break; + } + } + + if (!databaseAuthenticator) { + response.status(400).send({ + error: "Authenticator is not registered with this site", + }); + return; + } + + let verification: VerifiedAuthenticationResponse; + try { + const options: VerifyAuthenticationResponseOpts = { + response: body, + expectedChallenge, + expectedOrigin: ORIGIN, + expectedRPID: rpId, + authenticator: databaseAuthenticator, + requireUserVerification: false, + }; + verification = await verifyAuthenticationResponse(options); + } catch (error) { + const _error = error as Error; + response.status(400).send({ error: _error.message }); + return; + } + + const { verified, authenticationInfo } = verification; + + if (verified) { + databaseAuthenticator.counter = authenticationInfo.newCounter; + } + if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + const token = jwt.sign({ credentialID: base64URLEncode(authenticationInfo.credentialID) }, process.env.JWT_SECRET, { + expiresIn: "24h", + }); + response.send({ verified, token }); +} + +export default allowCors(handler); diff --git a/pomelo/api/auth/verify-registration.ts b/pomelo/api/auth/verify-registration.ts new file mode 100644 index 00000000..e81fc8a7 --- /dev/null +++ b/pomelo/api/auth/verify-registration.ts @@ -0,0 +1,56 @@ +import { + verifyRegistrationResponse, + type VerifiedRegistrationResponse, + type VerifyRegistrationResponseOpts, +} from "@simplewebauthn/server"; +import type { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +import { getChallenge, ORIGIN, saveCredentials } from "../../utils/auth.js"; +import allowCors from "../../utils/cors.js"; +import rpId from "../../utils/rpId.js"; + +async function handler(request: VercelRequest, response: VercelResponse) { + const body = request.body as RegistrationResponseJSON; + const { challengeID } = request.query as { challengeID: string }; + + const challenge = await getChallenge(challengeID); + if (!challenge) { + response.send({ verified: false }); + return; + } + + const { value: expectedChallenge } = challenge; + + let verification: VerifiedRegistrationResponse; + try { + const options: VerifyRegistrationResponseOpts = { + response: body, + expectedChallenge, + expectedOrigin: ORIGIN, + expectedRPID: rpId, + requireUserVerification: false, + }; + verification = await verifyRegistrationResponse(options); + } catch (error) { + const _error = error as Error; + console.error(_error); + response.status(400).send({ error: _error.message }); + return; + } + const { verified, registrationInfo } = verification; + + if (verified && registrationInfo) { + const { credentialPublicKey, credentialID, counter } = registrationInfo; + + await saveCredentials({ + credentialPublicKey, + credentialID, + counter, + transports: body.response.transports, + }); + } + response.send({ verified }); +} + +export default allowCors(handler); diff --git a/pomelo/api/card.ts b/pomelo/api/card.ts new file mode 100644 index 00000000..99ba7ca3 --- /dev/null +++ b/pomelo/api/card.ts @@ -0,0 +1,30 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +import { authenticated } from "../utils/auth.js"; +import { createCard, getCardsByUserID } from "../utils/card.js"; +import allowCors from "../utils/cors.js"; +import { getUserByCredentialID } from "../utils/user.js"; + +async function handler(request: VercelRequest, response: VercelResponse, credentialID: string) { + const user = await getUserByCredentialID(credentialID); + if (!user) { + response.status(404).end("User not found"); + return; + } + if (request.method === "POST") { + try { + const card = await createCard({ + user_id: user.id, + card_type: "VIRTUAL", + affinity_group_id: "afg-2VfIFzzjDX9eRD2VVgmKnB6YmWm", // TODO: use env. note: we'll probably use the same affinity group for all cards + }); + response.status(200).json(card); + } catch (error) { + response.status(400).end(error instanceof Error ? error.message : "There was an error"); + } + } else if (request.method === "GET") { + const cards = await getCardsByUserID(user.id); + response.status(200).json(cards); + } +} +export default allowCors(authenticated(handler)); diff --git a/pomelo/api/user.ts b/pomelo/api/user.ts new file mode 100644 index 00000000..7a26e729 --- /dev/null +++ b/pomelo/api/user.ts @@ -0,0 +1,31 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +import { authenticated } from "../utils/auth.js"; +import allowCors from "../utils/cors.js"; +import type { CreateUserForm } from "../utils/types.js"; +import { createUser, getUserByCredentialID } from "../utils/user.js"; + +async function handler(request: VercelRequest, response: VercelResponse, credentialID: string) { + if (request.method === "POST") { + try { + const body = request.body as CreateUserForm; // TODO: validate request body + const user = await createUser({ + ...body, + operation_country: "MEX", // TODO: only for sandbox. For prod will be "PER" + email: `${credentialID}@exactly.account`, + }); + response.status(200).json(user); + } catch (error) { + response.status(400).end(error instanceof Error ? error.message : "Unknown error"); + } + } else if (request.method === "GET") { + const user = await getUserByCredentialID(credentialID); + if (user) { + response.status(200).json(user); + } else { + response.status(404).end("User not found"); + } + } +} + +export default allowCors(authenticated(handler)); diff --git a/pomelo/database/schema.ts b/pomelo/database/schema.ts index e69027eb..adb8f1ee 100644 --- a/pomelo/database/schema.ts +++ b/pomelo/database/schema.ts @@ -1,6 +1,6 @@ import { jsonb, pgEnum, pgTable, text, numeric } from "drizzle-orm/pg-core"; -import { CARD_STATUS, OPERATION_COUNTRIES, USER_STATUS } from "../utils/types"; +import { CARD_STATUS, OPERATION_COUNTRIES, USER_STATUS } from "../utils/types.js"; export const userStatusEnum = pgEnum("status", USER_STATUS); export const countryEnum = pgEnum("operation_country", OPERATION_COUNTRIES); @@ -42,3 +42,15 @@ export const transaction = pgTable("transactions", { transaction_status: transactionStatusEnum("transaction_status").notNull(), payload: jsonb("payload").notNull(), }); + +export const credential = pgTable("credentials", { + credentialID: text("credentialID").notNull(), + transports: text("transports").array(), + credentialPublicKey: text("credentialPublicKey").notNull(), + counter: numeric("counter").notNull(), +}); + +export const challenge = pgTable("challenge", { + id: text("id").primaryKey(), + value: text("value").notNull(), +}); diff --git a/pomelo/package.json b/pomelo/package.json index 982bd464..f2548a00 100644 --- a/pomelo/package.json +++ b/pomelo/package.json @@ -20,12 +20,17 @@ "debug": "^4.3.4", "drizzle-orm": "^0.29.3", "viem": "2.1.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "@simplewebauthn/server": "^8.3.6", + "@simplewebauthn/typescript-types": "^8.3.4", + "base64-arraybuffer": "^1.0.2", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "@types/node": "^20.11.3", "drizzle-kit": "^0.20.12", "typescript": "^5.3.3", - "vercel": "^33.1.0" + "vercel": "^32.7.2", + "@types/jsonwebtoken": "^9.0.5" } } diff --git a/pomelo/tsconfig.json b/pomelo/tsconfig.json index aea275ac..b96a17e9 100644 --- a/pomelo/tsconfig.json +++ b/pomelo/tsconfig.json @@ -3,7 +3,7 @@ "lib": ["ESNext"], "target": "ESNext", "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "node", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "esModuleInterop": true, diff --git a/pomelo/utils/auth.ts b/pomelo/utils/auth.ts new file mode 100644 index 00000000..21b48c19 --- /dev/null +++ b/pomelo/utils/auth.ts @@ -0,0 +1,81 @@ +import type { + AuthenticatorDevice, + AuthenticatorTransportFuture, + Base64URLString, +} from "@simplewebauthn/typescript-types"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { encode, decode } from "base64-arraybuffer"; +import { eq } from "drizzle-orm"; +import jwt from "jsonwebtoken"; + +import database from "../database/index.js"; +import { credential as credentialSchema, challenge as challengeSchema } from "../database/schema.js"; + +type TokenPayload = { + credentialID: string; + iat: number; + exp: number; +}; + +export const ORIGIN = "http://localhost:8081"; // TODO: this works for local development using web. Check what would be the origin for mobile + +// TODO: use function in /utils. for now it was not working +export function base64URLEncode(buffer: ArrayBufferLike) { + return encode(buffer).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} +// TODO: use function in /utils. for now it was not working +export function base64URLDecode(text: string) { + const buffer = decode(text.replaceAll("-", "+").replaceAll("_", "/")); + return new Uint8Array(buffer); +} + +export async function saveChallenge({ challengeID, challenge }: { challenge: Base64URLString; challengeID: string }) { + await database.delete(challengeSchema).where(eq(challengeSchema.id, challengeID)); + await database.insert(challengeSchema).values([{ id: challengeID, value: challenge }]); +} + +export async function getChallenge(challengeID: string) { + const [challenge] = await database.select().from(challengeSchema).where(eq(challengeSchema.id, challengeID)); + await database.delete(challengeSchema).where(eq(challengeSchema.id, challengeID)); + return challenge; +} + +export async function saveCredentials(credential: AuthenticatorDevice) { + await database.insert(credentialSchema).values([ + { + credentialID: base64URLEncode(credential.credentialID), + transports: credential.transports, + credentialPublicKey: base64URLEncode(credential.credentialPublicKey).toString(), + counter: credential.counter.toString(), + }, + ]); +} + +export async function getCredentialsByID(id: string) { + const credentials = await database.select().from(credentialSchema).where(eq(credentialSchema.credentialID, id)); + return credentials.map((cred) => ({ + ...cred, + id: base64URLDecode(cred.credentialID), + transports: cred.transports as AuthenticatorTransportFuture[], + credentialPublicKey: base64URLDecode(cred.credentialPublicKey), + credentialID: base64URLDecode(cred.credentialID), + counter: Number(cred.counter), + })); +} + +export const authenticated = + (handler: (request: VercelRequest, response: VercelResponse, userID: string) => Promise) => + async (request: VercelRequest, response: VercelResponse) => { + const { authorization } = request.headers; + if (!authorization) { + response.status(400).end("No token provided"); + return; + } + const token = authorization.split("Bearer ")[1]; + if (!token) { + response.status(400).end("No token provided"); + return; + } + const { credentialID } = jwt.decode(token) as TokenPayload; + await handler(request, response, credentialID); + }; diff --git a/pomelo/utils/card.ts b/pomelo/utils/card.ts index 3bd73b33..9f336804 100644 --- a/pomelo/utils/card.ts +++ b/pomelo/utils/card.ts @@ -1,21 +1,22 @@ import request from "./request.js"; import type { Card, CreateCardRequest, Paginated, User } from "./types.js"; -import { card as cardSchema, paginated } from "./types.js"; +import { card as cardSchema, paginated, responseData } from "./types.js"; export function getCard(id: Card["id"]) { return request(`/cards/v1/${id}`, { method: "GET" }, cardSchema); } -export function getCardByUserID(userId: User["id"]) { - return request>( +export async function getCardsByUserID(userId: User["id"]) { + const response = await request>( `/cards/v1?filter[user_id]=${userId}`, { method: "GET" }, paginated(cardSchema), ); + return response.data; } export function createCard(card: CreateCardRequest) { - return request( + return request<{ data: Card }>( "/cards/v1", { method: "POST", @@ -24,6 +25,6 @@ export function createCard(card: CreateCardRequest) { "x-idempotency-key": card.user_id, // TODO use a real idempotency key }, }, - cardSchema, + responseData(cardSchema), ); } diff --git a/pomelo/utils/cors.ts b/pomelo/utils/cors.ts new file mode 100644 index 00000000..6c3e2781 --- /dev/null +++ b/pomelo/utils/cors.ts @@ -0,0 +1,17 @@ +import type { VercelApiHandler, VercelRequest, VercelResponse } from "@vercel/node"; + +import { ORIGIN } from "./auth"; + +const allowCors = (function_: VercelApiHandler) => async (request: VercelRequest, response: VercelResponse) => { + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Origin", ORIGIN); + response.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS,PATCH,DELETE,POST,PUT"); + response.setHeader("Access-Control-Allow-Headers", "*, Authorization"); + if (request.method === "OPTIONS") { + response.status(200).end(); + return; + } + await function_(request, response); +}; + +export default allowCors; diff --git a/pomelo/utils/request.ts b/pomelo/utils/request.ts index e5aeb652..84a23267 100644 --- a/pomelo/utils/request.ts +++ b/pomelo/utils/request.ts @@ -41,7 +41,7 @@ export default async function request( }); const response = await fetch(_request); - const json = response.json(); + const json: unknown = await response.json(); if (!response.ok) { const { @@ -68,5 +68,12 @@ export default async function request( } } - return validator.parse(json); + try { + return validator.parse(json); + } catch { + // TODO: review with team + // dont throw when validation fails? + // maybe just report to sentry? + return json as Response; + } } diff --git a/pomelo/utils/rpId.ts b/pomelo/utils/rpId.ts new file mode 100644 index 00000000..61086471 --- /dev/null +++ b/pomelo/utils/rpId.ts @@ -0,0 +1,5 @@ +// TODO: check if we could use value from utils/contants.ts instead +// Error: Named export 'rpId' not found. The requested module '../../../utils/constants.js' is a CommonJS module, which may not support all module.exports as named exports. +// also, rpID depends on the environment, so it's better to keep it in a separate file? + +export default "localhost"; diff --git a/pomelo/utils/types.ts b/pomelo/utils/types.ts index 652357d9..1a0d93e0 100644 --- a/pomelo/utils/types.ts +++ b/pomelo/utils/types.ts @@ -61,10 +61,10 @@ export const user = z.object({ identification_value: z.string().optional(), birthdate: date.optional(), gender: z.string().optional(), - phone: z.number(), + phone: z.number().optional(), tax_identification_type: z.enum(["CUIL", "CUIT", "CDI", "CPF", "RUT", "RUC", "RFC", "NIT"]).optional(), tax_identification_value: z.string().optional(), - nationality: country, + nationality: country.optional(), legal_address: address.optional(), }); @@ -77,6 +77,7 @@ const createUserRequest = user.omit({ }); export type CreateUserRequest = z.infer; +export type CreateUserForm = Omit; export const card = z.object({ id: z.string().regex(/^crd-.*/), @@ -207,13 +208,15 @@ export type AuthorizationResponse = z.infer; export const paginated = >(data: T) => z.object({ - data: data.array(), + data: z.array(data), meta: z.object({ pagination: z.object({ total_pages: z.number(), - current: z.number(), + current_page: z.number(), }), }), }); +export const responseData = >(data: T) => z.object({ data }); + export type Paginated> = z.infer>>; diff --git a/pomelo/utils/user.ts b/pomelo/utils/user.ts index db1e9eb0..ff7eeb0a 100644 --- a/pomelo/utils/user.ts +++ b/pomelo/utils/user.ts @@ -1,15 +1,24 @@ import request from "./request.js"; import type { CreateUserRequest, User } from "./types.js"; -import { user as userSchema } from "./types.js"; +import { paginated, responseData, user as userSchema } from "./types.js"; export async function getUser(userId: User["id"]) { - try { - return request(`/users/v1/${userId}`, { method: "GET" }, userSchema); - } catch { - // couldn't find user, return undefined - } + return request<{ data: User }>(`/users/v1/${userId}`, { method: "GET" }, responseData(userSchema)); } -export function createUser(user: CreateUserRequest) { - return request("/users/v1", { method: "POST", body: user }, userSchema); +export async function getUserByEmail(email: User["email"]) { + const response = await request<{ data: User[] }>( + `/users/v1/?filter[email]=${email}`, + { method: "GET" }, + paginated(userSchema), + ); + return response.data[0]; +} + +export async function getUserByCredentialID(credentialID: string) { + return getUserByEmail(`${credentialID}@exactly.account`); +} + +export async function createUser(user: CreateUserRequest) { + return request<{ data: User }>("/users/v1", { method: "POST", body: user }, responseData(userSchema)); } diff --git a/tsconfig.json b/tsconfig.json index 62621aa7..571f9db3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "checkJs": true, "noUncheckedIndexedAccess": true }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"], + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "**/*.svg"], "exclude": [ "contracts", "pomelo", diff --git a/utils/constants.ts b/utils/constants.ts index 032c71f4..c2207b8a 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -8,6 +8,7 @@ if (!process.env.EXPO_PUBLIC_ALCHEMY_GAS_POLICY_ID) throw new Error("missing alc if (!process.env.EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID) throw new Error("missing turnkey organization id"); if (!process.env.EXPO_PUBLIC_TURNKEY_API_PUBLIC_KEY) throw new Error("missing turnkey api public key"); if (!process.env.EXPO_PUBLIC_TURNKEY_API_PRIVATE_KEY) throw new Error("missing turnkey api private key"); +if (!process.env.EXPO_PUBLIC_BACKEND_URL) throw new Error("missing backend url"); export const alchemyAPIKey = process.env.EXPO_PUBLIC_ALCHEMY_API_KEY; export const alchemyGasPolicyId = process.env.EXPO_PUBLIC_ALCHEMY_GAS_POLICY_ID; @@ -15,6 +16,7 @@ export const turnkeyAPIPublicKey = process.env.EXPO_PUBLIC_TURNKEY_API_PUBLIC_KE export const turnkeyAPIPrivateKey = process.env.EXPO_PUBLIC_TURNKEY_API_PRIVATE_KEY; export const turnkeyOrganizationId = process.env.EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID; export const oneSignalAppId = process.env.EXPO_PUBLIC_ONE_SIGNAL_APP_ID; +export const backendURL = process.env.EXPO_PUBLIC_BACKEND_URL; export const chain = { ...goerli, diff --git a/utils/createAccount.ts b/utils/createAccount.ts new file mode 100644 index 00000000..13b08407 --- /dev/null +++ b/utils/createAccount.ts @@ -0,0 +1,84 @@ +import { startRegistration } from "@simplewebauthn/browser"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { TurnkeyClient, createActivityPoller } from "@turnkey/http"; +import { deviceName } from "expo-device"; +import { UAParser } from "ua-parser-js"; + +import base64URLEncode from "./base64URLEncode"; +import { rpId, turnkeyAPIPrivateKey, turnkeyAPIPublicKey, turnkeyOrganizationId } from "./constants"; +import generateRandomBuffer from "./generateRandomBuffer"; +import handleError from "./handleError"; +import { registrationOptions, verifyRegistration } from "./pomelo/client"; +import uppercase from "./uppercase"; + +export default async function createAccount() { + const challengeID = base64URLEncode(generateRandomBuffer()); + const { challenge } = await registrationOptions(challengeID); + const name = `exactly, ${new Date().toISOString()}`; + const attestation = await startRegistration({ + rp: { id: rpId, name: "exactly" }, + user: { id: challenge, name, displayName: name }, + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + authenticatorSelection: { requireResidentKey: true, residentKey: "required", userVerification: "required" }, + challenge, + }); + const client = new TurnkeyClient( + { baseUrl: "https://api.turnkey.com" }, + new ApiKeyStamper({ apiPublicKey: turnkeyAPIPublicKey, apiPrivateKey: turnkeyAPIPrivateKey }), + ); + const activityPoller = createActivityPoller({ client, requestFn: client.createSubOrganization }); + try { + const { + result: { createSubOrganizationResultV4 }, + } = await activityPoller({ + type: "ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V4", + timestampMs: String(Date.now()), + organizationId: turnkeyOrganizationId, + parameters: { + subOrganizationName: attestation.id, + rootQuorumThreshold: 1, + rootUsers: [ + { + apiKeys: [], + userName: "account", + authenticators: [ + { + authenticatorName: deviceName ?? new UAParser(navigator.userAgent).getBrowser().name ?? "unknown", + challenge, + attestation: { + credentialId: attestation.id, + attestationObject: attestation.response.attestationObject, + clientDataJson: attestation.response.clientDataJSON, + transports: + attestation.response.transports?.map( + (t) => + `AUTHENTICATOR_TRANSPORT_${uppercase(t === "smart-card" || t === "cable" ? "usb" : t)}` as const, + ) || [], + }, + }, + ], + }, + ], + wallet: { + walletName: "default", + accounts: [ + { + curve: "CURVE_SECP256K1", + addressFormat: "ADDRESS_FORMAT_ETHEREUM", + pathFormat: "PATH_FORMAT_BIP32", + path: "m/44'/60'/0'/0/0", + }, + ], + }, + }, + }); + if (!createSubOrganizationResultV4?.wallet?.addresses[0]) throw new Error("sub-org creation failed"); + + const verifyRegistrationResponse = await verifyRegistration({ challengeID, attestation }); + const { verified } = verifyRegistrationResponse; + + alert(verified ? "Account created" : "Account creation failed"); + } catch (error) { + handleError(error); + } +} diff --git a/utils/pomelo/client.ts b/utils/pomelo/client.ts new file mode 100644 index 00000000..42bc1dab --- /dev/null +++ b/utils/pomelo/client.ts @@ -0,0 +1,136 @@ +import { startAuthentication } from "@simplewebauthn/browser"; +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/typescript-types"; + +import type { Card, CreateUserForm, User } from "../../pomelo/utils/types"; +import base64URLEncode from "../base64URLEncode"; +import { backendURL } from "../constants"; +import generateRandomBuffer from "../generateRandomBuffer"; + +async function accessToken() { + const challengeID = base64URLEncode(generateRandomBuffer()); + const optionsResponse = await fetch(`${backendURL}/auth/generate-authentication-options?challengeID=${challengeID}`); + const options = (await optionsResponse.json()) as PublicKeyCredentialRequestOptionsJSON; + const authenticationResponse = await startAuthentication(options); + const verificationResp = await fetch(`${backendURL}/auth/verify-authentication?challengeID=${challengeID}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(authenticationResponse), + }); + const { verified, token } = (await verificationResp.json()) as { verified: boolean; token: string }; + + if (!verified) { + throw new Error("Authentication failed"); + } + + return token; +} + +async function pomelo( + url: `/${string}`, + init: Omit & { method: "GET" | "POST" | "PATCH"; body?: object }, + authenticated: boolean, +) { + const _request = new Request(`${backendURL}${url}`, { + ...init, + headers: { + ...init.headers, + "Content-Type": "application/json; charset=UTF-8", + ...(authenticated ? { Authorization: `Bearer ${await accessToken()}` } : {}), + }, + body: JSON.stringify(init.body), + }); + + const response = await fetch(_request); + const json: unknown = await response.json(); + + if (!response.ok) { + const { + error: { details }, + } = json as { error: { details: { detail: string }[] } }; + + const detailsText = details.map(({ detail }) => detail).join(", "); + switch (response.status) { + case 400: { + throw new Error(`invalid request: ${detailsText}`); + } + case 401: { + throw new Error("unauthorized"); + } + case 403: { + throw new Error("forbidden"); + } + case 404: { + throw new Error(`not found: ${detailsText}`); + } + default: { + throw new Error(`unexpected error: ${response.status}`); // TODO report to sentry + } + } + } + + return json as Response; +} + +export async function getCards() { + return pomelo("/card", { method: "GET" }, true); +} + +export async function createCard() { + return pomelo( + "/card", + { + method: "POST", + }, + true, + ); +} + +export async function createUser(user: CreateUserForm) { + return pomelo("/user", { method: "POST", body: user }, true); +} + +export async function getUser() { + return pomelo( + "/user", + { + method: "GET", + }, + true, + ); +} + +export async function verifyRegistration({ + challengeID, + attestation, +}: { + challengeID: string; + attestation: RegistrationResponseJSON; +}) { + return pomelo<{ verified: boolean }>( + `/auth/verify-registration?challengeID=${challengeID}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: attestation, + }, + false, + ); +} + +export async function registrationOptions(challengeID: string) { + return await pomelo( + `/auth/generate-registration-options?challengeID=${challengeID}`, + { + method: "GET", + }, + false, + ); +} diff --git a/utils/uppercase.ts b/utils/uppercase.ts new file mode 100644 index 00000000..05dea89e --- /dev/null +++ b/utils/uppercase.ts @@ -0,0 +1,3 @@ +const uppercase = (string: T) => string.toUpperCase() as Uppercase; + +export default uppercase;