From a18179f3bd80f393d2e1d8aa1c46e10b4bf5773e Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 1 Sep 2026 11:10:43 +0200 Subject: [PATCH] refactor: extract duplicated helpers into shared modules - client rpc() fetch wrapper (12 copies) -> src/lib/client-rpc.ts - server json() Response helper (12 copies) + readFormData() (6 try/catch blocks) -> src/lib/api-utils.ts - toHexField() (3 copies) -> src/lib/token-utils.ts, next to its inverse hexToText() - ModalOverlay component (3 identical overlay shells) -> src/components/ui/ModalOverlay.tsx - consumeChallengeFromRequest() (verbatim in both passkey verify routes) -> src/lib/passkey.ts; all 4 passkey routes now use the shared json() No behavior changes. Tests updated to mock the new passkey export. 502/502 tests pass; tsc clean. Net -294 lines. --- app/src/components/DelegationPanel.tsx | 12 +----- app/src/components/IssueNFTModal.tsx | 42 +++--------------- app/src/components/IssueTokenModal.tsx | 42 +++--------------- app/src/components/IssuedTokensPanel.tsx | 12 +----- app/src/components/NFTMarketplace.tsx | 12 +----- app/src/components/OrderBook.tsx | 12 +----- app/src/components/OwnedNFTsPanel.tsx | 12 +----- app/src/components/PoolActions.tsx | 12 +----- app/src/components/ReceiveModal.tsx | 12 +----- app/src/components/StakingControl.tsx | 12 +----- app/src/components/TokenManagePanel.tsx | 29 +++---------- app/src/components/TokenSearch.tsx | 12 +----- app/src/components/ui/ModalOverlay.tsx | 24 +++++++++++ app/src/lib/api-utils.ts | 20 +++++++++ app/src/lib/client-rpc.ts | 14 ++++++ app/src/lib/passkey.ts | 11 +++++ app/src/lib/token-utils.ts | 7 +++ app/src/pages/api/address-tokens.ts | 8 +--- app/src/pages/api/ipfs-upload.ts | 8 +--- app/src/pages/api/passkey/auth-options.ts | 14 ++---- app/src/pages/api/passkey/auth-verify.test.ts | 8 ++-- app/src/pages/api/passkey/auth-verify.ts | 40 ++++++----------- app/src/pages/api/passkey/register-options.ts | 15 ++----- .../pages/api/passkey/register-verify.test.ts | 8 ++-- app/src/pages/api/passkey/register-verify.ts | 43 ++++++------------- app/src/pages/api/plugins/[id]/toggle.ts | 8 +--- app/src/pages/api/plugins/[id]/uninstall.ts | 8 +--- app/src/pages/api/plugins/install.ts | 16 ++----- app/src/pages/api/prefs.ts | 8 +--- app/src/pages/api/scan-issued-tokens.ts | 8 +--- app/src/pages/api/settings/ipfs.ts | 16 ++----- app/src/pages/api/settings/password.ts | 16 ++----- app/src/pages/api/settings/reset-2fa.ts | 16 ++----- app/src/pages/api/settings/telegram.ts | 16 ++----- app/src/pages/api/token-authority.ts | 8 +--- app/src/pages/api/token-search.ts | 8 +--- app/src/pages/api/wallet-open.ts | 7 +-- 37 files changed, 170 insertions(+), 406 deletions(-) create mode 100644 app/src/components/ui/ModalOverlay.tsx create mode 100644 app/src/lib/api-utils.ts create mode 100644 app/src/lib/client-rpc.ts diff --git a/app/src/components/DelegationPanel.tsx b/app/src/components/DelegationPanel.tsx index f7bf198..dce6e30 100644 --- a/app/src/components/DelegationPanel.tsx +++ b/app/src/components/DelegationPanel.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { watchTx } from "@/lib/txWatcher"; import { submitWithToast } from "@/lib/toastStore"; import { CopyButton } from "@/components/CopyButton"; +import { rpc } from '@/lib/client-rpc'; interface Delegation { delegation_id: string; @@ -19,17 +20,6 @@ interface Props { type ActionState = "idle" | "loading" | "success" | "error"; -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - /** Get the first unused wallet address, generating a new one only if all are used. */ async function freshAddress(): Promise { const addresses = await rpc>( diff --git a/app/src/components/IssueNFTModal.tsx b/app/src/components/IssueNFTModal.tsx index 7b17766..774a2e9 100644 --- a/app/src/components/IssueNFTModal.tsx +++ b/app/src/components/IssueNFTModal.tsx @@ -2,6 +2,9 @@ import { useState, useEffect } from 'react'; import { submitWithToast } from '@/lib/toastStore'; import { watchTx } from '@/lib/txWatcher'; import { CopyButton } from '@/components/CopyButton'; +import { rpc } from '@/lib/client-rpc'; +import { toHexField } from '@/lib/token-utils'; +import { ModalOverlay } from '@/components/ui/ModalOverlay'; type Mode = 'easy' | 'expert'; @@ -11,24 +14,6 @@ interface Props { onIssued?: (tokenId: string) => void; } -async function rpc(method: string, params: Record): Promise { - const res = await fetch('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); - return data.result as T; -} - -/** Encode a UTF-8 string as a lowercase hex string for the Mintlayer RPC `{ hex }` format. */ -function toHexField(str: string): { hex: string } { - const bytes = new TextEncoder().encode(str); - const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - return { hex }; -} - /** Strip non-alphanumeric characters and enforce max byte length (chain constraint). */ function sanitize(str: string, maxLen: number): string { return str.replace(/[^a-zA-Z0-9]/g, '').slice(0, maxLen); @@ -182,7 +167,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props) if (issuedTokenId) { return ( - +

NFT Issued!

@@ -196,12 +181,12 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props) Close
-
+ ); } return ( - +

Mint NFT

@@ -414,7 +399,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
- + ); } @@ -441,19 +426,6 @@ function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => voi ); } -function Overlay({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { - return ( -
e.target === e.currentTarget && onClose()} - > -
- {children} -
-
- ); -} - const inp = 'w-full rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-mint-600'; const cancelBtn = 'flex-1 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 transition-colors'; const submitBtn = 'flex-1 rounded-lg bg-mint-700 hover:bg-mint-600 px-4 py-2 text-sm font-semibold text-white transition-colors disabled:opacity-50'; diff --git a/app/src/components/IssueTokenModal.tsx b/app/src/components/IssueTokenModal.tsx index d592dd0..93ca841 100644 --- a/app/src/components/IssueTokenModal.tsx +++ b/app/src/components/IssueTokenModal.tsx @@ -2,6 +2,9 @@ import { useState, useEffect } from 'react'; import { submitWithToast } from '@/lib/toastStore'; import { watchTx } from '@/lib/txWatcher'; import { CopyButton } from '@/components/CopyButton'; +import { rpc } from '@/lib/client-rpc'; +import { toHexField } from '@/lib/token-utils'; +import { ModalOverlay } from '@/components/ui/ModalOverlay'; type SupplyType = 'Fixed' | 'Lockable' | 'Unlimited'; type Mode = 'easy' | 'expert'; @@ -12,24 +15,6 @@ interface Props { onIssued?: (tokenId: string) => void; } -async function rpc(method: string, params: Record): Promise { - const res = await fetch('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); - return data.result as T; -} - -/** Encode a UTF-8 string as a lowercase hex string for the Mintlayer RPC `{ hex }` format. */ -function toHexField(str: string): { hex: string } { - const bytes = new TextEncoder().encode(str); - const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - return { hex }; -} - async function uploadFile(file: File): Promise { const form = new FormData(); form.append('file', file); @@ -149,7 +134,7 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop if (issuedTokenId) { return ( - +

Token Issued!

@@ -163,12 +148,12 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop Close
-
+ ); } return ( - +

Issue Fungible Token

@@ -357,7 +342,7 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop
- + ); } @@ -384,19 +369,6 @@ function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => voi ); } -function Overlay({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { - return ( -
e.target === e.currentTarget && onClose()} - > -
- {children} -
-
- ); -} - const input = 'w-full rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-mint-600'; const cancelBtn = 'flex-1 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 transition-colors'; const submitBtn = 'flex-1 rounded-lg bg-mint-700 hover:bg-mint-600 px-4 py-2 text-sm font-semibold text-white transition-colors disabled:opacity-50'; diff --git a/app/src/components/IssuedTokensPanel.tsx b/app/src/components/IssuedTokensPanel.tsx index e9b5cf1..a97aa9d 100644 --- a/app/src/components/IssuedTokensPanel.tsx +++ b/app/src/components/IssuedTokensPanel.tsx @@ -3,6 +3,7 @@ import { hexToText } from '@/lib/token-utils'; import { CopyButton } from '@/components/CopyButton'; import TokenManagePanel from '@/components/TokenManagePanel'; import { TokenIdTooltip } from '@/components/TokenIdTooltip'; +import { rpc } from '@/lib/client-rpc'; // ── Types ────────────────────────────────────────────────────────────────────── @@ -56,17 +57,6 @@ function saveStored(list: StoredToken[]) { localStorage.setItem(LS_KEY, JSON.stringify(deduped)); } -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); - return data.result as T; -} - function atomsToDecimal(atoms: string, decimals: number): string { if (decimals === 0) return atoms; const n = BigInt(atoms); diff --git a/app/src/components/NFTMarketplace.tsx b/app/src/components/NFTMarketplace.tsx index 8962d66..ec46a75 100644 --- a/app/src/components/NFTMarketplace.tsx +++ b/app/src/components/NFTMarketplace.tsx @@ -5,6 +5,7 @@ import { watchTx } from "@/lib/txWatcher"; import { submitWithToast } from "@/lib/toastStore"; import { hexToText } from "@/lib/token-utils"; import type { TokenCurrency, OrderInfo } from "@/lib/wallet-rpc"; +import { rpc } from '@/lib/client-rpc'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -43,17 +44,6 @@ function resolveUri(raw: string | null): string | null { return raw; } -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - function friendlyError(err: unknown): string { const raw = (err as Error)?.message ?? String(err); const lower = raw.toLowerCase(); diff --git a/app/src/components/OrderBook.tsx b/app/src/components/OrderBook.tsx index e7df4a9..4478950 100644 --- a/app/src/components/OrderBook.tsx +++ b/app/src/components/OrderBook.tsx @@ -5,6 +5,7 @@ import { watchTx } from "@/lib/txWatcher"; import { submitWithToast } from "@/lib/toastStore"; import { CopyButton } from "@/components/CopyButton"; import type { OrderInfo, TokenCurrency } from "@/lib/wallet-rpc"; +import { rpc } from '@/lib/client-rpc'; // Raw shape returned by order_list_all_active (flat, no existing_order_data wrapper) interface ActiveOrderRaw { @@ -59,17 +60,6 @@ function saveFavourites(favs: FavouriteEntry[]): void { // ── RPC helper ──────────────────────────────────────────────────────────────── -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - // ── Error translation ───────────────────────────────────────────────────────── function friendlyError(err: unknown): string { diff --git a/app/src/components/OwnedNFTsPanel.tsx b/app/src/components/OwnedNFTsPanel.tsx index d0e2cd4..9fccb4e 100644 --- a/app/src/components/OwnedNFTsPanel.tsx +++ b/app/src/components/OwnedNFTsPanel.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { hexToText } from "@/lib/token-utils"; import { CopyButton } from "@/components/CopyButton"; +import { rpc } from '@/lib/client-rpc'; interface NFTEntry { tokenId: string; @@ -23,17 +24,6 @@ function resolveUri(raw: string | null): string | null { return raw; } -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - // ── Image cell ──────────────────────────────────────────────────────────────── function NFTImageCell({ uri, name }: { uri: string | null; name: string }) { diff --git a/app/src/components/PoolActions.tsx b/app/src/components/PoolActions.tsx index 4d9bc20..4bb5d18 100644 --- a/app/src/components/PoolActions.tsx +++ b/app/src/components/PoolActions.tsx @@ -3,17 +3,7 @@ import { useState } from "react"; import { watchTx } from "@/lib/txWatcher"; import { submitWithToast } from "@/lib/toastStore"; - -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} +import { rpc } from '@/lib/client-rpc'; async function freshAddress(): Promise { const addresses = await rpc>( diff --git a/app/src/components/ReceiveModal.tsx b/app/src/components/ReceiveModal.tsx index b9ff2fe..6f9fdff 100644 --- a/app/src/components/ReceiveModal.tsx +++ b/app/src/components/ReceiveModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { QRCodeSVG } from "qrcode.react"; +import { rpc } from '@/lib/client-rpc'; type State = | { status: "idle" } @@ -9,17 +10,6 @@ type State = | { status: "ready"; address: string } | { status: "error"; message: string }; -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - export default function ReceiveModal() { const [open, setOpen] = useState(false); const [state, setState] = useState({ status: "idle" }); diff --git a/app/src/components/StakingControl.tsx b/app/src/components/StakingControl.tsx index d5da7b4..7d57796 100644 --- a/app/src/components/StakingControl.tsx +++ b/app/src/components/StakingControl.tsx @@ -1,22 +1,12 @@ "use client"; import { useState } from "react"; +import { rpc } from '@/lib/client-rpc'; interface Props { initialStatus: "Staking" | "NotStaking"; } -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? "RPC error"); - return data.result as T; -} - export default function StakingControl({ initialStatus }: Props) { const [status, setStatus] = useState(initialStatus); const [loading, setLoading] = useState(false); diff --git a/app/src/components/TokenManagePanel.tsx b/app/src/components/TokenManagePanel.tsx index 9169573..d29243a 100644 --- a/app/src/components/TokenManagePanel.tsx +++ b/app/src/components/TokenManagePanel.tsx @@ -4,6 +4,9 @@ import { watchTx } from '@/lib/txWatcher'; import { CopyButton } from '@/components/CopyButton'; import { TokenIdTooltip } from '@/components/TokenIdTooltip'; import SafeExternalLink from '@/components/SafeExternalLink'; +import { rpc } from '@/lib/client-rpc'; +import { toHexField } from '@/lib/token-utils'; +import { ModalOverlay } from '@/components/ui/ModalOverlay'; // ── Types ────────────────────────────────────────────────────────────────────── @@ -30,23 +33,6 @@ interface Props { // ── Helpers ──────────────────────────────────────────────────────────────────── -async function rpc(method: string, params: Record = {}): Promise { - const res = await fetch('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); - return data.result as T; -} - -function toHexField(str: string): { hex: string } { - const bytes = new TextEncoder().encode(str); - const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - return { hex }; -} - function atomsToDecimal(atoms: string, decimals: number): string { if (decimals === 0) return atoms; const n = BigInt(atoms); @@ -420,11 +406,7 @@ export default function TokenManagePanel({ tokenId, onClose, onRefresh }: Props) const decimals = info?.number_of_decimals ?? 0; return ( -
e.target === e.currentTarget && onClose()} - > -
+ {/* Header */}
@@ -534,8 +516,7 @@ export default function TokenManagePanel({ tokenId, onClose, onRefresh }: Props) Close
-
-
+ ); } diff --git a/app/src/components/TokenSearch.tsx b/app/src/components/TokenSearch.tsx index 3e45626..f7543a7 100644 --- a/app/src/components/TokenSearch.tsx +++ b/app/src/components/TokenSearch.tsx @@ -3,6 +3,7 @@ import type { TokenInfo } from '@/lib/wallet-rpc'; import { hexToText } from '@/lib/token-utils'; import { CopyButton } from '@/components/CopyButton'; import { TokenIdTooltip } from '@/components/TokenIdTooltip'; +import { rpc } from '@/lib/client-rpc'; interface SearchResult { tokenId: string; @@ -18,17 +19,6 @@ interface Props { network: string; } -async function rpc(method: string, params: Record): Promise { - const res = await fetch('/api/rpc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, params }), - }); - const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; - if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); - return data.result as T; -} - function looksLikeTokenId(q: string): boolean { return q.length > 20 && !/\s/.test(q); } diff --git a/app/src/components/ui/ModalOverlay.tsx b/app/src/components/ui/ModalOverlay.tsx new file mode 100644 index 0000000..6af6bd8 --- /dev/null +++ b/app/src/components/ui/ModalOverlay.tsx @@ -0,0 +1,24 @@ +"use client" + +import type { ReactNode } from 'react'; + +interface ModalOverlayProps { + onClose: () => void; + maxWidth?: string; + maxHeight?: string; + children: ReactNode; +} + +/** Fixed full-screen backdrop with a centered modal card; closes on backdrop click. */ +export function ModalOverlay({ onClose, maxWidth = 'max-w-lg', maxHeight = 'max-h-[90vh]', children }: ModalOverlayProps) { + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {children} +
+
+ ); +} diff --git a/app/src/lib/api-utils.ts b/app/src/lib/api-utils.ts new file mode 100644 index 0000000..0db18e0 --- /dev/null +++ b/app/src/lib/api-utils.ts @@ -0,0 +1,20 @@ +/** + * Shared helpers for API routes (`src/pages/api/**`). + */ + +/** Serialize a JSON body into a `Response` with the JSON content type. */ +export function json(body: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +/** Parse the request body as multipart form data; returns null when parsing fails. */ +export async function readFormData(request: Request): Promise { + try { + return await request.formData(); + } catch { + return null; + } +} diff --git a/app/src/lib/client-rpc.ts b/app/src/lib/client-rpc.ts new file mode 100644 index 0000000..fc3baab --- /dev/null +++ b/app/src/lib/client-rpc.ts @@ -0,0 +1,14 @@ +/** + * Shared browser-side helper for calling the `/api/rpc` proxy. + * Throws with the RPC error message when the call fails. + */ +export async function rpc(method: string, params: Record = {}): Promise { + const res = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, params }), + }); + const data = await res.json() as { ok: boolean; result?: T; error?: { message: string } }; + if (!data.ok) throw new Error(data.error?.message ?? 'RPC error'); + return data.result as T; +} diff --git a/app/src/lib/passkey.ts b/app/src/lib/passkey.ts index b9cd4e3..12d1452 100644 --- a/app/src/lib/passkey.ts +++ b/app/src/lib/passkey.ts @@ -102,3 +102,14 @@ export function makeChallengeCookieHeader(token: string): string { export function clearChallengeCookieHeader(): string { return `${PASSKEY_CHALLENGE_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=0`; } + +/** + * Extract the challenge token from the request's challenge cookie and consume it. + * Returns the pending challenge, or null when the cookie is missing/expired. + */ +export function consumeChallengeFromRequest(request: Request): string | null { + const cookieHeader = request.headers.get('cookie') ?? ''; + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${PASSKEY_CHALLENGE_COOKIE}=([^;]+)`)); + const token = match?.[1] ?? ''; + return token ? consumeChallenge(token) : null; +} diff --git a/app/src/lib/token-utils.ts b/app/src/lib/token-utils.ts index 5d64eee..171bd30 100644 --- a/app/src/lib/token-utils.ts +++ b/app/src/lib/token-utils.ts @@ -2,6 +2,13 @@ * Pure token utility helpers - safe to import in both server and browser code. */ +/** Encode a UTF-8 string as a lowercase hex string for the Mintlayer RPC `{ hex }` format. */ +export function toHexField(str: string): { hex: string } { + const bytes = new TextEncoder().encode(str); + const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + return { hex }; +} + /** Decode a `{ text, hex }` RPC field - text may be null even when data is present. */ export function hexToText(field: { text: string | null; hex: string } | null | undefined): string | null { if (!field) return null; diff --git a/app/src/pages/api/address-tokens.ts b/app/src/pages/api/address-tokens.ts index 7363f24..39a2f51 100644 --- a/app/src/pages/api/address-tokens.ts +++ b/app/src/pages/api/address-tokens.ts @@ -8,6 +8,7 @@ */ import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; const INDEXER_URL = process.env.INDEXER_URL ?? 'http://api-web-server:3000'; @@ -112,10 +113,3 @@ export const GET: APIRoute = async ({ url }) => { return json({ ok: false, error: String(err) }, 502); } }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/ipfs-upload.ts b/app/src/pages/api/ipfs-upload.ts index 67f0cf6..6d4ec5f 100644 --- a/app/src/pages/api/ipfs-upload.ts +++ b/app/src/pages/api/ipfs-upload.ts @@ -14,6 +14,7 @@ import type { APIRoute } from 'astro'; import { getStringPref } from '@/lib/prefs-db'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { // Read per-request so settings changes take effect without restart @@ -144,10 +145,3 @@ async function uploadToPinata(file: File, jwt: string): Promise { } // ── Shared ──────────────────────────────────────────────────────────────────── - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/passkey/auth-options.ts b/app/src/pages/api/passkey/auth-options.ts index 7208a62..5a461e4 100644 --- a/app/src/pages/api/passkey/auth-options.ts +++ b/app/src/pages/api/passkey/auth-options.ts @@ -7,15 +7,13 @@ import { isValidRpId, makeChallengeCookieHeader, } from '@/lib/passkey'; +import { json } from '@/lib/api-utils'; export const GET: APIRoute = async ({ request }) => { const rpId = getRpId(request.url); if (!isValidRpId(rpId)) { - return new Response(JSON.stringify({ error: 'Passkeys require a DNS hostname.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Passkeys require a DNS hostname.' }, 400); } const creds = getCredentials(); @@ -31,11 +29,7 @@ export const GET: APIRoute = async ({ request }) => { const token = createChallenge(options.challenge); - return new Response(JSON.stringify(options), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': makeChallengeCookieHeader(token), - }, + return json(options, 200, { + 'Set-Cookie': makeChallengeCookieHeader(token), }); }; diff --git a/app/src/pages/api/passkey/auth-verify.test.ts b/app/src/pages/api/passkey/auth-verify.test.ts index 702aaa0..73bc022 100644 --- a/app/src/pages/api/passkey/auth-verify.test.ts +++ b/app/src/pages/api/passkey/auth-verify.test.ts @@ -7,7 +7,7 @@ vi.mock('@simplewebauthn/server', () => ({ vi.mock('@/lib/passkey', () => ({ getCredentials: vi.fn(), saveCredentials: vi.fn(), - consumeChallenge: vi.fn(), + consumeChallengeFromRequest: vi.fn(), getRpId: vi.fn(), getOrigin: vi.fn(), isValidRpId: vi.fn(), @@ -26,7 +26,7 @@ vi.mock('@/lib/prefs-db', () => ({ import { POST } from '@/pages/api/passkey/auth-verify'; import { verifyAuthenticationResponse } from '@simplewebauthn/server'; -import { getCredentials, saveCredentials, consumeChallenge, getRpId, getOrigin, isValidRpId, clearChallengeCookieHeader } from '@/lib/passkey'; +import { getCredentials, saveCredentials, consumeChallengeFromRequest, getRpId, getOrigin, isValidRpId, clearChallengeCookieHeader } from '@/lib/passkey'; import { generateSessionToken, makeSessionCookieHeader } from '@/lib/auth'; import { getPref } from '@/lib/prefs-db'; @@ -46,7 +46,7 @@ beforeEach(() => { vi.mocked(getRpId).mockReturnValue('localhost'); vi.mocked(getOrigin).mockReturnValue('http://localhost:4321'); vi.mocked(isValidRpId).mockReturnValue(true); - vi.mocked(consumeChallenge).mockReturnValue('expected-challenge'); + vi.mocked(consumeChallengeFromRequest).mockReturnValue('expected-challenge'); vi.mocked(getCredentials).mockReturnValue([STORED_CRED]); vi.mocked(saveCredentials).mockReturnValue(undefined); vi.mocked(clearChallengeCookieHeader).mockReturnValue('pk_chal=; Max-Age=0'); @@ -84,7 +84,7 @@ describe('POST /api/passkey/auth-verify', () => { }); it('returns 400 when challenge cookie is missing', async () => { - vi.mocked(consumeChallenge).mockReturnValue(null); + vi.mocked(consumeChallengeFromRequest).mockReturnValue(null); const res = await POST(makeCtx({ id: 'cred1' }, '')); expect(res.status).toBe(400); const body = await res.json(); diff --git a/app/src/pages/api/passkey/auth-verify.ts b/app/src/pages/api/passkey/auth-verify.ts index 2ce6b15..59904c3 100644 --- a/app/src/pages/api/passkey/auth-verify.ts +++ b/app/src/pages/api/passkey/auth-verify.ts @@ -4,37 +4,29 @@ import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; import { getCredentials, saveCredentials, - consumeChallenge, + consumeChallengeFromRequest, getRpId, getOrigin, isValidRpId, - PASSKEY_CHALLENGE_COOKIE, clearChallengeCookieHeader, } from '@/lib/passkey'; import { generateSessionToken, makeSessionCookieHeader } from '@/lib/auth'; import { getPref } from '@/lib/prefs-db'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { const rpId = getRpId(request.url); const origin = getOrigin(request.url); if (!isValidRpId(rpId)) { - return new Response(JSON.stringify({ error: 'Passkeys require a DNS hostname.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Passkeys require a DNS hostname.' }, 400); } - // Extract challenge token from cookie - const cookieHeader = request.headers.get('cookie') ?? ''; - const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${PASSKEY_CHALLENGE_COOKIE}=([^;]+)`)); - const token = match?.[1] ?? ''; - const expectedChallenge = token ? consumeChallenge(token) : null; + const expectedChallenge = consumeChallengeFromRequest(request); if (!expectedChallenge) { - return new Response(JSON.stringify({ error: 'Challenge expired or missing. Please try again.' }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: 'Challenge expired or missing. Please try again.' }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } @@ -42,19 +34,15 @@ export const POST: APIRoute = async ({ request }) => { try { body = await request.json(); } catch { - return new Response(JSON.stringify({ error: 'Invalid request body.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Invalid request body.' }, 400); } const creds = getCredentials(); const storedCred = creds.find((c) => c.id === body.id); if (!storedCred) { - return new Response(JSON.stringify({ error: 'Passkey not registered.' }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: 'Passkey not registered.' }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } @@ -73,16 +61,14 @@ export const POST: APIRoute = async ({ request }) => { }, }); } catch (err) { - return new Response(JSON.stringify({ error: `Verification failed: ${(err as Error).message}` }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: `Verification failed: ${(err as Error).message}` }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } if (!verification.verified) { - return new Response(JSON.stringify({ error: 'Authentication not verified.' }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: 'Authentication not verified.' }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } diff --git a/app/src/pages/api/passkey/register-options.ts b/app/src/pages/api/passkey/register-options.ts index 66fea9c..5b69cb5 100644 --- a/app/src/pages/api/passkey/register-options.ts +++ b/app/src/pages/api/passkey/register-options.ts @@ -4,19 +4,16 @@ import { getCredentials, createChallenge, getRpId, - getOrigin, isValidRpId, makeChallengeCookieHeader, } from '@/lib/passkey'; +import { json } from '@/lib/api-utils'; export const GET: APIRoute = async ({ request }) => { const rpId = getRpId(request.url); if (!isValidRpId(rpId)) { - return new Response(JSON.stringify({ error: 'Passkeys require a DNS hostname, not an IP address.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Passkeys require a DNS hostname, not an IP address.' }, 400); } const existingCreds = getCredentials(); @@ -39,11 +36,7 @@ export const GET: APIRoute = async ({ request }) => { const token = createChallenge(options.challenge); - return new Response(JSON.stringify(options), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': makeChallengeCookieHeader(token), - }, + return json(options, 200, { + 'Set-Cookie': makeChallengeCookieHeader(token), }); }; diff --git a/app/src/pages/api/passkey/register-verify.test.ts b/app/src/pages/api/passkey/register-verify.test.ts index 5172eb1..1c47813 100644 --- a/app/src/pages/api/passkey/register-verify.test.ts +++ b/app/src/pages/api/passkey/register-verify.test.ts @@ -7,7 +7,7 @@ vi.mock('@simplewebauthn/server', () => ({ vi.mock('@/lib/passkey', () => ({ getCredentials: vi.fn(), saveCredentials: vi.fn(), - consumeChallenge: vi.fn(), + consumeChallengeFromRequest: vi.fn(), getRpId: vi.fn(), getOrigin: vi.fn(), isValidRpId: vi.fn(), @@ -17,7 +17,7 @@ vi.mock('@/lib/passkey', () => ({ import { POST } from '@/pages/api/passkey/register-verify'; import { verifyRegistrationResponse } from '@simplewebauthn/server'; -import { getCredentials, saveCredentials, consumeChallenge, getRpId, getOrigin, isValidRpId, clearChallengeCookieHeader } from '@/lib/passkey'; +import { getCredentials, saveCredentials, consumeChallengeFromRequest, getRpId, getOrigin, isValidRpId, clearChallengeCookieHeader } from '@/lib/passkey'; const MOCK_CRED = { id: 'newcred', publicKey: new Uint8Array([1, 2, 3]), counter: 0 }; @@ -36,7 +36,7 @@ beforeEach(() => { vi.mocked(getRpId).mockReturnValue('localhost'); vi.mocked(getOrigin).mockReturnValue('http://localhost:4321'); vi.mocked(isValidRpId).mockReturnValue(true); - vi.mocked(consumeChallenge).mockReturnValue('expected-challenge'); + vi.mocked(consumeChallengeFromRequest).mockReturnValue('expected-challenge'); vi.mocked(getCredentials).mockReturnValue([]); vi.mocked(saveCredentials).mockReturnValue(undefined); vi.mocked(clearChallengeCookieHeader).mockReturnValue('pk_chal=; Max-Age=0'); @@ -71,7 +71,7 @@ describe('POST /api/passkey/register-verify', () => { }); it('returns 400 when challenge cookie is missing', async () => { - vi.mocked(consumeChallenge).mockReturnValue(null); + vi.mocked(consumeChallengeFromRequest).mockReturnValue(null); const res = await POST(makeCtx({ id: 'x' }, '')); expect(res.status).toBe(400); const body = await res.json(); diff --git a/app/src/pages/api/passkey/register-verify.ts b/app/src/pages/api/passkey/register-verify.ts index 61cd8d8..cbc009b 100644 --- a/app/src/pages/api/passkey/register-verify.ts +++ b/app/src/pages/api/passkey/register-verify.ts @@ -4,35 +4,27 @@ import type { RegistrationResponseJSON } from '@simplewebauthn/server'; import { getCredentials, saveCredentials, - consumeChallenge, + consumeChallengeFromRequest, getRpId, getOrigin, isValidRpId, - PASSKEY_CHALLENGE_COOKIE, clearChallengeCookieHeader, } from '@/lib/passkey'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { const rpId = getRpId(request.url); const origin = getOrigin(request.url); if (!isValidRpId(rpId)) { - return new Response(JSON.stringify({ error: 'Passkeys require a DNS hostname.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Passkeys require a DNS hostname.' }, 400); } - // Extract challenge token from cookie - const cookieHeader = request.headers.get('cookie') ?? ''; - const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${PASSKEY_CHALLENGE_COOKIE}=([^;]+)`)); - const token = match?.[1] ?? ''; - const expectedChallenge = token ? consumeChallenge(token) : null; + const expectedChallenge = consumeChallengeFromRequest(request); if (!expectedChallenge) { - return new Response(JSON.stringify({ error: 'Challenge expired or missing. Please try again.' }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: 'Challenge expired or missing. Please try again.' }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } @@ -40,10 +32,7 @@ export const POST: APIRoute = async ({ request }) => { try { body = await request.json(); } catch { - return new Response(JSON.stringify({ error: 'Invalid request body.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return json({ error: 'Invalid request body.' }, 400); } const credentialName = (body.name ?? 'Passkey').slice(0, 64).trim() || 'Passkey'; @@ -57,16 +46,14 @@ export const POST: APIRoute = async ({ request }) => { expectedRPID: rpId, }); } catch (err) { - return new Response(JSON.stringify({ error: `Verification failed: ${(err as Error).message}` }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: `Verification failed: ${(err as Error).message}` }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } if (!verification.verified || !verification.registrationInfo) { - return new Response(JSON.stringify({ error: 'Registration not verified.' }), { - status: 400, - headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearChallengeCookieHeader() }, + return json({ error: 'Registration not verified.' }, 400, { + 'Set-Cookie': clearChallengeCookieHeader(), }); } @@ -82,11 +69,7 @@ export const POST: APIRoute = async ({ request }) => { }); saveCredentials(creds); - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': clearChallengeCookieHeader(), - }, + return json({ ok: true }, 200, { + 'Set-Cookie': clearChallengeCookieHeader(), }); }; diff --git a/app/src/pages/api/plugins/[id]/toggle.ts b/app/src/pages/api/plugins/[id]/toggle.ts index e8d2195..551dfb7 100644 --- a/app/src/pages/api/plugins/[id]/toggle.ts +++ b/app/src/pages/api/plugins/[id]/toggle.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { togglePlugin } from '@/lib/plugins'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async ({ params, request }) => { const id = params.id ?? ''; @@ -22,10 +23,3 @@ export const POST: APIRoute = async ({ params, request }) => { return json({ ok: false, error: (err as Error).message }, 422); } }; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/plugins/[id]/uninstall.ts b/app/src/pages/api/plugins/[id]/uninstall.ts index 0420aa8..f774e0c 100644 --- a/app/src/pages/api/plugins/[id]/uninstall.ts +++ b/app/src/pages/api/plugins/[id]/uninstall.ts @@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'; import { uninstallPlugin } from '@/lib/plugins'; import { verifyTOTP } from '@/lib/auth'; import { getStringPref } from '@/lib/prefs-db'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async ({ params, request }) => { const id = params.id ?? ''; @@ -37,10 +38,3 @@ export const POST: APIRoute = async ({ params, request }) => { return json({ ok: false, error: (err as Error).message }, 422); } }; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/plugins/install.ts b/app/src/pages/api/plugins/install.ts index f06402a..d1e279b 100644 --- a/app/src/pages/api/plugins/install.ts +++ b/app/src/pages/api/plugins/install.ts @@ -2,14 +2,11 @@ import type { APIRoute } from 'astro'; import { installPlugin } from '@/lib/plugins'; import { verifyTOTP } from '@/lib/auth'; import { getStringPref } from '@/lib/prefs-db'; +import { json, readFormData } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { - let formData: FormData; - try { - formData = await request.formData(); - } catch { - return json({ ok: false, error: 'Invalid multipart form data' }, 400); - } + const formData = await readFormData(request); + if (!formData) return json({ ok: false, error: 'Invalid multipart form data' }, 400); // Step-up auth: installing a plugin hands its code full server-side // access (FS/network/wallet-RPC). Require a fresh TOTP code, consistent with @@ -41,10 +38,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: false, error: (err as Error).message }, 422); } }; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/prefs.ts b/app/src/pages/api/prefs.ts index 8239198..ae6bbc4 100644 --- a/app/src/pages/api/prefs.ts +++ b/app/src/pages/api/prefs.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { getPref, setPref } from '@/lib/prefs-db'; +import { json } from '@/lib/api-utils'; const KEY = 'ml_favourite_tokens'; @@ -22,10 +23,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: false, error: String(err) }, 500); } }; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/scan-issued-tokens.ts b/app/src/pages/api/scan-issued-tokens.ts index 569224d..a89943d 100644 --- a/app/src/pages/api/scan-issued-tokens.ts +++ b/app/src/pages/api/scan-issued-tokens.ts @@ -17,6 +17,7 @@ import type { APIRoute } from 'astro'; import { createHash } from 'node:crypto'; import { rpcCall } from '@/lib/wallet-rpc'; import { hexToText } from '@/lib/token-utils'; +import { json } from '@/lib/api-utils'; // ── Network HRP ─────────────────────────────────────────────────────────────── @@ -208,10 +209,3 @@ export const GET: APIRoute = async () => { return json({ ok: true, fungible: [], nfts: [] }, 200); } }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/settings/ipfs.ts b/app/src/pages/api/settings/ipfs.ts index 3453889..3007957 100644 --- a/app/src/pages/api/settings/ipfs.ts +++ b/app/src/pages/api/settings/ipfs.ts @@ -1,15 +1,12 @@ import type { APIRoute } from 'astro'; import { setPref } from '@/lib/prefs-db'; +import { json, readFormData } from '@/lib/api-utils'; const VALID_PROVIDERS = new Set(['filebase', 'pinata', '']); export const POST: APIRoute = async ({ request }) => { - let form: FormData; - try { - form = await request.formData(); - } catch { - return json({ ok: false, error: 'Invalid request body' }, 400); - } + const form = await readFormData(request); + if (!form) return json({ ok: false, error: 'Invalid request body' }, 400); const provider = (form.get('provider') as string | null) ?? ''; const filebaseToken = (form.get('filebase_token') as string | null) ?? ''; @@ -25,10 +22,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: true }, 200); }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/settings/password.ts b/app/src/pages/api/settings/password.ts index 71d2dcd..a944f9d 100644 --- a/app/src/pages/api/settings/password.ts +++ b/app/src/pages/api/settings/password.ts @@ -1,13 +1,10 @@ import type { APIRoute } from 'astro'; import { resolvePasswordChange, applyPasswordChange } from '@/lib/password-change'; +import { json, readFormData } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { - let form: FormData; - try { - form = await request.formData(); - } catch { - return json({ ok: false, error: 'Invalid request body' }, 400); - } + const form = await readFormData(request); + if (!form) return json({ ok: false, error: 'Invalid request body' }, 400); // form.get() returns string | File | null; a File part would bypass the length // check and crash hashPassword. Coerce non-strings to '' so they fail cleanly. @@ -25,10 +22,3 @@ export const POST: APIRoute = async ({ request }) => { applyPasswordChange(decision.newHash); return json({ ok: true }, 200); }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/settings/reset-2fa.ts b/app/src/pages/api/settings/reset-2fa.ts index c45e240..0e60aee 100644 --- a/app/src/pages/api/settings/reset-2fa.ts +++ b/app/src/pages/api/settings/reset-2fa.ts @@ -1,14 +1,11 @@ import type { APIRoute } from 'astro'; import { verifyTOTP, generateTotpSecret } from '@/lib/auth'; import { getStringPref, setPref } from '@/lib/prefs-db'; +import { json, readFormData } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { - let form: FormData; - try { - form = await request.formData(); - } catch { - return json({ ok: false, error: 'Invalid request body' }, 400); - } + const form = await readFormData(request); + if (!form) return json({ ok: false, error: 'Invalid request body' }, 400); const totpCode = (form.get('totp_code') as string | null) ?? ''; @@ -30,10 +27,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: true, secret: newSecret, uri }, 200); }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/settings/telegram.ts b/app/src/pages/api/settings/telegram.ts index 2d99c7a..d4248bb 100644 --- a/app/src/pages/api/settings/telegram.ts +++ b/app/src/pages/api/settings/telegram.ts @@ -1,14 +1,11 @@ import type { APIRoute } from 'astro'; import { setPref } from '@/lib/prefs-db'; import { sendTelegramMessage } from '@/lib/telegram'; +import { json, readFormData } from '@/lib/api-utils'; export const POST: APIRoute = async ({ request }) => { - let form: FormData; - try { - form = await request.formData(); - } catch { - return json({ ok: false, error: 'Invalid request body' }, 400); - } + const form = await readFormData(request); + if (!form) return json({ ok: false, error: 'Invalid request body' }, 400); const botToken = (form.get('bot_token') as string | null) ?? ''; const chatId = (form.get('chat_id') as string | null) ?? ''; @@ -31,10 +28,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: true }, 200); }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/token-authority.ts b/app/src/pages/api/token-authority.ts index a67f708..e358db4 100644 --- a/app/src/pages/api/token-authority.ts +++ b/app/src/pages/api/token-authority.ts @@ -11,6 +11,7 @@ */ import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; const INDEXER_URL = process.env.INDEXER_URL ?? 'http://api-web-server:3000'; @@ -53,10 +54,3 @@ export const POST: APIRoute = async ({ request }) => { return json({ ok: false, error: String(err) }, 502); } }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/token-search.ts b/app/src/pages/api/token-search.ts index d4b927d..15c4cb2 100644 --- a/app/src/pages/api/token-search.ts +++ b/app/src/pages/api/token-search.ts @@ -4,6 +4,7 @@ */ import type { APIRoute } from 'astro'; +import { json } from '@/lib/api-utils'; const INDEXER_URL = process.env.INDEXER_URL ?? 'http://api-web-server:3000'; @@ -27,10 +28,3 @@ export const GET: APIRoute = async ({ url }) => { return json({ ok: false, error: String(err) }, 502); } }; - -function json(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/app/src/pages/api/wallet-open.ts b/app/src/pages/api/wallet-open.ts index 502aad4..e619dd0 100644 --- a/app/src/pages/api/wallet-open.ts +++ b/app/src/pages/api/wallet-open.ts @@ -11,6 +11,7 @@ import type { APIRoute } from 'astro'; import { ensureWalletOpen, isWalletNotOpenError, walletInfo } from '@/lib/wallet-rpc'; +import { json } from '@/lib/api-utils'; export const POST: APIRoute = async () => { // Check if already open - if so, nothing to do @@ -40,9 +41,3 @@ export const POST: APIRoute = async () => { } return json({ ok: false, status: 'error', message: result.message }); }; - -function json(body: unknown) { - return new Response(JSON.stringify(body), { - headers: { 'Content-Type': 'application/json' }, - }); -}