diff --git a/deno.json b/deno.json index b89e2cc..5eb08b9 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/provider-console", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "tasks": { "dev": "deno run --allow-all --allow-env --watch src/server.ts", diff --git a/src/app.ts b/src/app.ts index 4684a9d..4b2add5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,9 @@ import { metadataView } from "./views/setup/metadata.ts"; import { fundView } from "./views/setup/fund.ts"; import { joinView } from "./views/setup/join.ts"; +// Public KYC/KYB submission — no auth, isolated from operator session +import { entitiesRegisterView } from "./views/entities/register.ts"; + // Initialize analytics (NOOP in dev) initAnalytics(); initTracer({ endpoint: OTEL_ENDPOINT, auth: OTEL_AUTH }); @@ -27,6 +30,7 @@ route("/setup/metadata", metadataView); route("/setup/fund", fundView); route("/setup/join", joinView); route("/recover", recoverView); +route("/entities/register", entitiesRegisterView); // Root — redirect based on auth state route("/", () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 8539195..da374ca 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -178,10 +178,10 @@ export async function listPps(): Promise { } export async function deletePp(publicKey: string): Promise { - const res = await platformFetch("/dashboard/pp/delete", { - method: "POST", - body: JSON.stringify({ publicKey }), - }); + const res = await platformFetch( + `/providers/${encodeURIComponent(publicKey)}`, + { method: "DELETE" }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.message || "Failed to delete provider"); @@ -234,10 +234,14 @@ export async function joinCouncil(data: { timestamp: number; }; }): Promise<{ joinRequestId: string; status: string }> { - const res = await platformFetch("/dashboard/council/join", { - method: "POST", - body: JSON.stringify(data), - }); + const { ppPublicKey, ...rest } = data; + const res = await platformFetch( + `/providers/${encodeURIComponent(ppPublicKey)}/council/join`, + { + method: "POST", + body: JSON.stringify(rest), + }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.message || "Failed to join council"); @@ -263,9 +267,7 @@ export async function getCouncilMembership( ppPublicKey: string, ): Promise { const res = await platformFetch( - `/dashboard/council/membership?ppPublicKey=${ - encodeURIComponent(ppPublicKey) - }`, + `/providers/${encodeURIComponent(ppPublicKey)}/council/membership`, ); if (!res.ok) throw new Error("Failed to retrieve membership"); const { data } = await res.json(); @@ -280,10 +282,13 @@ export async function getCouncilMembership( export async function checkMembershipStatus( ppPublicKey: string, ): Promise<"ACTIVE" | "PENDING" | "REJECTED"> { - const res = await platformFetch("/dashboard/council/membership", { - method: "POST", - body: JSON.stringify({ ppPublicKey }), - }); + const res = await platformFetch( + `/providers/${encodeURIComponent(ppPublicKey)}/council/membership`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); if (!res.ok) return "PENDING"; const { data } = await res.json(); return data?.status ?? "PENDING"; @@ -302,7 +307,7 @@ export async function getTreasury( ppPublicKey: string, ): Promise { const res = await platformFetch( - `/dashboard/treasury?ppPublicKey=${encodeURIComponent(ppPublicKey)}`, + `/providers/${encodeURIComponent(ppPublicKey)}/treasury`, ); if (!res.ok) throw new Error("Failed to fetch treasury info"); const { data } = await res.json(); @@ -338,11 +343,10 @@ export async function getMetrics( ppPublicKey: string, rangeMin: number, ): Promise { - const qs = new URLSearchParams({ - ppPublicKey, - rangeMin: String(rangeMin), - }); - const res = await platformFetch(`/dashboard/metrics?${qs}`); + const qs = new URLSearchParams({ rangeMin: String(rangeMin) }); + const res = await platformFetch( + `/providers/${encodeURIComponent(ppPublicKey)}/metrics?${qs}`, + ); if (!res.ok) throw new Error("Failed to fetch metrics"); const body = await res.json(); return body.data as MetricsResponse; @@ -371,9 +375,14 @@ export interface BundleDetail { amount: string | null; } -export async function getBundleDetail(bundleId: string): Promise { +export async function getBundleDetail( + ppPublicKey: string, + bundleId: string, +): Promise { const res = await platformFetch( - `/dashboard/bundles/${encodeURIComponent(bundleId)}`, + `/providers/${encodeURIComponent(ppPublicKey)}/bundles/${ + encodeURIComponent(bundleId) + }`, ); if (!res.ok) throw new Error("Failed to fetch bundle detail"); const body = await res.json(); @@ -395,8 +404,10 @@ export async function listRecentBundles( ppPublicKey: string, limit: number, ): Promise { - const qs = new URLSearchParams({ ppPublicKey, limit: String(limit) }); - const res = await platformFetch(`/dashboard/bundles?${qs}`); + const qs = new URLSearchParams({ limit: String(limit) }); + const res = await platformFetch( + `/providers/${encodeURIComponent(ppPublicKey)}/bundles?${qs}`, + ); if (!res.ok) throw new Error("Failed to list recent bundles"); const body = await res.json(); return (body.data as { bundles: RecentBundleSummary[] }).bundles; diff --git a/src/lib/events-client.ts b/src/lib/events-client.ts index 9908a9e..7570ada 100644 --- a/src/lib/events-client.ts +++ b/src/lib/events-client.ts @@ -1,5 +1,5 @@ /** - * WebSocket client for /api/v1/events/ws on provider-platform. + * WebSocket client for /api/v1/providers/:ppPublicKey/events/ws on provider-platform. * * - URL derived from API_BASE_URL by swapping http(s):// → ws(s)://. * - Auth via `Sec-WebSocket-Protocol: moonlight.events.v1, bearer.`. @@ -173,9 +173,9 @@ export class EventsClient { return; } const base = wsUrlFromApiBase(API_BASE_URL); - const url = `${base}/events/ws?pp=${ + const url = `${base}/providers/${ encodeURIComponent(this.opts.ppPublicKey) - }`; + }/events/ws`; this.opts.onStatus?.("connecting"); const sock = new WebSocket(url, [SUBPROTOCOL, `bearer.${token}`]); this.socket = sock; diff --git a/src/lib/router.ts b/src/lib/router.ts index 5d74330..1d94c65 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -17,6 +17,7 @@ type RouteEntry = { const routes: RouteEntry[] = []; let cleanups: (() => void)[] = []; let currentParams: Record = {}; +let currentQuery: URLSearchParams = new URLSearchParams(); function parsePattern(pattern: string): Omit { const segments = pattern.split("/").filter((s) => s.length > 0); @@ -57,9 +58,21 @@ export function getRouteParams(): Record { return currentParams; } +/** + * Returns the parsed query string from the current hash route. + * `#/foo/bar?x=1&y=2` → URLSearchParams of `x=1&y=2`. + */ +export function getRouteQuery(): URLSearchParams { + return currentQuery; +} + async function render(): Promise { const hash = globalThis.location.hash || "#/"; - const path = hash.startsWith("#") ? hash.slice(1) : hash; + const raw = hash.startsWith("#") ? hash.slice(1) : hash; + const qIdx = raw.indexOf("?"); + const path = qIdx === -1 ? raw : raw.slice(0, qIdx); + const queryString = qIdx === -1 ? "" : raw.slice(qIdx + 1); + currentQuery = new URLSearchParams(queryString); const pathSegments = path.split("/").filter((s) => s.length > 0); let matched: { entry: RouteEntry; params: Record } | null = diff --git a/src/lib/wallet-kyc.ts b/src/lib/wallet-kyc.ts new file mode 100644 index 0000000..8262f82 --- /dev/null +++ b/src/lib/wallet-kyc.ts @@ -0,0 +1,95 @@ +/** + * Wallet integration for the public KYC/KYB submission route. + * + * Isolation contract — this module: + * - Holds the connected address in MODULE-LOCAL state. No localStorage, + * sessionStorage, IndexedDB, or cookies for any artifact (address, + * signed challenge, derived data). + * - Never reads or writes the operator-auth keys (provider_admin_address, + * master_seed, console_token). Existing operator sessions on other routes + * are unaffected by KYC-route activity, and vice versa. + * - Refresh / navigation away purges all state. The user must reconnect. + * + * The Stellar Wallets Kit is a static SDK shared with operator-auth code, but + * the kit's own internal state (selected wallet, network) is ephemeral and + * does not persist KYC-relevant data. We call its `authModal()` and + * `signMessage()` directly; we do NOT call `setWallet()` (no caching). + */ +import { StellarWalletsKit } from "@creit-tech/stellar-wallets-kit/sdk"; +import { Networks } from "@creit-tech/stellar-wallets-kit/types"; +import { FreighterModule } from "@creit-tech/stellar-wallets-kit/modules/freighter"; +import { STELLAR_NETWORK } from "./config.ts"; + +let kitInitialized = false; +let kycAddress: string | null = null; + +function getWalletNetwork(): Networks { + switch (STELLAR_NETWORK) { + case "mainnet": + return Networks.PUBLIC; + case "standalone": + return Networks.STANDALONE; + default: + return Networks.TESTNET; + } +} + +function getNetworkPassphrase(): string { + switch (STELLAR_NETWORK) { + case "mainnet": + return "Public Global Stellar Network ; September 2015"; + case "standalone": + return "Standalone Network ; February 2017"; + default: + return "Test SDF Network ; September 2015"; + } +} + +function ensureKitInit(): void { + if (kitInitialized) return; + StellarWalletsKit.init({ + modules: [new FreighterModule()], + network: getWalletNetwork(), + }); + kitInitialized = true; +} + +export function getKycAddress(): string | null { + return kycAddress; +} + +export function clearKycWallet(): void { + kycAddress = null; +} + +/** + * Opens the wallet modal and stores the chosen address in module-local state. + */ +export async function connectKycWallet(): Promise { + ensureKitInit(); + const { address } = await StellarWalletsKit.authModal(); + if (!address) throw new Error("Wallet connect cancelled"); + kycAddress = address; + return address; +} + +/** + * Signs the given challenge message (SEP-53) with the connected wallet. + * Returns the wallet's signedMessage string verbatim — the server side + * accepts hex or base64 and tries SEP-43, SEP-53, and raw fallbacks. + */ +export async function signKycMessage(message: string): Promise { + ensureKitInit(); + if (!kycAddress) throw new Error("Wallet not connected"); + const result = await StellarWalletsKit.signMessage(message, { + address: kycAddress, + networkPassphrase: getNetworkPassphrase(), + }); + if ( + typeof result?.signedMessage !== "string" || + result.signedMessage.length === 0 + ) { + throw new Error("Wallet returned an empty signature"); + } + return result.signedMessage; +} diff --git a/src/views/entities/register.ts b/src/views/entities/register.ts new file mode 100644 index 0000000..a324688 --- /dev/null +++ b/src/views/entities/register.ts @@ -0,0 +1,222 @@ +/** + * Public KYC/KYB submission route at #/entities/register?provider=. + * + * Hard requirements — see prompt §4 Phase 3: + * - Public (no JWT). PP comes ONLY from ?provider=; missing → + * hard-error UI, no fallback. + * - ZERO persistence: no localStorage/sessionStorage/IndexedDB/cookies/ + * window.name for any artifact (token, derived key, name input, signed + * challenge, wallet adapter state). + * - No session bleed: this route MUST NOT read the operator-auth storage + * and MUST NOT modify it. The wallet-kyc module enforces this. + * - No operator chrome — no nav, no logout, no "My providers" link. + * + * Flow: connect wallet → fetch challenge → sign nonce → POST entity. + */ +import { escapeHtml } from "../../lib/dom.ts"; +import { getRouteQuery } from "../../lib/router.ts"; +import { API_BASE_URL } from "../../lib/config.ts"; +import { + clearKycWallet, + connectKycWallet, + getKycAddress, + signKycMessage, +} from "../../lib/wallet-kyc.ts"; + +const NAME_MAX_LEN = 250; + +// Plain-text-only sanitisation: strip anything tag-shaped, collapse +// whitespace, trim. Server-side post.ts applies the same rule and is the +// authoritative gate; this is defence in depth. +function sanitiseNameClient(raw: string): string { + return raw + .replace(/<[^>]*>/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function isValidPpPublicKey(value: string): boolean { + // Stellar G-address: 56 chars, starts with G, base32 alphabet. + return /^G[A-Z2-7]{55}$/.test(value); +} + +function hardError(message: string): HTMLElement { + const el = document.createElement("div"); + el.className = "login-container"; + el.innerHTML = ` + + `; + return el; +} + +export function entitiesRegisterView(): HTMLElement { + // Reset any wallet state from a prior visit to this route in the same tab. + clearKycWallet(); + + const query = getRouteQuery(); + const provider = query.get("provider") ?? ""; + + if (!provider) { + return hardError( + "This page requires a ?provider= query parameter naming the provider you are registering with.", + ); + } + if (!isValidPpPublicKey(provider)) { + return hardError( + "The ?provider value does not look like a valid Stellar public key.", + ); + } + + const container = document.createElement("div"); + container.className = "login-container"; + container.innerHTML = ` + + `; + + const stepConnect = container.querySelector( + "#kyc-step-connect", + ) as HTMLDivElement; + const stepForm = container.querySelector( + "#kyc-step-form", + ) as HTMLDivElement; + const stepSuccess = container.querySelector( + "#kyc-step-success", + ) as HTMLDivElement; + const connectBtn = container.querySelector( + "#kyc-connect-btn", + ) as HTMLButtonElement; + const submitBtn = container.querySelector( + "#kyc-submit-btn", + ) as HTMLButtonElement; + const addressEl = container.querySelector("#kyc-address") as HTMLElement; + const nameInput = container.querySelector("#kyc-name") as HTMLInputElement; + const errorEl = container.querySelector("#kyc-error") as HTMLParagraphElement; + + function showError(message: string): void { + errorEl.textContent = message; + errorEl.hidden = false; + } + function clearError(): void { + errorEl.hidden = true; + errorEl.textContent = ""; + } + + connectBtn.addEventListener("click", async () => { + connectBtn.disabled = true; + clearError(); + try { + const address = await connectKycWallet(); + addressEl.textContent = address; + stepConnect.hidden = true; + stepForm.hidden = false; + nameInput.focus(); + } catch (err) { + showError(err instanceof Error ? err.message : "Failed to connect"); + connectBtn.disabled = false; + } + }); + + submitBtn.addEventListener("click", async () => { + clearError(); + const address = getKycAddress(); + if (!address) { + showError("Wallet disconnected. Reload and reconnect."); + return; + } + const name = sanitiseNameClient(nameInput.value); + if (name.length === 0) { + showError("Please enter your legal name."); + return; + } + submitBtn.disabled = true; + nameInput.disabled = true; + + try { + const base = `${API_BASE_URL}/providers/${ + encodeURIComponent(provider) + }/entities`; + + // 1. Fetch challenge. + const challengeRes = await fetch(`${base}/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey: address }), + }); + if (!challengeRes.ok) { + const body = await challengeRes.json().catch(() => ({})); + throw new Error( + body.message || `Challenge failed (${challengeRes.status}).`, + ); + } + const { data: { nonce } } = await challengeRes.json(); + + // 2. Sign the nonce. + const signature = await signKycMessage(nonce); + + // 3. Submit entity. + const submitRes = await fetch(base, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pubkey: address, + name, + jurisdictions: [], + signedChallenge: { nonce, signature }, + }), + }); + if (!submitRes.ok && submitRes.status !== 409) { + const body = await submitRes.json().catch(() => ({})); + throw new Error( + body.message || `Submit failed (${submitRes.status}).`, + ); + } + + // Success: clear all in-memory state, show success card. + clearKycWallet(); + nameInput.value = ""; + stepForm.hidden = true; + stepSuccess.hidden = false; + } catch (err) { + showError(err instanceof Error ? err.message : "Submission failed"); + submitBtn.disabled = false; + nameInput.disabled = false; + } + }); + + return container; +} diff --git a/src/views/provider.ts b/src/views/provider.ts index 12e10b4..f8b0770 100644 --- a/src/views/provider.ts +++ b/src/views/provider.ts @@ -717,7 +717,7 @@ function setupV2Zones(opts: SetupOpts): ZoneHandle { function previewEnsureDetail(bundleId: string): void { if (bundleDetails.has(bundleId)) return; bundleDetails.set(bundleId, "loading"); - getBundleDetail(bundleId).then( + getBundleDetail(ppPublicKey, bundleId).then( (d) => { bundleDetails.set(bundleId, d); // Enrich the row with entity data fetched from the detail endpoint —