diff --git a/apps/sdp-web/messages/en/dashboard-custody.json b/apps/sdp-web/messages/en/dashboard-custody.json index 5f81246f7..e0e5c9362 100644 --- a/apps/sdp-web/messages/en/dashboard-custody.json +++ b/apps/sdp-web/messages/en/dashboard-custody.json @@ -221,8 +221,6 @@ "rpcProviderSaveMismatch": "RPC provider save mismatch (requested {requested}, persisted {persisted}).", "rpcSettingsSaved": "RPC settings saved.", "failedToTestRpcProvider": "Failed to test RPC provider.", - "rpcTestFailed": "RPC test failed.", - "rpcTestFailedStatus": "RPC test failed ({status}).", "rpcTestMismatch": "RPC test mismatch (requested {requested}, resolved {resolved}).", "rpcUpstreamReturned": "RPC upstream returned {status} {statusText}.", "rpcTestPassed": "RPC test passed ({status} {statusText}) in {latency}ms.", diff --git a/apps/sdp-web/messages/en/dashboard-issuance.json b/apps/sdp-web/messages/en/dashboard-issuance.json index a5ee9a639..6a9f28b05 100644 --- a/apps/sdp-web/messages/en/dashboard-issuance.json +++ b/apps/sdp-web/messages/en/dashboard-issuance.json @@ -510,13 +510,8 @@ "noPauseAuthorityConfigured": "No pause authority is configured. Set a pausable authority or mint authority first.", "freezingDisabled": "Freezing is disabled for this token.", "noFreezeAuthorityConfigured": "No freeze authority is configured.", - "unknownError": "Unknown error", - "unknown": "unknown", - "ok": "ok", - "executionRouteFailed": "Execution route failed ({status})", "actionFailed": "{action} failed ({status}): {error}", "actionSucceeded": "{action} succeeded ({status})", - "requestFailed": "Request failed", "mintAuthorityHelper": "Can mint new tokens.", "freezeAuthorityHelper": "Can freeze and unfreeze token accounts.", "metadataAuthorityHelper": "Can update token metadata.", diff --git a/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts b/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts index 1b93251e8..af00c5888 100644 --- a/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts +++ b/apps/sdp-web/playwright/tests/issuance.e2e.spec.ts @@ -147,24 +147,16 @@ async function waitForActionResponse( trigger: () => Promise ): Promise { const responsePromise = page.waitForResponse( - (response) => { - const request = response.request(); - const postData = request.postData() ?? ""; - return ( - response.url().endsWith("/api/playground/execute") && - request.method() === "POST" && - postData.includes(`"method":"${options.method}"`) && - postData.includes(options.pathIncludes) - ); - }, + (response) => + response.request().method() === options.method && + new URL(response.url()).pathname.includes(options.pathIncludes), { timeout: 180_000 } ); await trigger(); const response = await responsePromise; - expect(response.ok()).toBe(true); - const payload = (await response.json().catch(() => null)) as { ok?: boolean } | null; - expect(payload?.ok, JSON.stringify(payload)).toBe(true); + const body = await response.text().catch(() => ""); + expect(response.ok(), body).toBe(true); } async function confirmAction(page: Page, confirmButtonLabel: string): Promise { @@ -468,7 +460,7 @@ test.describe page, { method: "POST", - pathIncludes: `/v1/issuance/tokens/${fixtures.tokens.allowlisted.id}/allowlist`, + pathIncludes: `/api/dashboard/issuance/tokens/${fixtures.tokens.allowlisted.id}/allowlist`, }, async () => { await page.getByRole("button", { name: "Add allowlist entry" }).click(); @@ -487,7 +479,7 @@ test.describe page, { method: "DELETE", - pathIncludes: `/v1/issuance/tokens/${fixtures.tokens.allowlisted.id}/allowlist/`, + pathIncludes: `/api/dashboard/issuance/tokens/${fixtures.tokens.allowlisted.id}/allowlist/`, }, async () => { await allowlistEntry.getByRole("button", { name: "Remove entry" }).click(); @@ -515,7 +507,7 @@ test.describe page, { method: "POST", - pathIncludes: `/v1/issuance/tokens/${authorityTokenId}/authority`, + pathIncludes: `/api/dashboard/issuance/tokens/${authorityTokenId}/authority`, }, async () => { await page.getByRole("button", { name: "Save authority" }).click(); @@ -540,7 +532,7 @@ test.describe page, { method: "POST", - pathIncludes: `/v1/issuance/tokens/${authorityTokenId}/authority`, + pathIncludes: `/api/dashboard/issuance/tokens/${authorityTokenId}/authority`, }, async () => { await page.getByRole("button", { name: "Yes, set to None" }).click(); diff --git a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.ts new file mode 100644 index 000000000..1bfdb3ef1 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { proxyToSdpApi } from "@/lib/sdp-api"; + +const TOKEN_POST_ACTIONS = { + deploy: "deploy", + mint: "mint", + burn: "burn", + seize: "seize", + "force-burn": "force-burn", + authority: "authority", + freeze: "freeze", + unfreeze: "unfreeze", + pause: "pause", + unpause: "unpause", + "refresh-supply": "supply/refresh", +} as const satisfies Record; + +type TokenPostAction = keyof typeof TOKEN_POST_ACTIONS; + +type RouteContext = { + params: Promise<{ tokenId: string; action: string }>; +}; + +/** + * Narrows a route param to a known token POST action. + * @param action - The raw `[action]` route segment. + * @returns Whether `action` is a key of `TOKEN_POST_ACTIONS`. + */ +function isTokenPostAction(action: string): action is TokenPostAction { + return Object.hasOwn(TOKEN_POST_ACTIONS, action); +} + +export async function POST(request: Request, context: RouteContext) { + const { tokenId, action } = await context.params; + if (!isTokenPostAction(action)) { + return NextResponse.json( + { error: { message: "Token action is not supported" } }, + { status: 404 } + ); + } + + return proxyToSdpApi({ + request, + traceSource: `route.dashboard.issuance.token.${action}`, + path: `/v1/issuance/tokens/${encodeURIComponent(tokenId)}/${TOKEN_POST_ACTIONS[action]}`, + }); +} diff --git a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts new file mode 100644 index 000000000..249164cfa --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + proxyToSdpApi: vi.fn(), +})); + +vi.mock("@/lib/sdp-api", () => ({ + proxyToSdpApi: mocks.proxyToSdpApi, +})); + +import { POST } from "./route"; + +function tokenActionRequest(): Request { + return new Request("https://dashboard.example.com/api/dashboard/issuance/tokens/tok_1/mint", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mint: { destination: "dest", amount: "1" } }), + }); +} + +describe("POST /api/dashboard/issuance/tokens/[tokenId]/[action]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + "not-a-real-action", + "__proto__", + "constructor", + "toString", + ])("returns 404 for unsupported action %s without proxying", async (action) => { + const response = await POST(tokenActionRequest(), { + params: Promise.resolve({ tokenId: "tok_1", action }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { message: "Token action is not supported" }, + }); + expect(mocks.proxyToSdpApi).not.toHaveBeenCalled(); + }); + + it.each([ + ["deploy", "deploy"], + ["mint", "mint"], + ["burn", "burn"], + ["seize", "seize"], + ["force-burn", "force-burn"], + ["authority", "authority"], + ["freeze", "freeze"], + ["unfreeze", "unfreeze"], + ["pause", "pause"], + ["unpause", "unpause"], + ["refresh-supply", "supply/refresh"], + ])("proxies action %s to /v1/issuance/tokens/:tokenId/%s", async (action, sdpApiSegment) => { + mocks.proxyToSdpApi.mockResolvedValue(new Response(null, { status: 200 })); + const request = tokenActionRequest(); + + await POST(request, { params: Promise.resolve({ tokenId: "tok_1", action }) }); + + expect(mocks.proxyToSdpApi).toHaveBeenCalledWith({ + request, + traceSource: `route.dashboard.issuance.token.${action}`, + path: `/v1/issuance/tokens/tok_1/${sdpApiSegment}`, + }); + }); +}); diff --git a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/[entryId]/route.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/[entryId]/route.ts new file mode 100644 index 000000000..11cf1f677 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/[entryId]/route.ts @@ -0,0 +1,15 @@ +import { proxyToSdpApi } from "@/lib/sdp-api"; + +type RouteContext = { + params: Promise<{ tokenId: string; entryId: string }>; +}; + +export async function DELETE(request: Request, context: RouteContext) { + const { tokenId, entryId } = await context.params; + + return proxyToSdpApi({ + request, + traceSource: "route.dashboard.issuance.token.allowlist.remove", + path: `/v1/issuance/tokens/${encodeURIComponent(tokenId)}/allowlist/${encodeURIComponent(entryId)}`, + }); +} diff --git a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/route.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/route.ts index 8bb9faad0..a835fd490 100644 --- a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/route.ts +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/route.ts @@ -1,7 +1,7 @@ import type { TokenAllowlistEntry } from "@sdp/types"; import { NextResponse } from "next/server"; import { createTimedTrace } from "@/lib/request-tracing"; -import { createSdpApiClient } from "@/lib/sdp-api"; +import { createSdpApiClient, proxyToSdpApi } from "@/lib/sdp-api"; function parseErrorMessage(body: string): string { try { @@ -95,3 +95,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ toke ); } } + +export async function POST(request: Request, { params }: { params: Promise<{ tokenId: string }> }) { + const { tokenId } = await params; + + return proxyToSdpApi({ + request, + traceSource: "route.dashboard.issuance.token.allowlist.add", + path: `/v1/issuance/tokens/${encodeURIComponent(tokenId)}/allowlist`, + }); +} diff --git a/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/route.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/route.ts new file mode 100644 index 000000000..fcd0fb857 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/route.ts @@ -0,0 +1,15 @@ +import { proxyToSdpApi } from "@/lib/sdp-api"; + +type RouteContext = { + params: Promise<{ tokenId: string }>; +}; + +export async function PATCH(request: Request, context: RouteContext) { + const { tokenId } = await context.params; + + return proxyToSdpApi({ + request, + traceSource: "route.dashboard.issuance.token.update", + path: `/v1/issuance/tokens/${encodeURIComponent(tokenId)}`, + }); +} diff --git a/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts b/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts new file mode 100644 index 000000000..e5be5f52f --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts @@ -0,0 +1,9 @@ +import { proxyToSdpApi } from "@/lib/sdp-api"; + +export async function POST(request: Request) { + return proxyToSdpApi({ + request, + traceSource: "route.dashboard.settings.rpc-test", + path: "/v1/rpc/test", + }); +} diff --git a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/asset-profile/use-token-operations.ts b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/asset-profile/use-token-operations.ts index 1d3408328..efe4ba497 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/asset-profile/use-token-operations.ts +++ b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/asset-profile/use-token-operations.ts @@ -307,7 +307,7 @@ export function useTokenOperations({ const frozenAccountsTotal = resolvedSupportingData.frozenAccountsTotal; const frozenAccountsHasMore = resolvedSupportingData.frozenAccountsHasMore; - const tokenBasePath = `/v1/issuance/tokens/${token.id}`; + const tokenBasePath = `/api/dashboard/issuance/tokens/${token.id}`; const explorerHref = getExplorerHref(token.mintAddress); const canDeployToken = token.status === "pending" && !token.mintAddress; const { @@ -548,7 +548,7 @@ export function useTokenOperations({ runAction({ label: t("DashboardIssuance.management.refreshSupply"), method: "POST", - path: `${tokenBasePath}/supply/refresh`, + path: `${tokenBasePath}/refresh-supply`, body: {}, }); }; diff --git a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.tsx b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.tsx index 2c9cffd4d..5ea1005d2 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.tsx @@ -412,7 +412,7 @@ export function TokenManagementWorkspace({ const frozenAccountsTotal = resolvedSupportingData.frozenAccountsTotal; const frozenAccountsHasMore = resolvedSupportingData.frozenAccountsHasMore; - const tokenBasePath = `/v1/issuance/tokens/${token.id}`; + const tokenBasePath = `/api/dashboard/issuance/tokens/${token.id}`; const explorerHref = getExplorerHref(token.mintAddress); const canDeployToken = token.status === "pending" && !token.mintAddress; const { @@ -754,7 +754,7 @@ export function TokenManagementWorkspace({ runAction({ label: t("DashboardIssuance.management.refreshSupply"), method: "POST", - path: `${tokenBasePath}/supply/refresh`, + path: `${tokenBasePath}/refresh-supply`, body: {}, }); }; diff --git a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.types.ts b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.types.ts index 7f7bf8187..80ea3f805 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.types.ts +++ b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.types.ts @@ -34,13 +34,6 @@ export interface ActionExecutionInput { body?: unknown; } -export interface ExecuteRouteResponse { - ok?: boolean; - status?: number; - body?: unknown; - error?: string; -} - export interface ActionExecutionResult { ok: boolean; message: string; diff --git a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.utils.ts b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.utils.ts index 065c512d7..580e4c6e1 100644 --- a/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.utils.ts +++ b/apps/sdp-web/src/app/dashboard/issuance/[tokenId]/token-management-workspace.utils.ts @@ -1,6 +1,7 @@ import type { PaymentsDashboardWallet, Token, TokenAllowlistEntry } from "@sdp/types"; import type { AppLocale } from "@/i18n/config"; import type { MessageKey, TranslationValues } from "@/i18n/messages"; +import { dashboardFetch } from "@/lib/dashboard-fetch"; import { formatDisplayLabel } from "@/lib/utils"; import { type AccessControlMode, getTokenAccessControlMode } from "../access-control.utils"; import type { @@ -11,7 +12,6 @@ import type { AuthorityFormState, BurnFormState, BurnValidationErrors, - ExecuteRouteResponse, ExtensionRow, ForceBurnFormState, ForceBurnValidationErrors, @@ -533,26 +533,6 @@ export function formatValue(value: string | null | undefined, t: Translate): str return `${value.slice(0, 6)}...${value.slice(-6)}`; } -export function extractApiError(body: unknown, t: Translate): string { - if (typeof body === "string") { - return body; - } - - if (body && typeof body === "object") { - const maybeError = (body as { error?: { message?: string } }).error; - if (maybeError?.message) { - return maybeError.message; - } - - const maybeMessage = (body as { message?: string }).message; - if (typeof maybeMessage === "string" && maybeMessage) { - return maybeMessage; - } - } - - return t("DashboardIssuance.management.unknownError"); -} - export function getExplorerHref(mintAddress: string | null): string | null { if (!mintAddress) { return null; @@ -570,64 +550,36 @@ export async function executeActionRequest( input: ActionExecutionInput, t: Translate ): Promise { - try { - const response = await fetch("/api/playground/execute", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - method: input.method, - path: input.path, - body: input.body, - }), - }); - - const payload = (await response.json()) as ExecuteRouteResponse; - - if (!response.ok) { - return { - ok: false, - message: - payload.error ?? - t("DashboardIssuance.management.executionRouteFailed", { status: response.status }), - status: response.status, - body: payload, - }; - } - - if (!payload.ok) { - const status = payload.status ?? null; - return { - ok: false, - message: t("DashboardIssuance.management.actionFailed", { - action: input.label, - status: status ?? t("DashboardIssuance.management.unknown"), - error: extractApiError(payload.body, t), - }), - status, - body: payload.body, - }; - } + const result = await dashboardFetch(input.path, { + method: input.method, + body: input.body, + }); - return { - ok: true, - message: t("DashboardIssuance.management.actionSucceeded", { - action: input.label, - status: payload.status ?? t("DashboardIssuance.management.ok"), - }), - status: payload.status ?? null, - body: payload.body ?? null, - }; - } catch (error) { + if (!result.ok) { return { ok: false, message: - error instanceof Error ? error.message : t("DashboardIssuance.management.requestFailed"), - status: null, - body: null, + result.status === null + ? result.error + : t("DashboardIssuance.management.actionFailed", { + action: input.label, + status: result.status, + error: result.error, + }), + status: result.status, + body: result.body, }; } + + return { + ok: true, + message: t("DashboardIssuance.management.actionSucceeded", { + action: input.label, + status: result.status, + }), + status: result.status, + body: result.data, + }; } export function getPermissionRows( diff --git a/apps/sdp-web/src/app/dashboard/settings/organization-rpc-settings-form.tsx b/apps/sdp-web/src/app/dashboard/settings/organization-rpc-settings-form.tsx index 5e92e43c8..6764e2aec 100644 --- a/apps/sdp-web/src/app/dashboard/settings/organization-rpc-settings-form.tsx +++ b/apps/sdp-web/src/app/dashboard/settings/organization-rpc-settings-form.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Select, SelectItem } from "@/components/ui/select"; import { useTranslations } from "@/i18n/provider"; +import { dashboardFetch } from "@/lib/dashboard-fetch"; import { updateOrganizationRpcSettingsAction } from "./actions"; type OrganizationSettings = { @@ -59,51 +60,25 @@ async function runRpcProviderTest( const startedAt = Date.now(); try { - const executeResponse = await fetch("/api/playground/execute", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await dashboardFetch<{ data: RpcProxyResponse }>( + "/api/dashboard/settings/rpc-test", + { method: "POST", - path: "/v1/rpc/test", body: { jsonrpc: "2.0", id: "org-rpc-test", method: "getVersion", params: [], }, - apiKey: null, - }), - }); + } + ); const latencyMs = Date.now() - startedAt; - const envelope = (await executeResponse.json()) as { - ok?: boolean; - status?: number; - statusText?: string; - body?: { - data?: RpcProxyResponse; - error?: { message?: string }; - }; - error?: string; - }; - - if (!executeResponse.ok || envelope.status === undefined || envelope.statusText === undefined) { - return { - status: "error", - message: envelope.error ?? t("DashboardCustody.rpcTestFailed"), - requestedProvider, - latencyMs, - }; - } - if (!envelope.ok || !envelope.body?.data) { + if (!result.ok) { return { status: "error", - message: - envelope.body?.error?.message || - t("DashboardCustody.rpcTestFailedStatus", { status: envelope.status }), + message: result.error, requestedProvider, latencyMs, }; @@ -112,7 +87,7 @@ async function runRpcProviderTest( const { provider: { id: resolvedProvider, endpoint, selectionMode }, upstream, - } = envelope.body.data; + } = result.data.data; if (requestedProvider !== "default" && resolvedProvider !== requestedProvider) { return { diff --git a/apps/sdp-web/src/lib/dashboard-fetch.ts b/apps/sdp-web/src/lib/dashboard-fetch.ts index 5c4b695c4..352c42399 100644 --- a/apps/sdp-web/src/lib/dashboard-fetch.ts +++ b/apps/sdp-web/src/lib/dashboard-fetch.ts @@ -1,4 +1,6 @@ -export type DashboardFetchResult = { ok: true; data: T } | { ok: false; error: string }; +export type DashboardFetchResult = + | { ok: true; data: T; status: number } + | { ok: false; error: string; status: number | null; body: unknown }; interface DashboardFetchOptions { method?: "GET" | "POST" | "PATCH" | "DELETE"; @@ -21,23 +23,35 @@ export async function dashboardFetch( signal, }); } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Network error" }; + return { + ok: false, + error: err instanceof Error ? err.message : "Network error", + status: null, + body: null, + }; } let text: string; try { text = await response.text(); } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Network error" }; + return { + ok: false, + error: err instanceof Error ? err.message : "Network error", + status: response.status, + body: null, + }; } if (!response.ok) { let message = `Request failed (${response.status})`; + let errorBody: unknown = text; try { const json = JSON.parse(text) as { error?: string | { message?: string }; message?: string; }; + errorBody = json; const errObj = json?.error; message = (typeof errObj === "string" ? errObj : null) ?? @@ -47,13 +61,13 @@ export async function dashboardFetch( } catch { // keep status-based message } - return { ok: false, error: message }; + return { ok: false, error: message, status: response.status, body: errorBody }; } try { const data = (text ? JSON.parse(text) : null) as T; - return { ok: true, data }; + return { ok: true, data, status: response.status }; } catch { - return { ok: false, error: "Invalid response" }; + return { ok: false, error: "Invalid response", status: response.status, body: text }; } }