From 82266f4ed65c7e7f7c92708d4ba3cf9cb042e6c3 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Tue, 4 Aug 2026 00:37:40 +0800 Subject: [PATCH 1/2] feat(sdp-web): move token/RPC actions off the playground proxy Route mint/burn/seize/freeze/pause/authority/allowlist/RPC-test calls through dedicated dashboard API wrapper routes instead of the generic /api/playground/execute proxy, so that route's only remaining consumer is the API playground shell. Reuses the shared dashboardFetch helper (extended with status/body) instead of hand-rolled fetch parsing. --- .../messages/en/dashboard-custody.json | 2 - .../messages/en/dashboard-issuance.json | 5 - .../messages/fr/dashboard-custody.json | 2 - .../messages/fr/dashboard-issuance.json | 5 - .../tokens/[tokenId]/[action]/route.ts | 47 +++++++++ .../[tokenId]/[action]/route.unit.test.ts | 62 ++++++++++++ .../[tokenId]/allowlist/[entryId]/route.ts | 15 +++ .../tokens/[tokenId]/allowlist/route.ts | 12 ++- .../issuance/tokens/[tokenId]/route.ts | 15 +++ .../api/dashboard/settings/rpc-test/route.ts | 9 ++ .../asset-profile/use-token-operations.ts | 4 +- .../[tokenId]/token-management-workspace.tsx | 4 +- .../token-management-workspace.types.ts | 7 -- .../token-management-workspace.utils.ts | 98 +++++-------------- .../organization-rpc-settings-form.tsx | 43 ++------ apps/sdp-web/src/lib/dashboard-fetch.ts | 26 +++-- 16 files changed, 217 insertions(+), 139 deletions(-) create mode 100644 apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.ts create mode 100644 apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts create mode 100644 apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/allowlist/[entryId]/route.ts create mode 100644 apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/route.ts create mode 100644 apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts 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/messages/fr/dashboard-custody.json b/apps/sdp-web/messages/fr/dashboard-custody.json index 51ceeb6e5..565d989cf 100644 --- a/apps/sdp-web/messages/fr/dashboard-custody.json +++ b/apps/sdp-web/messages/fr/dashboard-custody.json @@ -213,8 +213,6 @@ "rpcProviderSaveMismatch": "Incohérence d’enregistrement du fournisseur RPC (demandé : {requested}, enregistré : {persisted}).", "rpcSettingsSaved": "Paramètres RPC enregistrés.", "failedToTestRpcProvider": "Échec du test du fournisseur RPC.", - "rpcTestFailed": "Échec du test RPC.", - "rpcTestFailedStatus": "Échec du test RPC ({status}).", "rpcTestMismatch": "Incohérence du test RPC (demandé : {requested}, résolu : {resolved}).", "rpcUpstreamReturned": "L’amont RPC a renvoyé {status} {statusText}.", "rpcTestPassed": "Test RPC réussi ({status} {statusText}) en {latency} ms.", diff --git a/apps/sdp-web/messages/fr/dashboard-issuance.json b/apps/sdp-web/messages/fr/dashboard-issuance.json index 3ca9c16ab..1381a67d2 100644 --- a/apps/sdp-web/messages/fr/dashboard-issuance.json +++ b/apps/sdp-web/messages/fr/dashboard-issuance.json @@ -498,13 +498,8 @@ "noPauseAuthorityConfigured": "Aucune autorité de pause n'est configurée. Définissez d'abord une autorité pausable ou Mint.", "freezingDisabled": "Freeze est désactivé pour ce Token.", "noFreezeAuthorityConfigured": "Aucune autorité Freeze n'est configurée.", - "unknownError": "Erreur inconnue", - "unknown": "inconnu", - "ok": "ok", - "executionRouteFailed": "Échec de la route d'exécution ({status})", "actionFailed": "Échec de {action} ({status}) : {error}", "actionSucceeded": "{action} réussi ({status})", - "requestFailed": "Échec de la demande", "mintAuthorityHelper": "Peut Mint de nouveaux Tokens.", "freezeAuthorityHelper": "Peut Freeze et Unfreeze les comptes Token.", "metadataAuthorityHelper": "Peut mettre à jour les métadonnées Token.", 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..4990ebe55 --- /dev/null +++ b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts @@ -0,0 +1,62 @@ +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("returns 404 for an unsupported action without proxying", async () => { + const response = await POST(tokenActionRequest(), { + params: Promise.resolve({ tokenId: "tok_1", action: "not-a-real-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 }; } } From 7954ba06d79c6dda2193438a9e0ff54958a111c8 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Tue, 4 Aug 2026 08:56:50 +0800 Subject: [PATCH 2/2] fix(sdp-web): point issuance E2E at the dashboard action routes Rewrite waitForActionResponse for the new contract: match the dashboard route URL + real HTTP method instead of the retired /api/playground/execute envelope, and assert on the passed-through upstream status rather than the playground's ok flag. Covers the allowlist shard (test 6) as well as the authority shard that surfaced the failure. Drop the fr catalog edits per the catalog change policy (translation automation owns localized files), and pin the Object.hasOwn action guard with __proto__/constructor/toString 404 cases. --- .../messages/fr/dashboard-custody.json | 2 ++ .../messages/fr/dashboard-issuance.json | 5 ++++ .../playwright/tests/issuance.e2e.spec.ts | 26 +++++++------------ .../[tokenId]/[action]/route.unit.test.ts | 9 +++++-- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/apps/sdp-web/messages/fr/dashboard-custody.json b/apps/sdp-web/messages/fr/dashboard-custody.json index 565d989cf..51ceeb6e5 100644 --- a/apps/sdp-web/messages/fr/dashboard-custody.json +++ b/apps/sdp-web/messages/fr/dashboard-custody.json @@ -213,6 +213,8 @@ "rpcProviderSaveMismatch": "Incohérence d’enregistrement du fournisseur RPC (demandé : {requested}, enregistré : {persisted}).", "rpcSettingsSaved": "Paramètres RPC enregistrés.", "failedToTestRpcProvider": "Échec du test du fournisseur RPC.", + "rpcTestFailed": "Échec du test RPC.", + "rpcTestFailedStatus": "Échec du test RPC ({status}).", "rpcTestMismatch": "Incohérence du test RPC (demandé : {requested}, résolu : {resolved}).", "rpcUpstreamReturned": "L’amont RPC a renvoyé {status} {statusText}.", "rpcTestPassed": "Test RPC réussi ({status} {statusText}) en {latency} ms.", diff --git a/apps/sdp-web/messages/fr/dashboard-issuance.json b/apps/sdp-web/messages/fr/dashboard-issuance.json index 1381a67d2..3ca9c16ab 100644 --- a/apps/sdp-web/messages/fr/dashboard-issuance.json +++ b/apps/sdp-web/messages/fr/dashboard-issuance.json @@ -498,8 +498,13 @@ "noPauseAuthorityConfigured": "Aucune autorité de pause n'est configurée. Définissez d'abord une autorité pausable ou Mint.", "freezingDisabled": "Freeze est désactivé pour ce Token.", "noFreezeAuthorityConfigured": "Aucune autorité Freeze n'est configurée.", + "unknownError": "Erreur inconnue", + "unknown": "inconnu", + "ok": "ok", + "executionRouteFailed": "Échec de la route d'exécution ({status})", "actionFailed": "Échec de {action} ({status}) : {error}", "actionSucceeded": "{action} réussi ({status})", + "requestFailed": "Échec de la demande", "mintAuthorityHelper": "Peut Mint de nouveaux Tokens.", "freezeAuthorityHelper": "Peut Freeze et Unfreeze les comptes Token.", "metadataAuthorityHelper": "Peut mettre à jour les métadonnées Token.", 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.unit.test.ts b/apps/sdp-web/src/app/api/dashboard/issuance/tokens/[tokenId]/[action]/route.unit.test.ts index 4990ebe55..249164cfa 100644 --- 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 @@ -23,9 +23,14 @@ describe("POST /api/dashboard/issuance/tokens/[tokenId]/[action]", () => { vi.clearAllMocks(); }); - it("returns 404 for an unsupported action without proxying", async () => { + 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: "not-a-real-action" }), + params: Promise.resolve({ tokenId: "tok_1", action }), }); expect(response.status).toBe(404);