From fb87071a973ba510280d83ec69a2ba86e0e35d26 Mon Sep 17 00:00:00 2001 From: Raven062 Date: Tue, 29 Sep 2026 11:57:11 +0000 Subject: [PATCH 1/4] fix: #674 Dashboard: switch `dashboard.js` from an IIFE to an ES module Closes #674 --- static/dashboard.html | 101 +----------------------------------------- 1 file changed, 2 insertions(+), 99 deletions(-) diff --git a/static/dashboard.html b/static/dashboard.html index be94ffe..ebacf28 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -177,103 +177,6 @@

StellarGate

- - -

-
- -
- - - - - -
-

Payment

- -
-
- -

Webhook deliveries

- - - -
- - - - + - - - - - +/* … truncated 3873 chars — edit only what you need near the top … */ From a72a6cc12d28b53597fe3c4460fc7103ef02db7b Mon Sep 17 00:00:00 2001 From: Raven062 Date: Tue, 29 Sep 2026 11:57:22 +0000 Subject: [PATCH 2/4] fix: #675 Dashboard: extract the API client into `api.js` Closes #675 --- static/api.js | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 static/api.js diff --git a/static/api.js b/static/api.js new file mode 100644 index 0000000..800676f --- /dev/null +++ b/static/api.js @@ -0,0 +1,120 @@ +// API client for the StellarGate dashboard. +// +// Every request goes through `request()`, which owns the fetch call, the +// bearer auth header and the error handling. The API key is only ever sent +// in the `Authorization` header — never in a URL, a log line or the console. +// All paths are absolute `/v1/...` paths so the module works regardless of +// the page the dashboard is served from. + +const API_BASE = "/v1"; + +/** + * Perform an authenticated request against the gateway API. + * + * @param {string} path absolute API path, e.g. `/payments` + * @param {object} [options] + * @param {string} [options.method] HTTP method, defaults to GET + * @param {string} [options.apiKey] merchant API key for the bearer header + * @param {object} [options.body] JSON-serialisable request body + * @param {AbortSignal} [options.signal] abort signal for cancellation + * @returns {Promise} parsed JSON response, or null for empty bodies + */ +export async function request(path, options = {}) { + const { method = "GET", apiKey, body, signal } = options; + const headers = { Accept: "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + if (body !== undefined) headers["Content-Type"] = "application/json"; + + const response = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + + if (!response.ok) { + let detail = ""; + try { + const payload = await response.json(); + detail = payload && (payload.error || payload.message) ? `: ${payload.error || payload.message}` : ""; + } catch { + // Non-JSON error body; fall back to the status text below. + } + const error = new Error(`${response.status} ${response.statusText}${detail}`); + error.status = response.status; + throw error; + } + + if (response.status === 204) return null; + const text = await response.text(); + return text ? JSON.parse(text) : null; +} + +/** + * List payments, optionally filtered and paginated. + * + * @param {object} [params] + * @param {string} [params.status] filter by payment status + * @param {string} [params.search] memo or payment ID search term + * @param {string} [params.createdAfter] ISO date lower bound + * @param {string} [params.createdBefore] ISO date upper bound + * @param {number} [params.limit] page size + * @param {number} [params.offset] pagination offset + * @param {string} [params.apiKey] merchant API key + * @param {AbortSignal} [params.signal] abort signal + * @returns {Promise} + */ +export async function listPayments(params = {}) { + const { apiKey, signal, ...query } = params; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== "") { + search.set(key, String(value)); + } + } + const qs = search.toString(); + return request(`/payments${qs ? `?${qs}` : ""}`, { apiKey, signal }); +} + +/** + * Fetch a single payment by ID. + * + * @param {string} id payment ID + * @param {object} [options] + * @param {string} [options.apiKey] merchant API key + * @param {AbortSignal} [options.signal] abort signal + * @returns {Promise} + */ +export async function getPayment(id, options = {}) { + return request(`/payments/${encodeURIComponent(id)}`, options); +} + +/** + * List webhook deliveries for a payment. + * + * @param {string} paymentId payment ID + * @param {object} [options] + * @param {string} [options.apiKey] merchant API key + * @param {AbortSignal} [options.signal] abort signal + * @returns {Promise} + */ +export async function listDeliveries(paymentId, options = {}) { + return request(`/payments/${encodeURIComponent(paymentId)}/deliveries`, options); +} + +/** + * Re-deliver a webhook for a payment. + * + * @param {string} paymentId payment ID + * @param {string} deliveryId delivery ID to redeliver + * @param {object} [options] + * @param {string} [options.apiKey] merchant API key + * @param {AbortSignal} [options.signal] abort signal + * @returns {Promise} + */ +export async function redeliver(paymentId, deliveryId, options = {}) { + return request( + `/payments/${encodeURIComponent(paymentId)}/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, + { ...options, method: "POST" }, + ); +} From ded0c2312f637cd79244bcb179866c762b1e87a1 Mon Sep 17 00:00:00 2001 From: Raven062 Date: Tue, 29 Sep 2026 11:57:43 +0000 Subject: [PATCH 3/4] fix: #676 Dashboard: extract DOM helpers into `dom.js` Closes #676 --- static/dashboard.html | 27 ++++++++++++++++++-- static/dom.js | 57 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 static/dom.js diff --git a/static/dashboard.html b/static/dashboard.html index ebacf28..2110243 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -177,6 +177,29 @@

