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 (
+
+
+ [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;