diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c34a3675..5f9e2b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,21 @@ jobs: cache: npm - name: Install locked dependencies run: npm ci + - name: API contract (breaking-change gate, spike 663) + # Breaking changes are measured against the target branch snapshot, so a + # regenerated docs/api/openapi.json cannot hide them. Exit 1 = breaking + # without a version bump + changelog entry, exit 2 = stale snapshot. + env: + BASE_REF: ${{ github.base_ref }} + run: | + BASE_ARGS="" + if [ -n "$BASE_REF" ]; then + git fetch --no-tags --depth=1 origin "$BASE_REF" + if git show "origin/$BASE_REF:docs/api/openapi.json" > "$RUNNER_TEMP/openapi-base.json" 2>/dev/null; then + BASE_ARGS="--base $RUNNER_TEMP/openapi-base.json" + fi + fi + npm run api:contract -- $BASE_ARGS - name: Lint # eslint had no config/devDependency before this change (npm run lint # failed outright); now that it runs, ~95 pre-existing errors surface @@ -52,10 +67,9 @@ jobs: continue-on-error: true run: npm run lint - name: Type check - # Pre-existing type errors exist outside this change's scope; surfaced but - # non-blocking until tracked and fixed separately. - continue-on-error: true run: npm run typecheck + - name: Unit tests + run: npm test - name: Build run: npm run build env: diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index db18ba82..4c0b4e15 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -100,6 +100,7 @@ Owns: - **Deterministic status codes** (validation, cooldown, authorization, pending). - **No implicit success**: all state transitions explicit and auditable. - **Compatibility routes** allowed if typed and documented. +- **Versioned contract** (`phase-api-versioning`): `lib/api-contract.ts` is the source of truth for the public surface; `docs/api/openapi.json` and `docs/api/changelog.json` are rendered from it. Middleware negotiates `X-API-Version` / `Accept-Version` on `/api/*`, echoes `X-API-Version`, answers unknown versions with `406 UNSUPPORTED_API_VERSION` and sunset ones with `410 API_VERSION_SUNSET`, and adds `Deprecation` / `Sunset` / `Link` from `ROUTE_DEPRECATIONS`. CI blocks a breaking diff against the target branch unless a new version and a `breaking: true` changelog entry ship with it. - **Optimistic concurrency on shared mutable records**: a record that more than one client can edit carries an integer `version`. Reads publish it as an `ETag`; mutations may echo it back as `If-Match` (or `parent_version` for appends) and @@ -211,8 +212,8 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-167` | `NEXT_PUBLIC_FEATURE_PHASE_167` / `FEATURE_PHASE_167` | WalletConnect v2 mobile session resilience (`lib/walletconnect-resilience.ts`): configurable relay URL, multi-chain support, session persistence, chain-switch guard, and mobile deep-link return. Spike 623 (issue #486) | off | Unset var, restart — WalletConnect banner and reconnect actions disabled, kit falls back to default single-session behavior | | `phase-168` | `NEXT_PUBLIC_FEATURE_PHASE_168` / `FEATURE_PHASE_168` | xBull wallet full support and parity (`lib/xbull-adapter.ts`): Freighter-difference matrix, xBull module detection, sign result normalization, trustline error translation, and badge surfacing. Spike 624 (issue #487) | off | Unset var, restart — xBull transactions fall through generic kit handlers, badge hidden | | `phase-169` | `NEXT_PUBLIC_FEATURE_PHASE_169` / `FEATURE_PHASE_169` | Ledger blind-signing to clear-signing review for Soroban (`lib/ledger-soroban-decoder.ts`): invokeHostFunction pre-image decode, verification code, on-device review modal, Gate #13 preemptive validation, and hardware error humanizer. Spike 625 (issue #488) | off | Unset var, restart — Ledger transactions fall back to default blind-signing flow without preflight review modal | - - +| `phase-docs-playground` | `NEXT_PUBLIC_FEATURE_PHASE_DOCS_PLAYGROUND` (build-time) | Docs try-it-live console (spike 662 / #525): typed request builder over `lib/api-contract.ts`, response viewer that classifies JSON-RPC-in-200, the x402 shim 503 and 429 Retry-After, correlation-ID display, client-only SEP-53 signing for signals, state-changing calls fenced to testnet, input kept on every error | off | Unset var, rebuild and restart — the `/docs` section disappears; nothing persisted | +| `phase-api-versioning` | `NEXT_PUBLIC_FEATURE_PHASE_API_VERSIONING` / `FEATURE_PHASE_API_VERSIONING` | API versioning (spike 663 / #526): middleware negotiation for `/api/*`, `X-API-Version` echo, typed `406`/`410`, RFC 9745/8594 deprecation headers, `GET /api/versions` (summary or `?format=openapi`). The CI contract gate (`npm run api:contract`) runs regardless of the flag | off | Unset var, restart — no negotiation or version headers, `/api/versions` returns 404; nothing persisted | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/governance/ve-lock/route.ts b/app/api/governance/ve-lock/route.ts index af037f9b..2fec85f2 100644 --- a/app/api/governance/ve-lock/route.ts +++ b/app/api/governance/ve-lock/route.ts @@ -92,7 +92,7 @@ export async function POST(request: NextRequest) { if (!claimRes.ok) { return NextResponse.json({ error: claimRes.error }, { status: 400, headers: CORS }); } - return NextResponse.json({ ok: true, ...claimRes }, { status: 200, headers: CORS }); + return NextResponse.json({ ...claimRes }, { status: 200, headers: CORS }); } if (action === "withdraw") { @@ -100,7 +100,7 @@ export async function POST(request: NextRequest) { if (!withdrawRes.ok) { return NextResponse.json({ error: withdrawRes.error }, { status: 400, headers: CORS }); } - return NextResponse.json({ ok: true, ...withdrawRes }, { status: 200, headers: CORS }); + return NextResponse.json({ ...withdrawRes }, { status: 200, headers: CORS }); } if (action === "lock") { diff --git a/app/api/ipfs/[...cid]/route.ts b/app/api/ipfs/[...cid]/route.ts index d5b053be..18a516e0 100644 --- a/app/api/ipfs/[...cid]/route.ts +++ b/app/api/ipfs/[...cid]/route.ts @@ -50,7 +50,7 @@ export async function GET( // the 1-year shared cache. const { verifyCID, cacheControlForCid } = await import("@/lib/cid-verification") const requestedCid = parsed.data[0] ?? "" - const verification = verifyCID(requestedCid, result.bytes) + const verification = verifyCID(requestedCid, new Uint8Array(result.bytes)) const gated = request.headers.get("x-phase-gated") === "1" if (!verification.ok && verification.code === "CID_MISMATCH") { diff --git a/app/api/market/[id]/offers/[offer_id]/route.ts b/app/api/market/[id]/offers/[offer_id]/route.ts index 6fdf59f4..ec629c15 100644 --- a/app/api/market/[id]/offers/[offer_id]/route.ts +++ b/app/api/market/[id]/offers/[offer_id]/route.ts @@ -53,6 +53,7 @@ export async function POST( // If accepted, mark listing as sold let royalty: Awaited> | null = null + let splits: Awaited> | null = null if (action === "accept") { await soldListing(id) @@ -65,6 +66,12 @@ export async function POST( } } + // Spike 674 (flag-gated): N-way fan-out at accept (dust → first payee, idempotent per offer). + if (isPhaseCollabSplitsEnabled() && listing.split_payees && listing.split_payees.length > 0) { + const computed = computeCollabSplits(offer.amount_phaselq, listing.split_payees) + splits = await recordCollabSplits(id, offer.id, computed) + } + // phase-140: split proceeds with the original creator on a secondary sale. if (isPhase140Enabled()) { const split = computeRoyaltySplit(listing, offer.amount_phaselq) diff --git a/app/api/nft-index/subscribe/route.ts b/app/api/nft-index/subscribe/route.ts index d4eadd6a..83bfeccc 100644 --- a/app/api/nft-index/subscribe/route.ts +++ b/app/api/nft-index/subscribe/route.ts @@ -157,8 +157,21 @@ export function validateWebhookUrl( if (/^\d+$/.test(host) || /^\[/.test(host) || host.includes(":")) { return { ok: false, error: "webhookUrl must use a public hostname", reason: "ip_literal" } } - if (host === "169.254.169.254" || host.startsWith("10.") || host.startsWith("192.168.")) { - return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" } + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])] + if (a === 127 || a === 0) { + return { ok: false, error: "webhookUrl host is not routable", reason: "loopback" } + } + const isPrivate = + a === 10 || + (a === 100 && b >= 64 && b <= 127) || // CGNAT + (a === 169 && b === 254) || // link-local, incl. cloud metadata + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + if (isPrivate) { + return { ok: false, error: "webhookUrl must use a public hostname", reason: "private_network" } + } } return { ok: true, url } } diff --git a/app/api/notifications/winback/route.ts b/app/api/notifications/winback/route.ts index 43dd8d2f..6e074534 100644 --- a/app/api/notifications/winback/route.ts +++ b/app/api/notifications/winback/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { evaluateWinbackCampaign, recordWinbackDispatch, getWinbackStore } from "@/lib/churn-winback"; -import { addNotification } from "@/lib/notification-store"; +import { createNotification } from "@/lib/notification-store"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -88,16 +88,12 @@ export async function POST(request: NextRequest) { if (body.dispatch && evaluation.eligible && evaluation.cohort !== "control") { await recordWinbackDispatch(wallet, evaluation.cohort, evaluation.hook, experimentId); try { - await addNotification({ - recipientWallet: wallet, - type: "system", + await createNotification(wallet, "winback_reactivation", { title: evaluation.title, body: evaluation.body, - data: { - cohort: evaluation.cohort, - hook: evaluation.hook, - experimentId, - }, + cohort: evaluation.cohort, + hook: evaluation.hook, + experimentId, }); dispatched = true; } catch { diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index 6028b5c7..498f1d20 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -259,13 +259,7 @@ export async function verifyAvatarOwnershipSignature( imageUrl: string, signatureBase64: string, ): Promise { - try { - const { Keypair } = await import("@stellar/stellar-sdk") - const { SIGNATURE_PREFIX, sha256Hex } = await import("@/lib/viewer-signature") - const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}` - const data = new TextEncoder().encode(SIGNATURE_PREFIX + message) - return Keypair.fromPublicKey(wallet).verify(data, Buffer.from(signatureBase64, "base64")) - } catch { - return false - } + const { sha256Hex, verifySep53Signature } = await import("@/lib/viewer-signature") + const message = `phase-avatar:v1:${await sha256Hex(JSON.stringify({ wallet, imageUrl }))}` + return verifySep53Signature(wallet, message, signatureBase64) } \ No newline at end of file diff --git a/app/api/versions/route.ts b/app/api/versions/route.ts new file mode 100644 index 00000000..5b76343d --- /dev/null +++ b/app/api/versions/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { + API_CHANGELOG, + API_VERSIONS, + buildOpenApiDocument, + currentApiVersion, + ROUTE_DEPRECATIONS, +} from "@/lib/api-versioning" + +export const dynamic = "force-dynamic" + +/** phase-api-versioning: machine-readable versions, deprecations, changelog and OpenAPI contract. */ +export async function GET(request: NextRequest) { + if (!isFeatureEnabled("phase-api-versioning")) { + return NextResponse.json({ error: "Feature phase-api-versioning disabled" }, { status: 404 }) + } + const format = request.nextUrl.searchParams.get("format") + if (format === "openapi") { + return NextResponse.json(buildOpenApiDocument()) + } + if (format && format !== "summary") { + return NextResponse.json({ error: "format must be summary or openapi" }, { status: 400 }) + } + return NextResponse.json({ + current: currentApiVersion(), + versions: API_VERSIONS, + deprecations: ROUTE_DEPRECATIONS, + changelog: API_CHANGELOG, + }) +} diff --git a/app/docs/page.tsx b/app/docs/page.tsx index 40d07143..1c9a4609 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -4,6 +4,7 @@ import Image from "next/image" import Link from "next/link" import { LangToggle } from "@/components/lang-toggle" import { useLang } from "@/components/lang-context" +import { DocsApiPlayground } from "@/components/docs-api-playground" import { pickProjectDocs, type DocsLinkItem, type DocsRecipe, type ProjectDocsPage } from "@/lib/project-docs-content" // phase-668. Read literally: Next only inlines NEXT_PUBLIC_* into client bundles @@ -12,6 +13,11 @@ const RECIPES_ENABLED = ["1", "true", "yes", "on"].includes( (process.env.NEXT_PUBLIC_FEATURE_PHASE_668 ?? "").trim().toLowerCase(), ) +// phase-docs-playground try-it-live console, read literally for the same reason. +const PLAYGROUND_ENABLED = ["1", "true", "yes", "on"].includes( + (process.env.NEXT_PUBLIC_FEATURE_PHASE_DOCS_PLAYGROUND ?? "").trim().toLowerCase(), +) + /** Renders text with every occurrence of "wallet" highlighted in cyan. */ function W({ children }: { children: string }) { const parts = children.split(/(wallet)/gi) @@ -161,7 +167,7 @@ function DocsRecipeCard({ export default function DocsPage() { const { lang } = useLang() - const d = pickProjectDocs(lang, { recipes: RECIPES_ENABLED }) + const d = pickProjectDocs(lang, { recipes: RECIPES_ENABLED, playground: PLAYGROUND_ENABLED }) return (
@@ -273,6 +279,14 @@ export default function DocsPage() {
) } + if (block.type === "playground") { + return ( +
+

{block.intro}

+ +
+ ) + } if (block.type === "recipe") { return } diff --git a/components/chamber/artifact-viewer.tsx b/components/chamber/artifact-viewer.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/artifact-viewer.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/catalog-thumb.tsx b/components/chamber/catalog-thumb.tsx new file mode 100644 index 00000000..81c5028f --- /dev/null +++ b/components/chamber/catalog-thumb.tsx @@ -0,0 +1,25 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import { useState } from "react" +import { IpfsDisplayImg } from "@/components/ipfs-display-img" + +export function ChamberCatalogThumb({ collectionId, uri }: { collectionId: number; uri: string }) { + const [broken, setBroken] = useState(false) + if (!uri.trim() || broken) { + return ( + + #{collectionId} + + ) + } + return ( + setBroken(true)} + /> + ) +} diff --git a/components/chamber/certificate-exporter.tsx b/components/chamber/certificate-exporter.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/certificate-exporter.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/chamber-chrome.ts b/components/chamber/chamber-chrome.ts new file mode 100644 index 00000000..9919347f --- /dev/null +++ b/components/chamber/chamber-chrome.ts @@ -0,0 +1,11 @@ +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +/** Same zinc / violet chrome as `/dashboard` (Phase Market). */ +export const chamberChromeNav = + "inline-flex min-h-[36px] items-center rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300" +export const chamberChromeNavHere = + "inline-flex min-h-[36px] items-center rounded-sm border border-violet-600/50 bg-violet-950/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-violet-300" +export const chamberChromePrimaryBtn = + "flex min-h-[46px] w-full items-center justify-center bg-gradient-to-r from-violet-600 to-cyan-500 py-3 text-center text-[11px] font-bold uppercase tracking-wide text-white shadow-[0_0_18px_rgba(139,92,246,0.35)] transition-opacity hover:opacity-90 disabled:pointer-events-none" +export const chamberChromeRefreshBtn = + "flex items-center gap-1.5 rounded-sm border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400 transition-colors hover:border-violet-500/50 hover:text-violet-300 disabled:opacity-40" diff --git a/components/chamber/energy-gauge-display.tsx b/components/chamber/energy-gauge-display.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/energy-gauge-display.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/log-stream.tsx b/components/chamber/log-stream.tsx new file mode 100644 index 00000000..68ca66df --- /dev/null +++ b/components/chamber/log-stream.tsx @@ -0,0 +1,36 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import type { RefObject } from "react" +import { cn } from "@/lib/utils" + +export function ChamberLogStream({ + lines, + endRef, +}: { + lines: { id: number; text: string }[] + endRef: RefObject +}) { + return ( + <> + {lines.map((l, i) => { + const fault = /FAIL|Failed|ERROR|FAULT|ABORT|REJECT|denied|timeout|sequence|desync/i.test(l.text) + return ( +
+ {l.text} +
+ ) + })} +
+ + ) +} diff --git a/components/chamber/phaser-liq-token-link.tsx b/components/chamber/phaser-liq-token-link.tsx new file mode 100644 index 00000000..d334c640 --- /dev/null +++ b/components/chamber/phaser-liq-token-link.tsx @@ -0,0 +1,45 @@ +"use client" + +// Extracted verbatim from components/fusion-chamber.tsx (refactor #33 imported it but never committed the file). + +import { TokenIcon } from "@/components/token-icon" +import { playTacticalUiClick } from "@/lib/tactical-ui-click" +import { cn } from "@/lib/utils" + +/** Enlace al asset PHASELQ en Stellar Expert (icono + símbolo). */ +export function PhaserLiqTokenLink({ + href, + symbol, + iconClassName, + className, + variant = "default", +}: { + href: string + symbol: string + iconClassName?: string + className?: string + variant?: "default" | "violet" +}) { + return ( + { + e.stopPropagation() + playTacticalUiClick() + }} + className={cn( + "inline-flex items-center gap-1.5 rounded-sm border border-transparent font-mono tracking-normal underline-offset-[3px] transition-colors hover:underline focus:outline-none", + variant === "violet" + ? "text-violet-200/95 hover:border-violet-500/45 hover:bg-violet-950/35 hover:text-violet-50 focus-visible:ring-2 focus-visible:ring-violet-400/55" + : "text-cyan-400/95 hover:border-cyan-500/45 hover:bg-cyan-950/35 hover:text-cyan-50 focus-visible:ring-2 focus-visible:ring-cyan-400/60", + className, + )} + > + + {symbol} + + ) +} diff --git a/components/chamber/settlement-wizard.tsx b/components/chamber/settlement-wizard.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/settlement-wizard.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/chamber/terminal-header.tsx b/components/chamber/terminal-header.tsx deleted file mode 100644 index b3a42524..00000000 --- a/components/chamber/terminal-header.tsx +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/components/docs-api-playground.tsx b/components/docs-api-playground.tsx new file mode 100644 index 00000000..09f7f572 --- /dev/null +++ b/components/docs-api-playground.tsx @@ -0,0 +1,322 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useLang } from "@/components/lang-context" +import { useWallet } from "@/components/wallet-provider" +import { + buildPlaygroundRequest, + exampleBody, + fencePlaygroundCall, + interpretPlaygroundResponse, + playgroundOperations, + WALLET_FILLED_FIELDS, + type InterpretedResponse, + type PlaygroundInput, + type PlaygroundOperation, +} from "@/lib/api-playground" +import { fillCopy, pickCopy } from "@/lib/phase-copy" +import { NETWORK_PASSPHRASE } from "@/lib/phase-protocol" + +const APP_PASSPHRASE = process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE?.trim() || NETWORK_PASSPHRASE + +type Outcome = + | { type: "response"; sentCorrelationId: string; res: InterpretedResponse } + | { type: "message"; text: string } + +function emptyInput(op: PlaygroundOperation): PlaygroundInput { + return { pathParams: {}, query: {}, body: exampleBody(op) } +} + +async function walletPassphrase(): Promise { + try { + const { kit, initStellarWalletKit } = await import("@/lib/stellar-wallet-kit") + initStellarWalletKit() + const { networkPassphrase } = await kit.getNetwork() + return networkPassphrase || null + } catch { + return null + } +} + +/** Client-only signing: the signature comes from the wallet extension, never from the server. */ +async function signBody(op: PlaygroundOperation, body: string, address: string): Promise { + if (!WALLET_FILLED_FIELDS[op.id]) return body + const parsed = JSON.parse(body || "{}") as Record + const timestamp = Date.now() + const { signSignalPayload } = await import("@/lib/viewer-signature") + const signature = await signSignalPayload( + { title: String(parsed.title ?? "").trim(), body: String(parsed.body ?? "").trim(), timestamp }, + address, + ) + return JSON.stringify({ ...parsed, wallet: address, timestamp, signature }) +} + +/** phase-docs-playground: try-it-live console for the documented API surface. */ +export function DocsApiPlayground() { + const { lang } = useLang() + const copy = pickCopy(lang) + const t = copy.playground + const { address, connect, connecting } = useWallet() + const ops = useMemo(() => playgroundOperations(), []) + const [opId, setOpId] = useState(ops[0].id) + const op = ops.find((o) => o.id === opId) ?? ops[0] + // One input record per operation, never cleared by a response, so errors cannot eat the user's work. + const [inputs, setInputs] = useState>({}) + const input = inputs[op.id] ?? emptyInput(op) + const [apiVersion, setApiVersion] = useState("") + const [busy, setBusy] = useState(false) + const [outcome, setOutcome] = useState(null) + const [retryUntil, setRetryUntil] = useState(0) + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (retryUntil <= Date.now()) return + const id = setInterval(() => setNow(Date.now()), 500) + return () => clearInterval(id) + }, [retryUntil]) + const retryLeft = Math.max(0, Math.ceil((retryUntil - now) / 1000)) + + const patch = useCallback( + (next: Partial) => setInputs((prev) => ({ ...prev, [op.id]: { ...input, ...next } })), + [op.id, input], + ) + + const fenceFor = (passphrase: string | null) => + fencePlaygroundCall(op, { appPassphrase: APP_PASSPHRASE, walletAddress: address, walletPassphrase: passphrase }) + + const staticFence = fenceFor(null) + const needsWallet = op.signing === "wallet" + + async function send() { + setBusy(true) + setOutcome(null) + try { + const passphrase = address && (op.stateChanging || needsWallet) ? await walletPassphrase() : null + const fence = fenceFor(passphrase) + if (!fence.allowed) { + setOutcome({ type: "message", text: t.fence[fence.reason] }) + return + } + + let body = input.body + if (needsWallet && address) { + try { + body = await signBody(op, body, address) + } catch { + setOutcome({ type: "message", text: `${t.signFailed} ${t.inputKept}` }) + return + } + } + + const correlationId = crypto.randomUUID() + const built = buildPlaygroundRequest(op, { ...input, body }, { + correlationId, + apiVersion: apiVersion.trim() || undefined, + }) + if (!built.ok) { + setOutcome({ type: "message", text: `${fillCopy(t.buildErrors[built.error], { field: built.detail ?? "" })} ${t.inputKept}` }) + return + } + + let res: Response + try { + res = await fetch(built.url, { ...built.init, cache: "no-store" }) + } catch { + setOutcome({ type: "message", text: `${t.networkError} ${t.inputKept}` }) + return + } + const interpreted = interpretPlaygroundResponse( + { status: res.status, headers: res.headers, bodyText: await res.text() }, + { lines: copy.chamber.phaseHostContractErrors, unknownTpl: copy.chamber.phaseHostContractUnknown }, + ) + if (interpreted.kind === "rate_limited" && interpreted.retryAfterSec) { + const until = Date.now() + interpreted.retryAfterSec * 1000 + setRetryUntil(until) + setNow(Date.now()) + } + setOutcome({ type: "response", sentCorrelationId: correlationId, res: interpreted }) + } finally { + setBusy(false) + } + } + + const label = "font-mono text-[9px] font-bold uppercase tracking-[0.28em] text-violet-500/70" + const field = + "w-full border border-violet-500/25 bg-black/60 px-3 py-2 font-mono text-[11px] text-zinc-200 outline-none focus:border-violet-400/60" + const badge = "border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.2em]" + const canSend = !busy && retryLeft === 0 && op.mode === "callable" + + return ( +
+
+ + {needsWallet ? ( + address ? ( + + {fillCopy(t.walletConnected, { address: `${address.slice(0, 4)}…${address.slice(-4)}` })} + + ) : ( + + ) + ) : null} +
+ +
+ {op.stateChanging ? ( + <> + {t.badges.stateChanging} + {t.badges.testnetOnly} + + ) : null} + {needsWallet ? {t.badges.walletSigned} : null} + {op.mode === "docs_only" ? {t.badges.docsOnly} : null} +
+ + {op.mode === "docs_only" && op.docsOnlyReason ? ( +

{t.docsOnly[op.docsOnlyReason]}

+ ) : null} + + {op.pathParams?.length ? ( +
+ {t.pathParams} + {op.pathParams.map((name) => ( + patch({ pathParams: { ...input.pathParams, [name]: e.target.value } })} + /> + ))} +
+ ) : null} + + {op.query?.length ? ( +
+ {t.query} + {op.query.map((name) => ( + patch({ query: { ...input.query, [name]: e.target.value } })} + /> + ))} +
+ ) : null} + + {op.body && op.method !== "GET" && op.mode === "callable" ? ( +