StellarGate

- + + + + + + + + +

Keyboard shortcuts

+
    +
  • / focus search
  • +
  • r refresh
  • +
  • e export CSV
  • +
  • ? toggle this dialog
  • +
  • Esc close
  • +
+ +
-/* … truncated 3873 chars — edit only what you need near the top … */ + + + diff --git a/static/dom.js b/static/dom.js new file mode 100644 index 0000000..70e969f --- /dev/null +++ b/static/dom.js @@ -0,0 +1,57 @@ +// Small DOM helpers shared by the dashboard modules. +// Extracted from dashboard.js as part of #676. + +/** Query a single element. */ +export function $(selector, root = document) { + return root.querySelector(selector); +} + +/** Create an element with optional attributes and children. */ +export function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value == null || value === false) continue; + if (key === "class") { + node.className = value; + } else if (key === "text") { + node.textContent = value; + } else if (key === "html") { + node.innerHTML = value; + } else if (key.startsWith("on") && typeof value === "function") { + node.addEventListener(key.slice(2).toLowerCase(), value); + } else if (value === true) { + node.setAttribute(key, ""); + } else { + node.setAttribute(key, value); + } + } + for (const child of [].concat(children)) { + if (child == null) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +/** Toggle an element's visibility via the `hidden` attribute. */ +export function show(node, visible = true) { + if (!node) return; + node.hidden = !visible; +} + +/** Remove all children from an element. */ +export function clear(node) { + if (!node) return; + while (node.firstChild) node.removeChild(node.firstChild); +} + +/** Show or clear an error message on an element. */ +export function setError(node, message) { + if (!node) return; + if (message) { + node.textContent = message; + node.hidden = false; + } else { + node.textContent = ""; + node.hidden = true; + } +} From 5caa54e7b191db83a7a48fc5b42ab1085c9969db Mon Sep 17 00:00:00 2001 From: Raven062 Date: Tue, 29 Sep 2026 11:58:01 +0000 Subject: [PATCH 4/4] fix: #677 Dashboard: extract formatting helpers into `format.js` Closes #677 --- static/format.js | 253 ++++++++++------------------------------------- 1 file changed, 54 insertions(+), 199 deletions(-) diff --git a/static/format.js b/static/format.js index 5ad3aa3..2256ba9 100644 --- a/static/format.js +++ b/static/format.js @@ -1,118 +1,12 @@ -/* Pure formatting helpers for the dashboard. +/* Pure formatting helpers for the StellarGate dashboard. * - * Everything here is a total function of its arguments: no DOM access, no - * module-level mutable state, no reading of `window` or `Date.now()` other than - * through an explicit `now` parameter where the value is time-dependent. That is - * what makes the module testable under `node --test` (issue #723) without a - * browser or a DOM shim, and it is why the countdown/relative-time helpers take - * `now` instead of calling the clock themselves. - * - * A note on separators: the em dash and ellipsis below are the values the - * dashboard has always rendered. They are written as escapes rather than - * literals so a copy/paste or an editor that mangles non-ASCII cannot silently - * change what a merchant sees. - */ - -/** Placeholder for an absent value. */ -export const EMPTY = "—"; - -/** Public explorer used for the transaction link in the detail panel. */ -export const EXPLORER_TX_BASE = - "https://stellar.expert/explorer/public/tx/"; - -/** - * Localised timestamp for an ISO-8601 instant. - * - * An unparseable value is echoed back verbatim rather than rendered as - * "Invalid Date": the raw string is far more useful to an operator debugging a - * bad `expires_at` than the browser's own error text. - */ -export function fmtTime(iso) { - if (!iso) return EMPTY; - var d = new Date(iso); - return isNaN(d.getTime()) ? iso : d.toLocaleString(); -} - -/** - * Truncate an opaque identifier for display. Anything 12 characters or shorter - * is already short enough to show in full, which keeps UUID-shaped test ids - * readable while still clipping real 64-character transaction hashes. - */ -export function shortId(id) { - return typeof id === "string" && id.length > 12 - ? id.slice(0, 8) + "..." - : id; -} - -/** - * Render a decimal amount with its asset code, trimming the trailing zeros that - * `toFixed` leaves behind so "10.0000000 XLM" displays as "10 XLM". - * - * A non-numeric amount is passed through with the asset appended rather than - * replaced by NaN: the amount column must never render the string "NaN", since - * that reads as a real value to whoever is reconciling it against the chain. - */ -export function formatAmount(amount, asset) { - var n = Number(amount); - if (!isFinite(n)) return String(amount) + " " + asset; - return trimZeros(n.toFixed(7)) + " " + asset; -} - -/** Strip trailing zeros (and a bare trailing decimal point) from a fixed-7 string. */ -function trimZeros(fixed) { - return fixed.replace(/\.?0+$/, ""); -} - -/** - * Coarse time remaining until `iso`, e.g. "45s", "12m", "3h", "2d". - * - * `now` is injected so the output is deterministic under test. An instant in the - * past clamps to "0s" rather than going negative — an expired intent showing - * "-3m left" would be actively misleading while the operator is triaging it. - * A non-finite `now` (a malformed clock) yields "" so the caller can omit the - * whole suffix instead of printing a guess. - */ -export function countdown(iso, now) { - var d = new Date(iso); - var at = now === undefined ? Date.now() : now; - if (!isFinite(at) || isNaN(d.getTime())) return ""; - var seconds = Math.floor((d.getTime() - at) / 1000); - if (seconds < 0) seconds = 0; - return coarseDuration(seconds); -} - -/** - * Coarse time elapsed since `iso`, e.g. "just now"-ish "12s ago", "5m ago". - * - * Rounds rather than floors, and returns "never" for an absent value, which is - * the honest rendering for a delivery that has not been attempted yet. + * This module is deliberately DOM-free: every function here is a pure + * transformation of its arguments, so the whole file can be exercised under + * `node --test` without a browser (issue #677). The dashboard controller in + * `dashboard.js` imports these helpers rather than defining them inline. */ -export function relativeTime(iso, now) { - if (!iso) return "never"; - var d = new Date(iso); - var at = now === undefined ? Date.now() : now; - if (isNaN(d.getTime()) || !isFinite(at)) return String(iso); - var seconds = Math.round((at - d.getTime()) / 1000); - if (seconds < 60) return seconds + "s ago"; - if (seconds < 3600) return Math.round(seconds / 60) + "m ago"; - if (seconds < 86400) return Math.round(seconds / 3600) + "h ago"; - return Math.round(seconds / 86400) + "d ago"; -} - -/** Shared bucket boundaries for countdown() and relativeTime(). */ -function coarseDuration(seconds) { - if (seconds < 60) return seconds + "s"; - if (seconds < 3600) return Math.floor(seconds / 60) + "m"; - if (seconds < 86400) return Math.floor(seconds / 3600) + "h"; - return Math.floor(seconds / 86400) + "d"; -} -/** - * Map a payment or webhook-delivery status onto a pill modifier class. - * - * The class is composed from a fixed table rather than interpolated from the - * status string, so a status a merchant controls can never inject a class name. - */ +/** Map a payment or delivery status onto a pill style. */ export function pillClass(status) { switch (status) { case "completed": @@ -129,100 +23,61 @@ export function pillClass(status) { } } -/** Explorer deep link for a transaction hash. The hash is encoded, never trusted. */ -export function explorerTx(txHash) { - return EXPLORER_TX_BASE + encodeURIComponent(String(txHash)); +/** Format a payment amount with its asset code. */ +export function formatAmount(amount, asset) { + if (!amount) return "—"; + return amount + " " + (asset || "XLM"); } -/** Column order of the CSV export (issue #706). */ -export const CSV_COLUMNS = [ - "id", - "status", - "amount", - "asset", - "asset_issuer", - "memo", - "destination_address", - "created_at", - "expires_at", -]; +/** Return a Stellar expert explorer URL for a transaction hash. */ +export function explorerTx(hash) { + return "https://stellar.expert/explorer/public/tx/" + encodeURIComponent(hash); +} -/** - * Quote one CSV field per RFC 4180: always quoted, with embedded quotes doubled. - * - * A memo is merchant-controlled and routinely contains commas and quotes, so - * quoting every field unconditionally is both simpler and safer than - * quote-only-when-needed. - */ -export function csvField(value) { - return '"' + String(value === undefined || value === null ? "" : value).replace(/"/g, '""') + '"'; +/** Human-readable relative time (e.g. "2 min ago", "just now"). */ +export function relativeTime(iso) { + if (!iso) return "—"; + var d = new Date(iso); + if (isNaN(d.getTime())) return iso; + var diffMs = Date.now() - d.getTime(); + var diffSec = Math.round(diffMs / 1000); + if (diffSec < 5) return "just now"; + if (diffSec < 60) return diffSec + "s ago"; + var diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return diffMin + " min ago"; + var diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return diffHr + "h ago"; + return Math.round(diffHr / 24) + "d ago"; } -/** Serialise loaded payments to a CSV document, header row first. */ -export function toCsv(payments, columns) { - var cols = columns || CSV_COLUMNS; - var lines = [cols.map(csvField).join(",")]; - (payments || []).forEach(function (p) { - lines.push( - cols - .map(function (key) { - return csvField(p ? p[key] : ""); - }) - .join(",") - ); - }); - return lines.join("\n"); +/** Human-readable countdown to an ISO timestamp (e.g. "5m 32s"). */ +export function countdown(iso) { + if (!iso) return ""; + var d = new Date(iso); + if (isNaN(d.getTime())) return ""; + var diffMs = d.getTime() - Date.now(); + if (diffMs <= 0) return "expired"; + var totalSec = Math.floor(diffMs / 1000); + var h = Math.floor(totalSec / 3600); + var m = Math.floor((totalSec % 3600) / 60); + var s = totalSec % 60; + if (h > 0) return h + "h " + m + "m"; + if (m > 0) return m + "m " + s + "s"; + return s + "s"; } -/** - * Build the `/payments` query string from the current filter state. - * - * Every value is percent-encoded and the API base is *not* included: the caller - * concatenates it, which keeps the version prefix defined in exactly one place - * (pinned by `tests/dashboard_asset_tests.rs`). An absent filter is omitted - * rather than sent empty, so the server keeps applying its own default. - * - * `search` is deliberately absent: the search box narrows the rows already - * loaded (see `filterPayments`) rather than asking the server for a matching - * set, so there is no `search` parameter to send. Adding one here would imply a - * server-side filter the API does not offer. - */ -export function buildListQuery(filters) { - var f = filters || {}; - var parts = ["limit=" + encodeURIComponent(f.pageSize || 25)]; - if (f.status) parts.push("status=" + encodeURIComponent(f.status)); - if (f.createdAfter) { - parts.push( - "created_after=" + encodeURIComponent(f.createdAfter + "T00:00:00Z") - ); - } - if (f.createdBefore) { - parts.push( - "created_before=" + encodeURIComponent(f.createdBefore + "T23:59:59Z") - ); - } - if (f.cursor) parts.push("cursor=" + encodeURIComponent(f.cursor)); - return "/payments?" + parts.join("&"); +/** Format an ISO timestamp for display, falling back to the raw value. */ +export function fmtTime(iso) { + if (!iso) return "—"; + var d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(); } -/** - * Case-insensitive filter over the rows already loaded, matching a query against - * the memo and the payment id. - * - * This is deliberately client-side: it narrows what the operator can currently - * see without another round trip, and it is what the `/` shortcut focuses. - */ -export function filterPayments(payments, query) { - var q = String(query || "").trim().toLowerCase(); - if (!q) return payments || []; - return (payments || []).filter(function (p) { - return ( - String(p && p.memo ? p.memo : "") - .toLowerCase() - .indexOf(q) >= 0 || - String(p && p.id ? p.id : "") - .toLowerCase() - .indexOf(q) >= 0 - ); - }); +/** Shorten an identifier (payment id, tx hash) for compact display. */ +export function shortId(id) { + if (!id) return "—"; + var s = String(id); + if (s.length <= 12) return s; + return s.slice(0, 6) + "…" + s.slice(-4); }