.

Kim committed Jun 5, 2026 at 18:24 UTC 763f018b5cace459a776fc83ef266d4cd5892051
22 files changed +1244 -851
cmd/payment-app/handler.go
+4 -133
@@ -14,139 +14,14 @@ import (
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
16
17 -//go:embed static/index.html static/style.css
17 +//go:embed static/index.html static/photo.html static/style.css
18 var staticFiles embed.FS
19
20 const paidPhotoPath = "/paid/photo"
21
22 var (
23 indexPage = template.Must(template.ParseFS(staticFiles, "static/index.html"))
24 - photoPage = template.Must(template.New("photo").Parse(`<!DOCTYPE html>
25 -<html lang="en">
26 -<head>
27 - <meta charset="UTF-8">
28 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
29 - <title>{{.PageTitle}}</title>
30 - <meta name="description" content="{{.PageDescription}}">
31 - <meta property="og:type" content="website">
32 - <meta property="og:title" content="{{.PageTitle}}">
33 - <meta property="og:description" content="{{.PageDescription}}">
34 - <meta property="og:image" content="{{.OGImage}}">
35 - <meta property="og:url" content="{{.URL}}">
36 - <meta name="twitter:card" content="summary_large_image">
37 - <meta name="twitter:title" content="{{.PageTitle}}">
38 - <meta name="twitter:description" content="{{.PageDescription}}">
39 - <meta name="twitter:image" content="{{.OGImage}}">
40 - <style>
41 - * { box-sizing: border-box; }
42 - body {
43 - margin: 0;
44 - min-height: 100vh;
45 - display: flex;
46 - padding: 0;
47 - background: #f7f8fb;
48 - color: #182033;
49 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
50 - }
51 - main {
52 - display: grid;
53 - width: 100%;
54 - min-height: 100vh;
55 - grid-template-rows: minmax(0, 1fr) auto;
56 - overflow: hidden;
57 - background: #ffffff;
58 - }
59 - img {
60 - width: 100%;
61 - height: 100%;
62 - min-height: 0;
63 - display: block;
64 - object-fit: contain;
65 - background: #111827;
66 - }
67 - section {
68 - display: grid;
69 - gap: 10px;
70 - padding: 18px;
71 - border-top: 1px solid #e5eaf0;
72 - }
73 - .eyebrow {
74 - margin: 0;
75 - color: #0e7490;
76 - font-size: 12px;
77 - font-weight: 800;
78 - text-transform: uppercase;
79 - letter-spacing: 0;
80 - }
81 - h1 {
82 - margin: 0;
83 - color: #111827;
84 - font-size: 25px;
85 - line-height: 1.18;
86 - }
87 - p {
88 - margin: 0;
89 - color: #4b5565;
90 - font-size: 15px;
91 - line-height: 1.55;
92 - }
93 - dl {
94 - display: grid;
95 - grid-template-columns: repeat(3, 1fr);
96 - gap: 10px;
97 - margin: 4px 0 0;
98 - }
99 - div { min-width: 0; }
100 - dt {
101 - color: #667085;
102 - font-size: 12px;
103 - font-weight: 700;
104 - }
105 - dd {
106 - margin: 4px 0 0;
107 - overflow-wrap: anywhere;
108 - color: #111827;
109 - font-size: 13px;
110 - font-weight: 750;
111 - }
112 - @media (max-width: 640px) {
113 - section { padding: 14px; }
114 - dl { grid-template-columns: 1fr; }
115 - }
116 - </style>
117 -</head>
118 -<body>
119 - <main>
120 - <img src="{{.PhotoURL}}" alt="Unlocked protected image">
121 - <section>
122 - <p class="eyebrow">Payment complete</p>
123 - <h1>Image unlocked</h1>
124 - <p>The protected image is available after the {{.Amount}} atomic USDC x402 settlement.</p>
125 - <dl>
126 - <div>
127 - <dt>Amount</dt>
128 - <dd>{{.Amount}} atomic USDC</dd>
129 - </div>
130 - <div>
131 - <dt>Network</dt>
132 - <dd>{{.NetworkName}}</dd>
133 - </div>
134 - <div>
135 - <dt>Recipient</dt>
136 - <dd>{{.RecipientAddress}}</dd>
137 - </div>
138 - {{if .TransactionID}}
139 - <div>
140 - <dt>Transaction</dt>
141 - <dd>{{.TransactionID}}</dd>
142 - </div>
143 - {{end}}
144 - </dl>
145 - </section>
146 - </main>
147 -</body>
148 -</html>
149 -`))
24 + photoPage = template.Must(template.ParseFS(staticFiles, "static/photo.html"))
25 )
26
27 type paymentHandlerConfig struct {
@@ -167,7 +42,6 @@ type paymentHandler struct {
42 asset string
43 amount string
44 payTo string
170 - endpoints []string
45 photoURL string
46 }
47
@@ -212,7 +86,6 @@ func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
86 handler.asset = payment.Asset
87 handler.amount = payment.Amount
88 handler.payTo = payment.PayTo
215 - handler.endpoints = append([]string(nil), payment.Endpoints...)
89 handler.photoURL = strings.TrimSpace(cfg.PhotoURL)
90
91 staticFS, err := fs.Sub(staticFiles, "static")
@@ -221,6 +94,7 @@ func newHandler(cfg paymentHandlerConfig) (http.Handler, error) {
94 }
95 mux := http.NewServeMux()
96 mux.Handle("/static/style.css", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
97 + mux.HandleFunc(types.X402ClientPath, x402.ServeClientJS)
98 mux.Handle(types.X402PreparePath, paidPhotoHandler)
99 mux.HandleFunc("/", handler.handleIndex)
100 mux.Handle(paidPhotoPath, paidPhotoHandler)
@@ -264,9 +138,6 @@ func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
138 "preparePath": types.X402PreparePath,
139 "protectedPath": paidPhotoPath,
140 }
267 - if len(h.endpoints) > 0 {
268 - config["endpoints"] = append([]string(nil), h.endpoints...)
269 - }
141 configJSON, err := json.Marshal(config)
142 if err != nil {
143 configJSON = []byte("{}")
@@ -280,7 +151,7 @@ func (h *paymentHandler) newPaymentPageData(r *http.Request) paymentPageData {
151 Network: h.network,
152 NetworkName: h.networkName,
153 Asset: h.asset,
283 - Amount: h.amount,
154 + Amount: x402.FormatUSDCAtomicAmount(h.amount),
155 PhotoURL: h.photoURL,
156 RecipientAddress: h.payTo,
157 ConfigJSON: template.JS(string(configJSON)),
cmd/payment-app/main.go
+5 -5
@@ -59,7 +59,7 @@ func run(args []string) error {
59 cfg := paymentConfig{}
60 fs := utils.NewFlagSet("payment-app", printUsage)
61
62 - utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
62 + utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with bootstrap relays when discovery is enabled)", "RELAYS")
63 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", false, "include bootstrap relays and enable discovery", "DISCOVERY")
64 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
65 utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
@@ -74,8 +74,8 @@ func run(args []string) error {
74 utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
75 utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
76 utils.BoolFlag(fs, &cfg.x402Testnet, "x402-testnet", true, "use Sui testnet for x402 payments")
77 - utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "0xdb3585edba7e946c7e5c3827bdc0fc92d20efbb88520a82699ef4504b61aada2", "Sui USDC recipient address")
78 - utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "10000", "USDC amount in atomic units")
77 + utils.StringFlag(fs, &cfg.x402PayTo, "x402-pay-to", "0xbf3cc34e9b676d5f0588b035b9fdb8972306430b19d8239cfee121b60e723ab6", "Sui USDC recipient address")
78 + utils.StringFlag(fs, &cfg.x402Amount, "x402-amount", "0.01", "USDC amount")
79 utils.RepeatedStringFlag(fs, &cfg.x402RPCs, "x402-rpc", "Sui RPC endpoint; repeat to try multiple endpoints before defaults")
80 fs.IntVar(&cfg.x402MaxTimeoutSeconds, "x402-max-timeout", 0, "x402 max payment timeout seconds advertised to clients")
81 fs.IntVar(&cfg.x402RequestTimeout, "x402-request-timeout", 30, "Sui RPC and x402 verify/settle timeout seconds")
@@ -187,8 +187,8 @@ func printUsage(w io.Writer) {
187 },
188 []string{
189 "payment-app --x402-pay-to 0x...",
190 - "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 10000",
191 - "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 10000",
190 + "payment-app --name paid-photo --x402-pay-to 0x... --x402-amount 0.01",
191 + "payment-app --x402-testnet=false --x402-pay-to 0x... --x402-amount 0.01",
192 },
193 )
194 }
cmd/payment-app/static/index.html
+56 -295
@@ -35,7 +35,7 @@
35 <dl class="facts">
36 <div>
37 <dt>Amount</dt>
38 - <dd>{{.Amount}} atomic USDC</dd>
38 + <dd>{{.Amount}}</dd>
39 </div>
40 <div>
41 <dt>Network</dt>
@@ -83,11 +83,9 @@
83 </main>
84
85 <script type="module">
86 - import { getWallets } from 'https://esm.sh/@wallet-standard/app';
87 - import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
86 + import { getSuiWallets, onSuiWalletChange, x402Fetch } from '/x402/client.js';
87
88 const config = JSON.parse(document.getElementById('payment-config').textContent || '{}');
90 - const walletsApi = getWallets();
89 const walletSelect = document.getElementById('walletSelect');
90 const accountSelect = document.getElementById('accountSelect');
91 const unlockButton = document.getElementById('unlockButton');
@@ -97,23 +95,17 @@
95 const paymentFrame = document.getElementById('paymentFrame');
96 const resetViewer = document.getElementById('resetViewer');
97 const viewerTitle = document.getElementById('viewerTitle');
100 - let connectedWallet = null;
98 + let wallets = [];
99 + let connectedWalletIndex = '';
100 let connectedAccounts = [];
101
102 function setStatus(value) {
103 statusEl.textContent = value;
104 }
105
107 - function supportsPayment(candidate) {
108 - const features = candidate?.features || {};
109 - return Boolean(
110 - features['standard:connect'] &&
111 - (features['sui:signTransaction'] || features['sui:signTransactionBlock'])
112 - );
113 - }
114 -
106 function currentWallets() {
116 - return walletsApi.get().filter(supportsPayment);
107 + wallets = getSuiWallets({ network: config.network });
108 + return wallets;
109 }
110
111 function resetAccountSelect() {
@@ -125,49 +117,49 @@
117 }
118
119 function refreshWallets() {
128 - const wallets = currentWallets();
129 - walletSelect.replaceChildren(...wallets.map((candidate, index) => {
120 + const selectedWalletIndex = walletSelect.value;
121 + const nextWallets = currentWallets();
122 + walletSelect.replaceChildren(...nextWallets.map((wallet, index) => {
123 const option = document.createElement('option');
124 option.value = String(index);
132 - option.textContent = candidate.name || `Wallet ${index + 1}`;
125 + option.textContent = wallet.name;
126 return option;
127 }));
135 - const hasWallets = wallets.length > 0;
128 + if (selectedWalletIndex && Number(selectedWalletIndex) < nextWallets.length) {
129 + walletSelect.value = selectedWalletIndex;
130 + }
131 + const hasWallets = nextWallets.length > 0;
132 walletSelect.disabled = !hasWallets;
133 unlockButton.disabled = !hasWallets;
134 unlockButton.textContent = 'Connect and pay';
139 - connectedWallet = null;
135 + connectedWalletIndex = '';
136 connectedAccounts = [];
137 resetAccountSelect();
138 setStatus(hasWallets ? 'Select a wallet and continue' : 'Install a Sui wallet extension');
139 }
140
145 - function normalizeAccounts(value) {
146 - const accounts = Array.isArray(value) ? value : (value ? [value] : []);
147 - return accounts.map((account) => {
148 - if (typeof account === 'string') {
149 - return { address: account };
150 - }
151 - return account && typeof account.address === 'string' ? account : null;
152 - }).filter(Boolean);
153 - }
154 -
155 - function pickAccount(accounts) {
156 - return accounts.find((candidate) => Array.isArray(candidate.chains) && candidate.chains.includes(config.network)) || accounts[0] || null;
141 + function accountKey(account) {
142 + return String(account?.address || '').trim().toLowerCase();
143 }
144
159 - function compatibleAccounts(accounts) {
160 - const matching = accounts.filter((account) => !Array.isArray(account.chains) || account.chains.includes(config.network));
161 - return matching.length > 0 ? matching : accounts;
145 + function shortAddress(value) {
146 + const address = String(value || '').trim();
147 + if (address.length <= 18) {
148 + return address;
149 + }
150 + return `${address.slice(0, 10)}...${address.slice(-6)}`;
151 }
152
164 - function accountKey(account) {
165 - return String(account?.address || '').trim().toLowerCase();
153 + function accountByAddress(accounts, address) {
154 + const key = String(address || '').trim().toLowerCase();
155 + return accounts.find((account) => account.address === address) ||
156 + accounts.find((account) => accountKey(account) === key) ||
157 + null;
158 }
159
168 - function mergeAccounts(primary, secondary) {
160 + function setAccountOptions(accounts, preferredAddress) {
161 const seen = new Set();
170 - return [...primary, ...secondary].filter((account) => {
162 + connectedAccounts = accounts.filter((account) => {
163 const key = accountKey(account);
164 if (!key || seen.has(key)) {
165 return false;
@@ -175,253 +167,46 @@
167 seen.add(key);
168 return true;
169 });
178 - }
179 -
180 - function shortAddress(value) {
181 - const address = String(value || '').trim();
182 - if (address.length <= 18) {
183 - return address;
184 - }
185 - return `${address.slice(0, 10)}...${address.slice(-6)}`;
186 - }
187 -
188 - function setAccountOptions(accounts, preferredAddress) {
189 - connectedAccounts = compatibleAccounts(accounts);
170 accountSelect.replaceChildren(...connectedAccounts.map((account) => {
171 const option = document.createElement('option');
172 option.value = account.address;
173 option.textContent = shortAddress(account.address);
174 return option;
175 }));
196 - const preferred = String(preferredAddress || '').trim();
197 - const preferredAccount = preferred ? accountByAddress(connectedAccounts, preferred) : null;
198 - if (preferredAccount) {
199 - accountSelect.value = preferredAccount.address;
176 + const preferred = accountByAddress(connectedAccounts, preferredAddress);
177 + if (preferred) {
178 + accountSelect.value = preferred.address;
179 }
180 accountSelect.disabled = connectedAccounts.length === 0;
181 }
182
204 - function selectedAccountFrom(response) {
205 - return pickAccount(normalizeAccounts(
206 - response?.account || response?.selectedAccount || response?.currentAccount || response?.address
207 - ));
208 - }
209 -
210 - function accountByAddress(accounts, address) {
211 - const key = String(address || '').trim().toLowerCase();
212 - return accounts.find((account) => account.address === address) ||
213 - accounts.find((account) => accountKey(account) === key) ||
214 - null;
215 - }
216 -
217 - function pickConnectedAccount(response, wallet, hadAccountSelection) {
218 - const selectedAccount = selectedAccountFrom(response);
219 - const responseAccounts = normalizeAccounts(response?.accounts);
220 - const accounts = mergeAccounts(
221 - mergeAccounts(selectedAccount ? [selectedAccount] : [], responseAccounts),
222 - normalizeAccounts(wallet.accounts || [])
223 - );
224 - setAccountOptions(accounts, selectedAccount?.address || accountSelect.value);
225 - if (!selectedAccount && connectedAccounts.length > 1 && !hadAccountSelection) {
226 - return null;
227 - }
228 - const selectedAddress = accountSelect.value;
229 - return accountByAddress(connectedAccounts, selectedAddress) ||
230 - selectedAccount ||
231 - pickAccount(connectedAccounts);
232 - }
233 -
183 async function connectWallet() {
235 - const wallet = currentWallets()[Number(walletSelect.value)];
184 + const selectedWalletIndex = walletSelect.value;
185 + const wallet = wallets[Number(selectedWalletIndex)] || currentWallets()[Number(selectedWalletIndex)];
186 if (!wallet) {
187 throw new Error('No Sui wallet selected');
188 }
239 - const hadAccountSelection = connectedWallet === wallet && accountSelect.value !== '';
240 - const response = await wallet.features['standard:connect'].connect();
241 - connectedWallet = wallet;
242 - const connectedAccount = pickConnectedAccount(response, wallet, hadAccountSelection);
243 - if (connectedAccount === null) {
189 +
190 + const hadAccountSelection = connectedWalletIndex === selectedWalletIndex && accountSelect.value !== '';
191 + const accounts = await wallet.accounts(config.network);
192 + connectedWalletIndex = selectedWalletIndex;
193 + setAccountOptions(accounts, accountSelect.value);
194 +
195 + if (connectedAccounts.length > 1 && !hadAccountSelection) {
196 unlockButton.textContent = 'Pay with selected address';
197 setStatus('Select an address and continue');
198 return null;
199 }
248 - if (!connectedAccount) {
200 +
201 + const account = accountByAddress(connectedAccounts, accountSelect.value) ||
202 + (connectedAccounts.length === 1 ? connectedAccounts[0] : null);
203 + if (!account) {
204 throw new Error('Connected wallet did not return an account');
205 }
251 - if (Array.isArray(connectedAccount.chains) && !connectedAccount.chains.includes(config.network)) {
206 + if (Array.isArray(account.chains) && !account.chains.includes(config.network)) {
207 throw new Error(`Connected account does not advertise ${config.network}`);
208 }
254 - return { wallet, account: connectedAccount };
255 - }
256 -
257 - function base64ToBytes(value) {
258 - const binary = atob(value);
259 - const bytes = new Uint8Array(binary.length);
260 - for (let i = 0; i < binary.length; i += 1) {
261 - bytes[i] = binary.charCodeAt(i);
262 - }
263 - return bytes;
264 - }
265 -
266 - function bytesToBase64(bytes) {
267 - let binary = '';
268 - for (let i = 0; i < bytes.length; i += 0x8000) {
269 - binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
270 - }
271 - return btoa(binary);
272 - }
273 -
274 - function transactionFromBase64(value) {
275 - return Transaction.from(base64ToBytes(value));
276 - }
277 -
278 - function suiRpcURL() {
279 - const endpoints = Array.isArray(config.endpoints) ? config.endpoints.filter(Boolean) : [];
280 - const defaults = {
281 - 'sui:mainnet': 'https://sui-rpc.publicnode.com',
282 - 'sui:testnet': 'https://sui-testnet-rpc.publicnode.com',
283 - };
284 - return endpoints[0] || defaults[String(config.network || '').trim().toLowerCase()] || defaults['sui:testnet'];
285 - }
286 -
287 - function transactionDigest(value) {
288 - if (!value || typeof value !== 'object') {
289 - return '';
290 - }
291 - const stack = [value];
292 - while (stack.length > 0) {
293 - const current = stack.pop();
294 - if (!current || typeof current !== 'object') {
295 - continue;
296 - }
297 - for (const [key, nested] of Object.entries(current)) {
298 - if ((key === 'digest' || key === 'transactionDigest') && typeof nested === 'string') {
299 - return nested;
300 - }
301 - if (nested && typeof nested === 'object') {
302 - stack.push(nested);
303 - }
304 - }
305 - }
306 - return '';
307 - }
308 -
309 - function sleep(ms) {
310 - return new Promise((resolve) => setTimeout(resolve, ms));
311 - }
312 -
313 - async function suiRPC(method, params) {
314 - const response = await fetch(suiRpcURL(), {
315 - method: 'POST',
316 - headers: { 'Content-Type': 'application/json' },
317 - body: JSON.stringify({
318 - jsonrpc: '2.0',
319 - id: 1,
320 - method,
321 - params,
322 - }),
323 - });
324 - if (!response.ok) {
325 - throw new Error(`Sui RPC returned ${response.status}`);
326 - }
327 - const body = await response.json();
328 - if (body.error) {
329 - throw new Error(body.error.message || 'Sui RPC error');
330 - }
331 - return body.result;
332 - }
333 -
334 - async function waitForTransaction(value) {
335 - const digest = transactionDigest(value);
336 - if (!digest) {
337 - return;
338 - }
339 -
340 - const deadline = Date.now() + 20000;
341 - let lastError = null;
342 - for (;;) {
343 - let result = null;
344 - try {
345 - result = await suiRPC('sui_getTransactionBlock', [
346 - digest,
347 - { showEffects: true },
348 - ]);
349 - } catch (error) {
350 - lastError = error;
351 - if (Date.now() >= deadline) {
352 - throw lastError;
353 - }
354 - await sleep(1000);
355 - }
356 - if (!result?.digest) {
357 - lastError = new Error('Prepare transaction is not indexed yet');
358 - if (Date.now() >= deadline) {
359 - throw lastError;
360 - }
361 - await sleep(1000);
362 - continue;
363 - }
364 - const status = result.effects?.status?.status;
365 - if (status && status !== 'success') {
366 - throw new Error(result.effects.status.error || 'Prepare transaction failed');
367 - }
368 - return;
369 - }
370 - }
371 -
372 - async function signTransaction(wallet, account, transactionBytes) {
373 - const tx = transactionFromBase64(transactionBytes);
374 - if (wallet.features['sui:signTransaction']) {
375 - return wallet.features['sui:signTransaction'].signTransaction({
376 - transaction: tx,
377 - account,
378 - chain: config.network,
379 - });
380 - }
381 - return wallet.features['sui:signTransactionBlock'].signTransactionBlock({
382 - transactionBlock: tx,
383 - account,
384 - chain: config.network,
385 - });
386 - }
387 -
388 - async function executePrepareTransaction(wallet, account, transactionBytes) {
389 - const tx = transactionFromBase64(transactionBytes);
390 - if (wallet.features['sui:signAndExecuteTransaction']) {
391 - return wallet.features['sui:signAndExecuteTransaction'].signAndExecuteTransaction({
392 - transaction: tx,
393 - account,
394 - chain: config.network,
395 - });
396 - }
397 - if (wallet.features['sui:signAndExecuteTransactionBlock']) {
398 - return wallet.features['sui:signAndExecuteTransactionBlock'].signAndExecuteTransactionBlock({
399 - transactionBlock: tx,
400 - account,
401 - chain: config.network,
402 - });
403 - }
404 - throw new Error('This wallet cannot execute the USDC prepare transaction');
405 - }
406 -
407 - function encodePaymentPayload(payload) {
408 - return bytesToBase64(new TextEncoder().encode(JSON.stringify(payload)));
409 - }
410 -
411 - async function preparePayment(sender) {
412 - const response = await fetch(config.preparePath, {
413 - method: 'POST',
414 - headers: { 'Content-Type': 'application/json' },
415 - body: JSON.stringify({
416 - sender,
417 - method: 'GET',
418 - path: config.protectedPath,
419 - }),
420 - });
421 - if (!response.ok) {
422 - throw new Error(await response.text());
423 - }
424 - return response.json();
209 + return { wallet, account };
210 }
211
212 async function unlock() {
@@ -443,33 +228,12 @@
228 paymentFrame.hidden = false;
229 viewerTitle.textContent = 'Sui wallet payment';
230
446 - setStatus('Preparing USDC transaction');
447 - const prepared = await preparePayment(account.address);
448 - if (prepared.prepareTransaction) {
449 - setStatus('Preparing object balance in wallet');
450 - const preparedResult = await executePrepareTransaction(wallet, account, prepared.prepareTransaction.transaction);
451 - setStatus('Waiting for prepared balance');
452 - await waitForTransaction(preparedResult);
453 - }
454 -
455 - setStatus('Signing x402 payment');
456 - const signed = await signTransaction(wallet, account, prepared.paymentTransaction.transaction);
457 - const paymentPayload = {
458 - x402Version: prepared.x402Version,
459 - payload: {
460 - signature: signed.signature,
461 - transaction: signed.bytes || signed.transactionBlockBytes || prepared.paymentTransaction.transaction,
462 - },
463 - accepted: prepared.paymentRequirements,
464 - resource: prepared.resource,
465 - };
466 -
467 - setStatus('Settling payment');
468 - const protectedResponse = await fetch(config.protectedPath, {
469 - method: 'GET',
470 - headers: {
471 - 'X-PAYMENT': encodePaymentPayload(paymentPayload),
472 - },
231 + const protectedResponse = await x402Fetch(config.protectedPath, { method: 'GET' }, {
232 + wallet,
233 + account,
234 + network: config.network,
235 + preparePath: config.preparePath,
236 + onStatus: setStatus,
237 });
238 if (!protectedResponse.ok) {
239 throw new Error(await protectedResponse.text());
@@ -494,7 +258,7 @@
258
259 unlockButton.addEventListener('click', unlock);
260 walletSelect.addEventListener('change', () => {
497 - connectedWallet = null;
261 + connectedWalletIndex = '';
262 connectedAccounts = [];
263 resetAccountSelect();
264 unlockButton.textContent = 'Connect and pay';
@@ -512,10 +276,7 @@
276 });
277
278 refreshWallets();
515 - if (typeof walletsApi.on === 'function') {
516 - walletsApi.on('register', refreshWallets);
517 - walletsApi.on('unregister', refreshWallets);
518 - }
279 + onSuiWalletChange(refreshWallets);
280 </script>
281 </body>
282
cmd/payment-app/static/photo.html new
+149
@@ -0,0 +1,149 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 + <title>{{.PageTitle}}</title>
8 + <meta name="description" content="{{.PageDescription}}">
9 + <meta property="og:type" content="website">
10 + <meta property="og:title" content="{{.PageTitle}}">
11 + <meta property="og:description" content="{{.PageDescription}}">
12 + <meta property="og:image" content="{{.OGImage}}">
13 + <meta property="og:url" content="{{.URL}}">
14 + <meta name="twitter:card" content="summary_large_image">
15 + <meta name="twitter:title" content="{{.PageTitle}}">
16 + <meta name="twitter:description" content="{{.PageDescription}}">
17 + <meta name="twitter:image" content="{{.OGImage}}">
18 + <style>
19 + * {
20 + box-sizing: border-box;
21 + }
22 +
23 + body {
24 + margin: 0;
25 + min-height: 100vh;
26 + display: flex;
27 + padding: 0;
28 + background: #f7f8fb;
29 + color: #182033;
30 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
31 + }
32 +
33 + main {
34 + display: grid;
35 + width: 100%;
36 + min-height: 100vh;
37 + grid-template-rows: minmax(0, 1fr) auto;
38 + overflow: hidden;
39 + background: #ffffff;
40 + }
41 +
42 + img {
43 + width: 100%;
44 + height: 100%;
45 + min-height: 0;
46 + display: block;
47 + object-fit: contain;
48 + background: #111827;
49 + }
50 +
51 + section {
52 + display: grid;
53 + gap: 10px;
54 + padding: 18px;
55 + border-top: 1px solid #e5eaf0;
56 + }
57 +
58 + .eyebrow {
59 + margin: 0;
60 + color: #0e7490;
61 + font-size: 12px;
62 + font-weight: 800;
63 + text-transform: uppercase;
64 + letter-spacing: 0;
65 + }
66 +
67 + h1 {
68 + margin: 0;
69 + color: #111827;
70 + font-size: 25px;
71 + line-height: 1.18;
72 + }
73 +
74 + p {
75 + margin: 0;
76 + color: #4b5565;
77 + font-size: 15px;
78 + line-height: 1.55;
79 + }
80 +
81 + dl {
82 + display: grid;
83 + grid-template-columns: repeat(3, 1fr);
84 + gap: 10px;
85 + margin: 4px 0 0;
86 + }
87 +
88 + div {
89 + min-width: 0;
90 + }
91 +
92 + dt {
93 + color: #667085;
94 + font-size: 12px;
95 + font-weight: 700;
96 + }
97 +
98 + dd {
99 + margin: 4px 0 0;
100 + overflow-wrap: anywhere;
101 + color: #111827;
102 + font-size: 13px;
103 + font-weight: 750;
104 + }
105 +
106 + @media (max-width: 640px) {
107 + section {
108 + padding: 14px;
109 + }
110 +
111 + dl {
112 + grid-template-columns: 1fr;
113 + }
114 + }
115 + </style>
116 +</head>
117 +
118 +<body>
119 + <main>
120 + <img src="{{.PhotoURL}}" alt="Unlocked protected image">
121 + <section>
122 + <p class="eyebrow">Payment complete</p>
123 + <h1>Image unlocked</h1>
124 + <p>The protected image is available after the {{.Amount}} x402 settlement.</p>
125 + <dl>
126 + <div>
127 + <dt>Amount</dt>
128 + <dd>{{.Amount}}</dd>
129 + </div>
130 + <div>
131 + <dt>Network</dt>
132 + <dd>{{.NetworkName}}</dd>
133 + </div>
134 + <div>
135 + <dt>Recipient</dt>
136 + <dd>{{.RecipientAddress}}</dd>
137 + </div>
138 + {{if .TransactionID}}
139 + <div>
140 + <dt>Transaction</dt>
141 + <dd>{{.TransactionID}}</dd>
142 + </div>
143 + {{end}}
144 + </dl>
145 + </section>
146 + </main>
147 +</body>
148 +
149 +</html>
cmd/portal-tunnel/README.md
+61 -255
@@ -1,65 +1,44 @@
1 # Portal CLI
2
3 -`cmd/portal-tunnel` builds the `portal` CLI. It connects local services to
4 -Portal relays without requiring inbound firewall rules, port forwarding, or
5 -manual DNS setup.
3 +`cmd/portal-tunnel` builds the `portal` CLI. It exposes local services through
4 +Portal relays without inbound firewall rules, port forwarding, or manual DNS
5 +setup.
6
7 -Portal's default model is intentionally simple:
7 +The relay owns transport, lease registration, routing, and relay policy. The
8 +tunnel process owns local proxy behavior, routed HTTP policy, x402 route
9 +payments, and tenant TLS termination for the default HTTPS stream path.
10
9 -- The relay owns transport, lease registration, routing, and relay policy.
10 -- The tunnel process owns the exposed endpoint behavior.
11 -- In the default HTTPS stream path, tenant TLS terminates in the tunnel process,
12 - not at the relay.
13 -- In routed HTTP mode, the tunnel process runs the HTTP reverse proxy. The relay
14 - is still not an HTTP proxy.
15 -- In raw TCP and UDP modes, the relay allocates public transport endpoints and
16 - forwards traffic to the tunnel process.
11 +## Quick Start
12
18 -## Install
19 -
20 -Install directly from the official GitHub release assets:
13 +Install from GitHub release assets:
14
15 ```bash
16 curl -fsSL https://github.com/gosuda/portal-tunnel/releases/latest/download/install.sh | bash
17 portal expose 3000
25 -portal list
18 ```
19
20 ```powershell
21 $ProgressPreference = 'SilentlyContinue'
22 irm https://github.com/gosuda/portal-tunnel/releases/latest/download/install.ps1 | iex
23 portal expose 3000
32 -portal list
24 ```
25
35 -If your relay publishes its own installer, use that relay instead:
26 +If a relay publishes its own installer:
27
28 ```bash
29 curl -sSL https://portal.example.com/api/install.sh | bash
30 portal expose 3000 --relays https://portal.example.com --discovery=false
31 ```
32
42 -```powershell
43 -$ProgressPreference = 'SilentlyContinue'
44 -irm https://portal.example.com/api/install.ps1 | iex
45 -portal expose 3000 --relays https://portal.example.com --discovery=false
46 -```
47 -
48 -## Choosing A Mode
33 +## Modes
34
50 -Use the default stream mode for most local web apps:
35 +Default HTTPS stream for most local web apps:
36
37 ```text
38 portal expose 3000 --name myapp
39 ```
40
56 -This publishes `myapp.<relay-root-host>` as HTTPS. The relay routes by SNI and
57 -bridges the connection to the tunnel process. The tunnel process performs the
58 -tenant TLS handshake locally and then proxies the byte stream to
59 -`127.0.0.1:3000`.
60 -
61 -Use routed HTTP mode when one public URL should mount multiple local HTTP
62 -services:
41 +Routed HTTP when one public URL should mount multiple local HTTP upstreams:
42
43 ```text
44 portal expose --name myapp \
@@ -67,105 +46,51 @@ portal expose --name myapp \
46 --http-route /=http://127.0.0.1:5173
47 ```
48
70 -This is a tunnel-controlled HTTP reverse proxy. The relay still only transports
71 -connections. Because the tunnel process parses HTTP in this mode, this is the
72 -right mode for HTTP-specific behavior such as path routing, response header
73 -policy, redirect rewriting, and cookie path remapping.
74 -
75 -Use dedicated raw TCP mode for non-HTTP services that need a public TCP port:
49 +Paid routed HTTP with Sui USDC x402:
50
51 ```text
78 -portal expose localhost:25565 --name minecraft --tcp
52 +portal expose --name paid-app \
53 + --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
54 + --http-route /=http://127.0.0.1:5173 \
55 + --x402-pay-to 0x...
56 ```
57
81 -The relay allocates a TCP port from its configured port range and bridges raw
82 -TCP to the local target. This path does not add TLS; use application-level
83 -encryption when the protocol needs confidentiality.
58 +Routed HTTP serves `/x402/client.js` and `/x402/prepare` on the tunnel origin so
59 +an upstream frontend can run the same in-page Sui wallet payment flow as the
60 +standalone payment app. The tunnel still verifies and settles payment before
61 +proxying the paid route.
62
85 -Use UDP mode when the service needs a public UDP port:
63 +Raw TCP and UDP:
64
65 ```text
66 +portal expose localhost:25565 --name minecraft --tcp
67 portal expose localhost:8080 --udp --udp-addr localhost:19132 --name game
68 ```
69
91 -The primary target still receives stream traffic. UDP datagrams are forwarded to
92 -`--udp-addr`; when omitted, UDP uses the primary target.
93 -
94 -## Relay And SEO Boundaries
95 -
96 -The relay cannot safely inject HTTP headers, `robots.txt`, `noindex`, or content
97 -policy into the default passthrough stream path. It does not own the HTTP
98 -response body, and it is not supposed to terminate tenant TLS.
99 -
100 -If a relay is used as a public multi-tenant service, do not put arbitrary user
101 -tunnels under a brand domain that also carries first-party SEO value. Use a
102 -separate tunnel domain for shared wildcard leases, and keep brand, docs, admin,
103 -and product pages on first-party hosts.
104 -
105 -Routed HTTP mode can enforce HTTP policy only inside cooperating tunnel
106 -processes. It is useful for product features, but it is not a substitute for
107 -domain separation because users can still choose the default passthrough path.
108 -
70 ## Commands
71
111 -### `portal expose [flags] <target>`
112 -
113 -Expose one local target through the default stream path.
114 -
72 ```text
116 -portal expose 3000
117 -portal expose localhost:8080 --name myapp
118 -portal expose http://127.0.0.1:8080 --name local-http
73 +portal expose [flags] <target>
74 +portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]" [...]
75 +portal list [flags]
76 +portal agent run [flags]
77 +portal agent dashboard [flags]
78 +portal agent stop [flags]
79 +portal agent restart [flags]
80 +portal update
81 +portal version
82 ```
83
121 -`<target>` accepts:
122 -
123 -- a bare port, such as `3000`
124 -- a `host:port`
125 -- an `http://host:port` or `https://host:port` URL
126 -
127 -Bare ports resolve to `127.0.0.1:<port>`. URL inputs are accepted for address
128 -parsing only; paths, queries, and fragments are not supported.
129 -
130 -Instead of `<target>`, repeat `--http-route PATH=UPSTREAM` to run routed HTTP
131 -mode:
132 -
133 -```text
134 -portal expose --name myapp \
135 - --http-route /api=http://127.0.0.1:3001 \
136 - --http-route /=http://127.0.0.1:5173
137 -```
138 -
139 -Route matching is longest-prefix-first. A route like
140 -`/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before
141 -proxying to the upstream.
142 -
143 -Routed HTTP mode automatically:
144 -
145 -- forwards `X-Forwarded-*`
146 -- rewrites matching upstream `Location` redirects back to the public route path
147 -- strips loopback cookie domains
148 -- remaps cookie paths to the mounted route prefix
149 -
150 -Mode constraints:
151 -
152 -- `<target>` cannot be combined with `--http-route`.
153 -- `--http-route` cannot be combined with `--udp`.
154 -- Multi-hop currently supports only the default SNI TLS stream transport.
155 -- `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
156 -- `--x402-amount` applies only to routed HTTP prefixes and requires a
157 - tunnel-owned `--x402-pay-to`.
158 -
159 -Common flags:
84 +Common `portal expose` flags:
85
86 ```text
87 --name Public hostname prefix; auto-generated when omitted
88 --relays Additional relay API URLs, comma-separated
89 --discovery Include registry relays and relay discovery expansion
165 ---max-active-relays Maximum auto-selected relays; explicit relays are always included
90 +--max-active-relays Maximum auto-selected relays
91 --multi-hop Ordered multi-hop relay API URLs, comma-separated
92 --multi-hop-depth Automatically select one multi-hop route with this hop count
168 ---ban-mitm Ban relay when the TLS self-probe detects termination
93 +--ban-mitm Ban relay when the MITM self-probe detects termination
94 --identity-path Identity JSON file path; created automatically when missing
95 --identity-json Identity JSON payload; overrides --identity-path when set
96 --description Service description metadata
@@ -173,44 +98,17 @@ Common flags:
98 --thumbnail Service thumbnail URL metadata
99 --owner Service owner metadata
100 --hide Hide service from relay listing screens
101 +--http-route HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form
102 --x402-pay-to Sui USDC payment recipient address for this tunnel
177 ---x402-amount Sui USDC x402 amount mapping in [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT form; repeatable
178 ---http-route HTTP route mapping in PATH=UPSTREAM form; repeatable
103 --tcp Request a dedicated raw TCP port on the relay
180 ---udp Enable public UDP relay in addition to the default stream path
181 ---udp-addr Local UDP target; defaults to the primary target when --udp is enabled
104 +--udp Enable public UDP relay
105 +--udp-addr Local UDP target
106 --metrics-addr Optional host:port for Prometheus /metrics
107 ```
108
185 -Custom relay and metadata example:
186 -
187 -```text
188 -portal expose localhost:8080 \
189 - --name myapp \
190 - --identity-path ~/.config/portal/myapp.identity.json \
191 - --relays https://portal.example.com \
192 - --discovery=false \
193 - --description "Service description" \
194 - --tags tag1,tag2 \
195 - --thumbnail https://example.com/thumb.png \
196 - --owner "Portal Operator"
197 -```
198 -
199 -### `portal list [flags]`
200 -
201 -Print the relay URLs that the CLI will use for the current invocation.
202 -
203 -```text
204 -portal list
205 -portal list --relays https://portal.example.com --default-relays=false
206 -```
207 -
208 -`portal list` resolves the registry seed list plus explicit relays. Unlike
209 -`portal expose`, it does not run the runtime relay discovery expansion loop.
210 -
211 -### `portal agent run [flags]`
109 +## Agent
110
213 -Run Portal as a managed long-lived tunnel agent.
111 +Use the agent for durable multi-tunnel operation from one config file:
112
113 ```text
114 portal agent run
@@ -219,120 +117,28 @@ portal agent stop
117 portal agent restart
118 ```
119
222 -The agent service owns multiple tunnel definitions from one config file. The
223 -local control API binds to loopback and is authenticated with a token stored in
224 -the agent state directory.
120 +The dashboard can edit basic tunnel settings, relays, and multi-hop routes. Add
121 +Tunnel opens a small form for name, target or HTTP routes, relays, discovery,
122 +and max active relays. After creation, routed HTTP paths, route-level x402
123 +amounts, and discovery mode are read-only in the Settings pane. Edit
124 +`http_routes`, `x402_pay_to`, or `discovery` in TOML, then restart the agent or
125 +tunnel to change them.
126
226 -For the full agent workflow, control API, dashboard behavior, and wallet status
227 -auth details, see [Portal Agent](../../docs/src/routes/portal-agent/+page.md)
228 -and [Wallet and ENS](../../docs/src/routes/wallet-and-ens/+page.md).
127 +## Constraints
128
230 -Useful commands:
231 -
232 -- `portal agent run` reads the platform default config path, installs or updates
233 - the OS service, starts it in the background, and exits after the agent is ready.
234 -- `portal agent run --config config.toml --foreground` runs the agent in the
235 - current terminal and opens the dashboard when the terminal is interactive.
236 -- `portal agent dashboard` attaches to a running agent and opens the local TUI
237 - for tunnels, relays, multi-hop routes, and editable tunnel settings.
238 -- `portal agent stop` asks the local agent to shut down, then disables or stops
239 - the OS service so intentional shutdown is not immediately restarted.
240 -- `portal agent restart` stops the running agent if present, installs or updates
241 - the service from the existing config, and starts it again.
242 -
243 -`portal agent run`, `stop`, and `restart` require an existing config file.
244 -`portal agent dashboard` can attach with only the default state directory or an
245 -explicit `--state-dir`.
246 -
247 -Default paths:
248 -
249 -| OS | Config | Default identity |
250 -|----|--------|------------------|
251 -| Linux user | `$XDG_CONFIG_HOME/portal-tunnel/agent/config.toml` or `~/.config/portal-tunnel/agent/config.toml` | `$XDG_DATA_HOME/portal-tunnel/agent/identity.json` or `~/.local/share/portal-tunnel/agent/identity.json` |
252 -| Linux root | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
253 -| macOS user | `~/Library/Application Support/Portal Tunnel/Agent/config.toml` | `~/Library/Application Support/Portal Tunnel/Agent/identity.json` |
254 -| macOS root | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
255 -| Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
256 -
257 -Example `config.toml`:
258 -
259 -```toml
260 -[agent]
261 -control_addr = "127.0.0.1:4018"
262 -service_name = "portal-agent"
263 -
264 -[[tunnels]]
265 -id = "web"
266 -name = "myapp"
267 -target = "127.0.0.1:3000"
268 -relays = ["https://portal.example.com"]
269 -discovery = false
270 -description = "Managed web tunnel"
271 -tags = ["web"]
272 -
273 -[[tunnels]]
274 -id = "frontend"
275 -name = "myapp-http"
276 -relays = ["https://portal.example.com"]
277 -discovery = false
278 -x402_pay_to = "0x..."
279 -http_routes = [
280 - { prefix = "/api", upstream = "http://127.0.0.1:3001", methods = ["GET"], amount = "100000" },
281 - { prefix = "/", upstream = "http://127.0.0.1:5173" },
282 -]
283 -```
284 -
285 -## Install Behavior
286 -
287 -- `install.sh` installs the downloaded binary as `portal` and adds the install
288 - directory to the user's shell profile when it is not already on `PATH`.
289 -- `install.ps1` installs `portal.exe` for the current Windows user and updates
290 - the user `PATH`.
291 -- The installer does not write a config file.
292 -- `portal expose 3000` works after install because discovery is enabled by
293 - default.
294 -- To target only a specific relay, use
295 - `--relays https://portal.example.com --discovery=false`.
296 -
297 -## Operational Notes
298 -
299 -- `portal expose` loads or creates the signing identity at `identity.json` by
300 - default.
301 -- Reusing the same `--identity-path` keeps the same tunnel address across runs.
302 -- Use different `--identity-path` values when you want separate local identities.
303 -- Relay publishes each default stream service at `<name>.<portal-root-host>`.
304 -- Multiple relay URLs are registered independently. Each relay gets its own
305 - lease registration and public URL.
306 -- The tunnel consumes one aggregate SDK listener; the CLI does not run per-relay
307 - listener loops itself.
308 -- Relay startup and reconnect failures are retried independently in the
309 - background. One unhealthy relay does not stop healthy relays from serving.
310 -- The tunnel starts once relay URLs pass local validation. Remote compatibility
311 - checks, lease registration, and reconnects continue in the background until
312 - each relay becomes ready.
313 -- With discovery enabled, the tunnel uses the public registry as discovery seed
314 - input and can expand through relay discovery.
315 -- Explicit `--relays` values are always included separately from the
316 - auto-selected relay pool.
317 -- With `--discovery=false`, only explicit relay URLs are used.
318 -- Published public URLs appear only for relays that register successfully.
319 -- Explicit relay listeners retry indefinitely. Auto-selected discovery relays
320 - are dropped from the active set after their retry budget is exhausted.
321 -- Tenant TLS is provisioned automatically through the relay keyless signer. The
322 - SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
323 -- `portal expose` logs MITM self-probe failures by default. Use `--ban-mitm`
324 - when suspected TLS termination should ban the relay automatically.
325 -- When the local stream target is unreachable, the tunnel returns an HTTP 503
326 - page to browser-style clients.
327 -- `--tcp` requires the relay to have TCP port transport enabled and a valid
328 - `MIN_PORT`/`MAX_PORT` range.
329 -- `--udp` requires the relay to have UDP transport enabled and a valid
330 - `MIN_PORT`/`MAX_PORT` range.
129 +- A positional `<target>` cannot be combined with `--http-route`.
130 +- `--http-route` cannot be combined with `--udp`.
131 +- Route payment amounts are USDC values such as `0.01`, are part of
132 + `--http-route`, and require `--x402-pay-to`.
133 +- `--multi-hop` cannot be combined with `--multi-hop-depth`.
134 +- Multi-hop currently supports only the default SNI TLS stream transport.
135 +- `--tcp` and `--udp` require matching relay transport support.
136
332 -## Compatibility Notes
137 +## More Docs
138
334 -- Use `portal expose ...` explicitly; bare `portal [flags]` is not accepted.
335 -- Runtime `APP_*`, `RELAYS`, and `DEFAULT_RELAYS` environment variable fallbacks
336 - are not used.
337 -- Pass either a positional local target or repeat `--http-route`; do not use
338 - both in the same tunnel.
139 +- [CLI Reference](../../docs/src/routes/cli-reference/+page.md)
140 +- [Concepts](../../docs/src/routes/concepts/+page.md)
141 +- [Configuration Reference](../../docs/src/routes/configuration/+page.md)
142 +- [Portal Agent](../../docs/src/routes/portal-agent/+page.md)
143 +- [Self Hosting](../../docs/src/routes/self-hosting/+page.md)
144 +- [Wallet and ENS](../../docs/src/routes/wallet-and-ens/+page.md)
cmd/portal-tunnel/agent/dashboard.go
+463 -62
@@ -42,6 +42,7 @@ const (
42 agentDashboardActionClearHop
43 agentDashboardActionApplySettings
44 agentDashboardActionFocusSettingsField
45 + agentDashboardActionFocusAddTunnelField
46 agentDashboardActionOpenTunnelURL
47 )
48
@@ -55,6 +56,17 @@ const (
56 agentDashboardPaneCount
57 )
58
59 +const (
60 + agentDashboardAddFieldName = iota
61 + agentDashboardAddFieldTarget
62 + agentDashboardAddFieldHTTPRoutes
63 + agentDashboardAddFieldX402PayTo
64 + agentDashboardAddFieldRelays
65 + agentDashboardAddFieldDiscovery
66 + agentDashboardAddFieldMaxRelays
67 + agentDashboardAddFieldCount
68 +)
69 +
70 const (
71 agentDashboardSettingsFieldMaxActiveRelays = iota
72 agentDashboardSettingsFieldDescription
@@ -87,8 +99,15 @@ type agentDashboardModel struct {
99 routeDraft []string
100 draftTunnelID string
101
90 - addingTunnel bool
91 - input textinput.Model
102 + addingTunnel bool
103 + addFocus int
104 + addName textinput.Model
105 + addTarget textinput.Model
106 + addHTTPRoutes textinput.Model
107 + addX402PayTo textinput.Model
108 + addRelays textinput.Model
109 + addDiscovery textinput.Model
110 + addMaxRelays textinput.Model
111
112 settingsEditTunnelID string
113 settingsFocus int
@@ -150,15 +169,16 @@ var (
169 )
170
171 func RunDashboard(configPath, stateDir string) error {
153 - input := newAgentDashboardTextInput()
154 - input.Prompt = "Name port: "
155 - input.Placeholder = "myname 3000"
156 - input.Width = agentDashboardTunnelInputMaxWidth
157 -
172 model := agentDashboardModel{
173 configPath: configPath,
174 stateDir: stateDir,
161 - input: input,
175 + addName: newAgentDashboardInlineInput("myapp"),
176 + addTarget: newAgentDashboardInlineInput("3000"),
177 + addHTTPRoutes: newAgentDashboardInlineInput("/paid=3001 GET:0.01; /=5173"),
178 + addX402PayTo: newAgentDashboardInlineInput("0x..."),
179 + addRelays: newAgentDashboardInlineInput("https://portal.example.com"),
180 + addDiscovery: newAgentDashboardInlineInput("true"),
181 + addMaxRelays: newAgentDashboardInlineInput("3"),
182 settingsMaxRelays: newAgentDashboardInlineInput("3"),
183 metadataDescription: newAgentDashboardInlineInput("description"),
184 metadataTags: newAgentDashboardInlineInput("api,staging"),
@@ -166,6 +186,7 @@ func RunDashboard(configPath, stateDir string) error {
186 metadataThumbnail: newAgentDashboardInlineInput("https://..."),
187 metadataHide: newAgentDashboardInlineInput("true or false"),
188 }
189 + model.resetAddTunnelForm()
190 model.resizeInputs(0)
191
192 _, err := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
@@ -235,8 +256,6 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
256 if m.activePane == agentDashboardPaneTunnels {
257 if m.addingTunnel {
258 m.cancelTunnelInput()
238 - } else {
239 - m.input.Reset()
259 }
260 } else {
261 m.setActivePane(agentDashboardPaneTunnels)
@@ -266,6 +285,27 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
285 }
286
287 func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
288 + if m.addingTunnel {
289 + switch msg.String() {
290 + case "tab", "down":
291 + m.focusAddTunnelField(m.addFocus + 1)
292 + return m, nil
293 + case "shift+tab", "up":
294 + m.focusAddTunnelField(m.addFocus - 1)
295 + return m, nil
296 + case "enter":
297 + return m.addTunnelFromInput()
298 + }
299 +
300 + input := m.focusedAddTunnelInput()
301 + if input == nil {
302 + return m, nil
303 + }
304 + var cmd tea.Cmd
305 + *input, cmd = input.Update(msg)
306 + return m, cmd
307 + }
308 +
309 switch msg.String() {
310 case "up":
311 m.selectTunnelOffset(-1)
@@ -274,20 +314,9 @@ func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cm
314 m.selectTunnelOffset(1)
315 return m, nil
316 case "delete":
277 - if !m.addingTunnel {
278 - return m.deleteTunnel("")
279 - }
280 - case "enter":
281 - if m.addingTunnel {
282 - return m.addTunnelFromInput()
283 - }
317 + return m.deleteTunnel("")
318 }
285 - if !m.addingTunnel {
286 - return m, nil
287 - }
288 - var cmd tea.Cmd
289 - m.input, cmd = m.input.Update(msg)
290 - return m, cmd
319 + return m, nil
320 }
321
322 func (m agentDashboardModel) updateRelayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -361,6 +390,15 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
390 m.focusSettingsField(region.field)
391 return m, nil
392 }
393 + if region.action == agentDashboardActionFocusAddTunnelField {
394 + m.setActivePane(agentDashboardPaneTunnels)
395 + if !m.addingTunnel {
396 + m.addingTunnel = true
397 + m.resetAddTunnelForm()
398 + }
399 + m.focusAddTunnelField(region.field)
400 + return m, nil
401 + }
402 return m.runAction(region.action, region.tunnel, region.relay)
403 }
404 }
@@ -479,12 +517,12 @@ func (m *agentDashboardModel) setActivePane(pane agentDashboardPane) {
517 pane = 0
518 }
519 m.activePane = pane
482 - m.input.Blur()
520 + m.blurAddTunnelInputs()
521 m.blurSettingsInputs()
522 switch pane {
523 case agentDashboardPaneTunnels:
524 if m.addingTunnel {
487 - _ = m.input.Focus()
525 + m.focusAddTunnelField(m.addFocus)
526 }
527 case agentDashboardPaneSettings:
528 m.ensureSelectedSettingsDraft()
@@ -679,6 +717,75 @@ func agentDashboardRelayKey(tunnelID, relayURL string) string {
717 return tunnelID + "\x00" + relayURL
718 }
719
720 +func (m *agentDashboardModel) focusAddTunnelField(field int) {
721 + fieldCount := agentDashboardAddFieldCount
722 + if field < 0 {
723 + field = fieldCount - 1
724 + }
725 + if field >= fieldCount {
726 + field = 0
727 + }
728 + m.blurSettingsInputs()
729 + m.addFocus = field
730 + m.blurAddTunnelInputs()
731 + if input := m.focusedAddTunnelInput(); input != nil {
732 + _ = input.Focus()
733 + }
734 +}
735 +
736 +func (m *agentDashboardModel) focusedAddTunnelInput() *textinput.Model {
737 + switch m.addFocus {
738 + case agentDashboardAddFieldName:
739 + return &m.addName
740 + case agentDashboardAddFieldTarget:
741 + return &m.addTarget
742 + case agentDashboardAddFieldHTTPRoutes:
743 + return &m.addHTTPRoutes
744 + case agentDashboardAddFieldX402PayTo:
745 + return &m.addX402PayTo
746 + case agentDashboardAddFieldRelays:
747 + return &m.addRelays
748 + case agentDashboardAddFieldDiscovery:
749 + return &m.addDiscovery
750 + case agentDashboardAddFieldMaxRelays:
751 + return &m.addMaxRelays
752 + default:
753 + return nil
754 + }
755 +}
756 +
757 +func (m *agentDashboardModel) blurAddTunnelInputs() {
758 + for _, input := range []*textinput.Model{
759 + &m.addName,
760 + &m.addTarget,
761 + &m.addHTTPRoutes,
762 + &m.addX402PayTo,
763 + &m.addRelays,
764 + &m.addDiscovery,
765 + &m.addMaxRelays,
766 + } {
767 + input.Blur()
768 + }
769 +}
770 +
771 +func (m *agentDashboardModel) resetAddTunnelForm() {
772 + for _, input := range []*textinput.Model{
773 + &m.addName,
774 + &m.addTarget,
775 + &m.addHTTPRoutes,
776 + &m.addX402PayTo,
777 + &m.addRelays,
778 + } {
779 + input.Reset()
780 + }
781 + m.addDiscovery.SetValue("true")
782 + m.addMaxRelays.SetValue("3")
783 + m.addDiscovery.CursorEnd()
784 + m.addMaxRelays.CursorEnd()
785 + m.addFocus = agentDashboardAddFieldName
786 + m.blurAddTunnelInputs()
787 +}
788 +
789 func (m agentDashboardModel) updateSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
790 switch msg.String() {
791 case "tab", "down":
@@ -708,7 +815,7 @@ func (m *agentDashboardModel) focusSettingsField(field int) {
815 if field >= fieldCount {
816 field = 0
817 }
711 - m.input.Blur()
818 + m.blurAddTunnelInputs()
819 m.settingsFocus = field
820 for _, input := range []*textinput.Model{
821 &m.settingsMaxRelays,
@@ -771,7 +878,7 @@ func (m *agentDashboardModel) clearSettingsDraft() {
878 }
879 m.blurSettingsInputs()
880 if m.activePane == agentDashboardPaneTunnels && m.addingTunnel {
774 - _ = m.input.Focus()
881 + m.focusAddTunnelField(m.addFocus)
882 }
883 }
884
@@ -780,11 +887,15 @@ func (m *agentDashboardModel) resizeInputs(width int) {
887 width = 88
888 }
889 contentWidth, _, _ := agentDashboardColumnWidths(width)
783 - availableWidth := contentWidth - lipgloss.Width(m.input.Prompt)
784 - m.input.Width = max(1, min(agentDashboardTunnelInputMaxWidth, availableWidth))
785 -
890 settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, contentWidth-13))
891 for _, input := range []*textinput.Model{
892 + &m.addName,
893 + &m.addTarget,
894 + &m.addHTTPRoutes,
895 + &m.addX402PayTo,
896 + &m.addRelays,
897 + &m.addDiscovery,
898 + &m.addMaxRelays,
899 &m.settingsMaxRelays,
900 &m.metadataDescription,
901 &m.metadataTags,
@@ -829,46 +940,196 @@ func (m *agentDashboardModel) loadSettingsDraft(tunnel types.AgentTunnelStatus)
940 }
941
942 func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
832 - value := strings.TrimSpace(m.input.Value())
833 - if value == "" {
834 - m.addingTunnel = true
835 - _ = m.input.Focus()
836 - return m, nil
837 - }
838 - fields := strings.Fields(value)
839 - if len(fields) < 2 {
840 - m.err = fmt.Errorf("use: name port")
841 - return m, nil
842 - }
843 - name := strings.Join(fields[:len(fields)-1], " ")
844 - if agentTunnelID(name) == "" {
845 - m.err = fmt.Errorf("tunnel name is required")
846 - return m, nil
847 - }
848 - targetInput := fields[len(fields)-1]
849 - target, err := utils.NormalizeLoopbackTarget(targetInput)
850 - if err != nil || target == "" {
851 - m.err = fmt.Errorf("invalid target %q", targetInput)
943 + req, err := m.addTunnelRequest()
944 + if err != nil {
945 + m.err = err
946 return m, nil
947 }
948
949 m.err = nil
856 - m.input.Reset()
950 m.addingTunnel = false
858 - m.input.Blur()
951 + m.resetAddTunnelForm()
952 return m, agentDashboardRun(func(ctx context.Context) error {
860 - return AddTunnel(ctx, m.stateDir, types.AgentTunnelRequest{
861 - Name: name,
862 - TargetAddr: target,
863 - })
953 + return AddTunnel(ctx, m.stateDir, req)
954 })
955 }
956
957 +func (m agentDashboardModel) addTunnelRequest() (types.AgentTunnelRequest, error) {
958 + name := strings.TrimSpace(m.addName.Value())
959 + if agentTunnelID(name) == "" {
960 + return types.AgentTunnelRequest{}, fmt.Errorf("tunnel name is required")
961 + }
962 +
963 + targetInput := strings.TrimSpace(m.addTarget.Value())
964 + routesInput := strings.TrimSpace(m.addHTTPRoutes.Value())
965 + if targetInput != "" && routesInput != "" {
966 + return types.AgentTunnelRequest{}, fmt.Errorf("target cannot be combined with routes")
967 + }
968 + if targetInput == "" && routesInput == "" {
969 + return types.AgentTunnelRequest{}, fmt.Errorf("target or routes is required")
970 + }
971 +
972 + var target string
973 + if targetInput != "" {
974 + var err error
975 + target, err = utils.NormalizeLoopbackTarget(targetInput)
976 + if err != nil || target == "" {
977 + return types.AgentTunnelRequest{}, fmt.Errorf("invalid target %q", targetInput)
978 + }
979 + }
980 +
981 + routes, err := agentDashboardParseAddHTTPRoutes(routesInput)
982 + if err != nil {
983 + return types.AgentTunnelRequest{}, err
984 + }
985 + payTo := strings.TrimSpace(m.addX402PayTo.Value())
986 + hasPaidRoute := false
987 + for _, route := range routes {
988 + if strings.TrimSpace(route.Amount) != "" {
989 + hasPaidRoute = true
990 + break
991 + }
992 + }
993 + if hasPaidRoute && payTo == "" {
994 + return types.AgentTunnelRequest{}, fmt.Errorf("paid routes require X402 Pay To")
995 + }
996 + if len(routes) == 0 && payTo != "" {
997 + return types.AgentTunnelRequest{}, fmt.Errorf("X402 Pay To requires routes")
998 + }
999 +
1000 + discoveryRaw := strings.TrimSpace(m.addDiscovery.Value())
1001 + if discoveryRaw == "" {
1002 + discoveryRaw = "true"
1003 + }
1004 + discovery, err := strconv.ParseBool(discoveryRaw)
1005 + if err != nil {
1006 + return types.AgentTunnelRequest{}, fmt.Errorf("discovery must be true or false")
1007 + }
1008 +
1009 + maxRelaysRaw := strings.TrimSpace(m.addMaxRelays.Value())
1010 + if maxRelaysRaw == "" {
1011 + maxRelaysRaw = "3"
1012 + }
1013 + maxRelays, err := strconv.Atoi(maxRelaysRaw)
1014 + if err != nil || maxRelays <= 0 {
1015 + return types.AgentTunnelRequest{}, fmt.Errorf("max relays must be a positive integer")
1016 + }
1017 +
1018 + return types.AgentTunnelRequest{
1019 + Name: name,
1020 + TargetAddr: target,
1021 + HTTPRoutes: routes,
1022 + RelayURLs: utils.SplitCSV(m.addRelays.Value()),
1023 + Discovery: &discovery,
1024 + MaxActiveRelays: maxRelays,
1025 + X402PayTo: payTo,
1026 + }, nil
1027 +}
1028 +
1029 +func agentDashboardParseAddHTTPRoutes(value string) ([]types.AgentHTTPRoute, error) {
1030 + value = strings.TrimSpace(value)
1031 + if value == "" {
1032 + return nil, nil
1033 + }
1034 + var routes []types.AgentHTTPRoute
1035 + seen := make(map[string]struct{})
1036 +
1037 + for _, rawSegment := range strings.Split(value, ";") {
1038 + segment := strings.TrimSpace(rawSegment)
1039 + if segment == "" {
1040 + continue
1041 + }
1042 + key, rest, ok := strings.Cut(segment, "=")
1043 + if !ok {
1044 + return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1045 + }
1046 + key = strings.TrimSpace(key)
1047 + rest = strings.TrimSpace(rest)
1048 + if strings.EqualFold(key, "payto") || strings.EqualFold(key, "x402_pay_to") {
1049 + return nil, fmt.Errorf("use the X402 Pay To field instead of %q in routes", key)
1050 + }
1051 + if key == "" {
1052 + return nil, fmt.Errorf("route path is required")
1053 + }
1054 + if !strings.HasPrefix(key, "/") {
1055 + return nil, fmt.Errorf("route path %q must start with /", key)
1056 + }
1057 +
1058 + parts := strings.Fields(rest)
1059 + if len(parts) == 0 || len(parts) > 2 {
1060 + return nil, fmt.Errorf("route %q must be PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", segment)
1061 + }
1062 + if parts[0] == "" {
1063 + return nil, fmt.Errorf("route %q upstream is required", key)
1064 + }
1065 +
1066 + prefix := utils.NormalizeURLPath(key)
1067 + if _, ok := seen[prefix]; ok {
1068 + return nil, fmt.Errorf("duplicate route path %q", prefix)
1069 + }
1070 + seen[prefix] = struct{}{}
1071 +
1072 + route := types.AgentHTTPRoute{
1073 + Prefix: prefix,
1074 + Upstream: parts[0],
1075 + }
1076 + if len(parts) == 2 {
1077 + methods, amount, err := agentDashboardParseAddRoutePayment(parts[1])
1078 + if err != nil {
1079 + return nil, fmt.Errorf("route %q: %w", prefix, err)
1080 + }
1081 + route.Methods = methods
1082 + route.Amount = amount
1083 + }
1084 + routes = append(routes, route)
1085 + }
1086 +
1087 + if len(routes) == 0 {
1088 + return nil, fmt.Errorf("at least one http route is required")
1089 + }
1090 + return routes, nil
1091 +}
1092 +
1093 +func agentDashboardParseAddRoutePayment(value string) ([]string, string, error) {
1094 + value = strings.TrimSpace(value)
1095 + if value == "" {
1096 + return nil, "", fmt.Errorf("payment amount is required")
1097 + }
1098 + methodPart, amount, hasMethods := strings.Cut(value, ":")
1099 + if !hasMethods {
1100 + amount = value
1101 + methodPart = ""
1102 + }
1103 + amount = strings.TrimSpace(amount)
1104 + if amount == "" {
1105 + return nil, "", fmt.Errorf("payment amount is required")
1106 + }
1107 + if !hasMethods {
1108 + return nil, amount, nil
1109 + }
1110 +
1111 + methods := []string(nil)
1112 + for _, rawMethod := range strings.Split(methodPart, ",") {
1113 + method := strings.ToUpper(strings.TrimSpace(rawMethod))
1114 + if method == "" {
1115 + return nil, "", fmt.Errorf("payment method is required")
1116 + }
1117 + if !slices.Contains(methods, method) {
1118 + methods = append(methods, method)
1119 + }
1120 + }
1121 + if len(methods) == 0 {
1122 + return nil, "", fmt.Errorf("payment methods are required before ':'")
1123 + }
1124 + return methods, amount, nil
1125 +}
1126 +
1127 func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
1128 if !m.addingTunnel {
1129 m.addingTunnel = true
1130 + m.resetAddTunnelForm()
1131 m.setActivePane(agentDashboardPaneTunnels)
871 - _ = m.input.Focus()
1132 + m.focusAddTunnelField(agentDashboardAddFieldName)
1133 return m, nil
1134 }
1135 return m.addTunnelFromInput()
@@ -876,8 +1137,7 @@ func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
1137
1138 func (m *agentDashboardModel) cancelTunnelInput() {
1139 m.addingTunnel = false
879 - m.input.Reset()
880 - m.input.Blur()
1140 + m.resetAddTunnelForm()
1141 }
1142
1143 func (m agentDashboardModel) applySettingsEdit() (tea.Model, tea.Cmd) {
@@ -1154,7 +1414,7 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1414 addDisabled := false
1415 if m.addingTunnel {
1416 addLabel = "Create"
1157 - addDisabled = strings.TrimSpace(m.input.Value()) == ""
1417 + addDisabled = strings.TrimSpace(m.addName.Value()) == ""
1418 }
1419 buttons := []agentDashboardButton{
1420 {label: addLabel, action: agentDashboardActionAddTunnel, disabled: addDisabled},
@@ -1167,7 +1427,7 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1427 )
1428 pane.addButtons(width, buttons...)
1429 if m.addingTunnel {
1170 - pane.addLine(m.input.View())
1430 + m.renderAddTunnelForm(&pane, width)
1431 }
1432 tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
1433 pane.addLine(agentDashboardHeaderStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
@@ -1199,6 +1459,25 @@ func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashbo
1459 return pane
1460 }
1461
1462 +func (m agentDashboardModel) renderAddTunnelForm(pane *agentDashboardView, width int) {
1463 + rows := []struct {
1464 + label string
1465 + input textinput.Model
1466 + field int
1467 + }{
1468 + {label: "Name", input: m.addName, field: agentDashboardAddFieldName},
1469 + {label: "Target", input: m.addTarget, field: agentDashboardAddFieldTarget},
1470 + {label: "Routes", input: m.addHTTPRoutes, field: agentDashboardAddFieldHTTPRoutes},
1471 + {label: "X402 Pay To", input: m.addX402PayTo, field: agentDashboardAddFieldX402PayTo},
1472 + {label: "Relays", input: m.addRelays, field: agentDashboardAddFieldRelays},
1473 + {label: "Discovery", input: m.addDiscovery, field: agentDashboardAddFieldDiscovery},
1474 + {label: "Max Relays", input: m.addMaxRelays, field: agentDashboardAddFieldMaxRelays},
1475 + }
1476 + for _, row := range rows {
1477 + pane.addAddTunnelInputRow(width, row.label, row.input, row.field, m.addFocus == row.field)
1478 + }
1479 +}
1480 +
1481 func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardView {
1482 var pane agentDashboardView
1483 tunnel, ok := m.selectedTunnelStatus()
@@ -1307,6 +1586,61 @@ func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, w
1586 }
1587 pane.addSettingsInputRow(width, row.label, row.input, row.field, m.settingsFocus == row.field)
1588 }
1589 +
1590 + if len(pane.lines)-startLine >= height {
1591 + return
1592 + }
1593 + pane.addMeta(width, 0, "Discovery", strconv.FormatBool(tunnel.Discovery))
1594 +
1595 + paidRouteCount := 0
1596 + for _, route := range tunnel.HTTPRoutes {
1597 + if strings.TrimSpace(route.Amount) != "" {
1598 + paidRouteCount++
1599 + }
1600 + }
1601 + payTo := strings.TrimSpace(tunnel.X402PayTo)
1602 + if payTo == "" && len(tunnel.HTTPRoutes) == 0 {
1603 + return
1604 + }
1605 + if len(pane.lines)-startLine >= height {
1606 + return
1607 + }
1608 + pane.addLine("")
1609 + if len(pane.lines)-startLine >= height {
1610 + return
1611 + }
1612 + pane.addStyled(width, agentDashboardLabelStyle, "Payments")
1613 + if payTo != "" {
1614 + if len(pane.lines)-startLine >= height {
1615 + return
1616 + }
1617 + pane.addMeta(width, 0, "Pay To", payTo)
1618 + }
1619 + if len(tunnel.HTTPRoutes) == 0 {
1620 + if len(pane.lines)-startLine >= height {
1621 + return
1622 + }
1623 + pane.addStyled(width, agentDashboardMutedStyle, "no routed HTTP paths")
1624 + return
1625 + }
1626 +
1627 + shown := 0
1628 + for _, route := range tunnel.HTTPRoutes {
1629 + if shown >= 4 {
1630 + break
1631 + }
1632 + if len(pane.lines)-startLine >= height {
1633 + return
1634 + }
1635 + pane.addText(width, agentDashboardHTTPRouteSummary(route))
1636 + shown++
1637 + }
1638 + if len(tunnel.HTTPRoutes) > shown && len(pane.lines)-startLine < height {
1639 + pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+%d more routes", len(tunnel.HTTPRoutes)-shown))
1640 + }
1641 + if paidRouteCount == 0 && len(pane.lines)-startLine < height {
1642 + pane.addStyled(width, agentDashboardMutedStyle, "no paid routes")
1643 + }
1644 }
1645
1646 func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
@@ -1508,6 +1842,26 @@ func (v *agentDashboardView) addSettingsInputRow(width int, label string, input
1842 })
1843 }
1844
1845 +func (v *agentDashboardView) addAddTunnelInputRow(width int, label string, input textinput.Model, field int, focused bool) {
1846 + if width <= 0 {
1847 + width = 1
1848 + }
1849 + labelStyle := agentDashboardMutedStyle
1850 + if focused {
1851 + labelStyle = agentDashboardInputStyle
1852 + }
1853 + labelText := agentDashboardCell(label+":", 12)
1854 + y := len(v.lines)
1855 + v.lines = append(v.lines, labelStyle.Render(labelText)+" "+input.View())
1856 + v.regions = append(v.regions, agentDashboardRegion{
1857 + x0: 0,
1858 + x1: width,
1859 + y: y,
1860 + action: agentDashboardActionFocusAddTunnelField,
1861 + field: field,
1862 + })
1863 +}
1864 +
1865 func (v *agentDashboardView) clip(height int) {
1866 if height <= 0 || len(v.lines) <= height {
1867 return
@@ -1644,6 +1998,33 @@ func relayDashboardConnected(tunnel types.AgentTunnelStatus, relay types.AgentRe
1998 return relay.PublicURL != "" || slices.Contains(tunnel.MultiHop, relay.RelayURL)
1999 }
2000
2001 +func agentDashboardHTTPRouteSummary(route types.AgentHTTPRoute) string {
2002 + prefix := strings.TrimSpace(route.Prefix)
2003 + if prefix == "" {
2004 + prefix = "-"
2005 + }
2006 + upstream := strings.TrimSpace(route.Upstream)
2007 + if upstream == "" {
2008 + upstream = "-"
2009 + }
2010 + if amount := strings.TrimSpace(route.Amount); amount != "" {
2011 + methods := "ALL"
2012 + if len(route.Methods) > 0 {
2013 + var normalized []string
2014 + for _, raw := range route.Methods {
2015 + if method := strings.ToUpper(strings.TrimSpace(raw)); method != "" {
2016 + normalized = append(normalized, method)
2017 + }
2018 + }
2019 + if len(normalized) > 0 {
2020 + methods = strings.Join(normalized, ",")
2021 + }
2022 + }
2023 + return fmt.Sprintf("%s -> %s (%s %s)", prefix, upstream, methods, amount)
2024 + }
2025 + return prefix + " -> " + upstream
2026 +}
2027 +
2028 func (m agentDashboardModel) settingsChanged(tunnel types.AgentTunnelStatus) bool {
2029 if m.settingsEditTunnelID != tunnel.ID {
2030 return false
@@ -1768,11 +2149,31 @@ func tunnelDashboardName(tunnel types.AgentTunnelStatus) string {
2149 return tunnel.ID
2150 }
2151
2152 +func tunnelDashboardTarget(tunnel types.AgentTunnelStatus) string {
2153 + if target := strings.TrimSpace(tunnel.TargetAddr); target != "" {
2154 + return target
2155 + }
2156 + if len(tunnel.HTTPRoutes) == 0 {
2157 + return "-"
2158 + }
2159 + paid := 0
2160 + for _, route := range tunnel.HTTPRoutes {
2161 + if strings.TrimSpace(route.Amount) != "" {
2162 + paid++
2163 + }
2164 + }
2165 + if paid == 0 {
2166 + return fmt.Sprintf("%d routes", len(tunnel.HTTPRoutes))
2167 + }
2168 + return fmt.Sprintf("%d routes, %d paid", len(tunnel.HTTPRoutes), paid)
2169 +}
2170 +
2171 func agentDashboardTunnelTableWidth(width int, tunnels []types.AgentTunnelStatus) int {
2172 tableWidth := 56
2173 for _, tunnel := range tunnels {
2174 nameWidth := max(lipgloss.Width(tunnelDashboardName(tunnel)), lipgloss.Width("TUNNEL"))
1775 - tableWidth = max(tableWidth, 11+1+22+1+nameWidth)
2175 + targetWidth := max(lipgloss.Width(tunnelDashboardTarget(tunnel)), lipgloss.Width("TARGET"))
2176 + tableWidth = max(tableWidth, 11+1+targetWidth+1+nameWidth)
2177 }
2178 return max(1, min(tableWidth, width))
2179 }
cmd/portal-tunnel/agent/manager.go
+55 -6
@@ -220,7 +220,19 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
220 return err
221 }
222 target := strings.TrimSpace(req.TargetAddr)
223 - if target == "" {
223 + httpRoutes := make([]HTTPRouteConfig, 0, len(req.HTTPRoutes))
224 + for _, route := range req.HTTPRoutes {
225 + httpRoutes = append(httpRoutes, HTTPRouteConfig{
226 + Prefix: strings.TrimSpace(route.Prefix),
227 + Upstream: strings.TrimSpace(route.Upstream),
228 + Methods: normalizeAgentHTTPRouteMethods(route.Methods),
229 + Amount: strings.TrimSpace(route.Amount),
230 + })
231 + }
232 + if target != "" && len(httpRoutes) > 0 {
233 + return errors.New("target cannot be combined with http_routes")
234 + }
235 + if target == "" && len(httpRoutes) == 0 {
236 target = defaultTargetAddr
237 }
238 if name == "" {
@@ -231,12 +243,21 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
243 return err
244 }
245 discovery := true
246 + if req.Discovery != nil {
247 + discovery = *req.Discovery
248 + }
249 + if req.MaxActiveRelays < 0 {
250 + return errors.New("max_active_relays cannot be negative")
251 + }
252 tunnelCfg := TunnelConfig{
235 - ID: id,
236 - Name: name,
237 - TargetAddr: target,
238 - RelayURLs: relayURLs,
239 - Discovery: &discovery,
253 + ID: id,
254 + Name: name,
255 + TargetAddr: target,
256 + HTTPRoutes: httpRoutes,
257 + RelayURLs: relayURLs,
258 + Discovery: &discovery,
259 + MaxActiveRelays: req.MaxActiveRelays,
260 + X402PayTo: strings.TrimSpace(req.X402PayTo),
261 }
262 if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
263 return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
@@ -263,6 +284,17 @@ func agentTunnelID(name string) string {
284 return strings.Trim(out.String(), "-")
285 }
286
287 +func normalizeAgentHTTPRouteMethods(methods []string) []string {
288 + out := make([]string, 0, len(methods))
289 + for _, raw := range methods {
290 + method := strings.ToUpper(strings.TrimSpace(raw))
291 + if method != "" && !slices.Contains(out, method) {
292 + out = append(out, method)
293 + }
294 + }
295 + return out
296 +}
297 +
298 func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error) error {
299 id = strings.TrimSpace(id)
300 if err := validateAgentPathComponent("tunnel id", id); err != nil {
@@ -565,6 +597,10 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
597 case running:
598 state = "starting"
599 }
600 + discovery := true
601 + if cfg.Discovery != nil {
602 + discovery = *cfg.Discovery
603 + }
604
605 status := types.AgentTunnelStatus{
606 ID: cfg.ID,
@@ -572,9 +608,22 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
608 State: state,
609 TargetAddr: cfg.TargetAddr,
610 LastError: lastError,
611 + Discovery: discovery,
612 MaxActiveRelays: cfg.MaxActiveRelays,
613 Metadata: metadataFromTunnelConfig(cfg),
614 MultiHop: append([]string(nil), cfg.MultiHop...),
615 + X402PayTo: strings.TrimSpace(cfg.X402PayTo),
616 + }
617 + if len(cfg.HTTPRoutes) > 0 {
618 + status.HTTPRoutes = make([]types.AgentHTTPRoute, 0, len(cfg.HTTPRoutes))
619 + for _, route := range cfg.HTTPRoutes {
620 + status.HTTPRoutes = append(status.HTTPRoutes, types.AgentHTTPRoute{
621 + Prefix: route.Prefix,
622 + Upstream: route.Upstream,
623 + Methods: append([]string(nil), route.Methods...),
624 + Amount: route.Amount,
625 + })
626 + }
627 }
628 if exposure == nil {
629 if strings.TrimSpace(runtime.Address) != "" {
cmd/portal-tunnel/main.go
+60 -63
@@ -60,7 +60,6 @@ type exposeFlags struct {
60 thumbnail string
61 hide bool
62 x402PayTo string
63 - x402Amounts []string
63 targetAddr string
64 httpRoutes []string
65 udp bool
@@ -90,8 +89,7 @@ func runExposeCommand(args []string) error {
89 utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
90 utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens")
91 utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui USDC payment recipient address for this tunnel")
93 - utils.RepeatedStringFlag(fs, &flags.x402Amounts, "x402-amount", "Sui USDC x402 amount mapping in [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT form; repeat for multiple HTTP routes")
94 - utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
92 + utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form; repeat to aggregate multiple local HTTP services behind one public URL")
93 utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
94 utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
95 utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
@@ -123,61 +121,17 @@ func runExposeCommand(args []string) error {
121 case len(httpRouteInputs) > 0 && flags.udp:
122 printExposeUsage(os.Stderr)
123 return errors.New("--udp cannot be combined with --http-route")
126 - case len(flags.x402Amounts) > 0 && len(httpRouteInputs) == 0:
127 - printExposeUsage(os.Stderr)
128 - return errors.New("--x402-amount requires --http-route")
129 - case len(flags.x402Amounts) > 0 && strings.TrimSpace(flags.x402PayTo) == "":
130 - printExposeUsage(os.Stderr)
131 - return errors.New("--x402-amount requires --x402-pay-to")
132 - }
133 -
134 - type x402AmountRule struct {
135 - methods []string
136 - amount string
137 - }
138 - x402Amounts := make(map[string]x402AmountRule, len(flags.x402Amounts))
139 - for _, raw := range flags.x402Amounts {
140 - prefix, amount, ok := strings.Cut(raw, "=")
141 - if !ok {
142 - return fmt.Errorf("--x402-amount %q: expected [METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT", raw)
143 - }
144 - prefix = strings.TrimSpace(prefix)
145 - methods := []string(nil)
146 - if !strings.HasPrefix(prefix, "/") {
147 - methodPart, pathPart, ok := strings.Cut(prefix, ":")
148 - if ok {
149 - for _, rawMethod := range strings.Split(methodPart, ",") {
150 - method := strings.ToUpper(strings.TrimSpace(rawMethod))
151 - if method == "" {
152 - return fmt.Errorf("--x402-amount %q: method is required", raw)
153 - }
154 - methods = append(methods, method)
155 - }
156 - prefix = strings.TrimSpace(pathPart)
157 - }
158 - }
159 - if prefix == "" {
160 - return fmt.Errorf("--x402-amount %q: path is required", raw)
161 - }
162 - if !strings.HasPrefix(prefix, "/") {
163 - return fmt.Errorf("--x402-amount %q: path must start with /", raw)
164 - }
165 - prefix = utils.NormalizeURLPath(prefix)
166 - amount = strings.TrimSpace(amount)
167 - if amount == "" {
168 - return fmt.Errorf("--x402-amount %q: amount is required", raw)
169 - }
170 - if _, exists := x402Amounts[prefix]; exists {
171 - return fmt.Errorf("--x402-amount path %q repeated", prefix)
172 - }
173 - x402Amounts[prefix] = x402AmountRule{methods: methods, amount: amount}
124 }
125
126 httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs))
127 for _, raw := range httpRouteInputs {
178 - prefix, upstream, ok := strings.Cut(raw, "=")
128 + fields := strings.Fields(raw)
129 + if len(fields) == 0 || len(fields) > 2 {
130 + return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw)
131 + }
132 + prefix, upstream, ok := strings.Cut(fields[0], "=")
133 if !ok {
180 - return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
134 + return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw)
135 }
136 prefix = strings.TrimSpace(prefix)
137 if prefix == "" {
@@ -190,21 +144,23 @@ func runExposeCommand(args []string) error {
144 if upstream == "" {
145 return fmt.Errorf("--http-route %q: upstream is required", raw)
146 }
193 - normalizedPrefix := utils.NormalizeURLPath(prefix)
147 route := sdk.HTTPRouteConfig{
148 Prefix: prefix,
149 Upstream: upstream,
150 }
198 - if payment, ok := x402Amounts[normalizedPrefix]; ok {
199 - route.Methods = payment.methods
200 - route.Amount = payment.amount
201 - delete(x402Amounts, normalizedPrefix)
151 + if len(fields) == 2 {
152 + methods, amount, err := parseHTTPRoutePayment(fields[1])
153 + if err != nil {
154 + return fmt.Errorf("--http-route %q: %w", raw, err)
155 + }
156 + if strings.TrimSpace(flags.x402PayTo) == "" {
157 + return fmt.Errorf("--http-route %q: payment amount requires --x402-pay-to", raw)
158 + }
159 + route.Methods = methods
160 + route.Amount = amount
161 }
162 httpRoutes = append(httpRoutes, route)
163 }
205 - for prefix := range x402Amounts {
206 - return fmt.Errorf("--x402-amount path %q has no matching --http-route", prefix)
207 - }
164
165 ctx, stop := utils.SignalContext()
166 defer stop()
@@ -258,6 +214,45 @@ func runExposeCommand(args []string) error {
214 return sdk.ProxyExposure(ctx, exposure)
215 }
216
217 +func parseHTTPRoutePayment(value string) ([]string, string, error) {
218 + value = strings.TrimSpace(value)
219 + if value == "" {
220 + return nil, "", errors.New("payment amount is required")
221 + }
222 + methodPart, amount, hasMethods := strings.Cut(value, ":")
223 + if !hasMethods {
224 + amount = value
225 + methodPart = ""
226 + }
227 + amount = strings.TrimSpace(amount)
228 + if amount == "" {
229 + return nil, "", errors.New("payment amount is required")
230 + }
231 + methods := []string(nil)
232 + if hasMethods {
233 + for _, rawMethod := range strings.Split(methodPart, ",") {
234 + method := strings.ToUpper(strings.TrimSpace(rawMethod))
235 + if method == "" {
236 + return nil, "", errors.New("payment method is required")
237 + }
238 + exists := false
239 + for _, existing := range methods {
240 + if existing == method {
241 + exists = true
242 + break
243 + }
244 + }
245 + if !exists {
246 + methods = append(methods, method)
247 + }
248 + }
249 + if len(methods) == 0 {
250 + return nil, "", errors.New("payment methods are required before ':'")
251 + }
252 + }
253 + return methods, amount, nil
254 +}
255 +
256 func runUpdateCommand(args []string) error {
257 var version string
258 fs := utils.NewFlagSet("update", printUpdateUsage)
@@ -347,7 +342,7 @@ func printRootUsage(w io.Writer) {
342 utils.WriteCommandUsage(w,
343 []string{
344 "portal expose [flags] <target>",
350 - "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
345 + "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]",
346 "portal agent run [flags]",
347 "portal agent dashboard [flags]",
348 "portal agent stop [flags]",
@@ -360,6 +355,7 @@ func printRootUsage(w io.Writer) {
355 "portal expose 3000",
356 "portal expose localhost:8080 --name my-app",
357 "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
358 + "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...",
359 "portal agent run",
360 "portal agent dashboard",
361 "portal agent stop",
@@ -377,12 +373,13 @@ func printExposeUsage(w io.Writer) {
373 utils.WriteCommandUsage(w,
374 []string{
375 "portal expose [flags] <target>",
380 - "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
376 + "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]",
377 },
378 []string{
379 "portal expose 3000",
380 "portal expose localhost:8080 --name my-app",
381 "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
382 + "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...",
383 "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
384 "portal expose 3000 --ban-mitm",
385 "portal expose 3000 --relays https://portal.example.com --discovery=false",
docker-compose.yml
+3 -3
@@ -81,8 +81,8 @@ services:
81 context: .
82 dockerfile: Dockerfile
83 stop_grace_period: 30s
84 - ports:
85 - - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
84 + # ports:
85 + # - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
86 # Uncomment for UDP backhaul, public UDP lease ports, and raw TCP lease ports as needed.
87 # - "443:443/udp"
88 # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
@@ -93,7 +93,7 @@ services:
93 # Public routing, discovery, and relay identity persistence
94 PORTAL_URL: ${PORTAL_URL:-https://localhost}
95 BOOTSTRAPS: ${BOOTSTRAPS:-}
96 - DISCOVERY: ${DISCOVERY:-true}
96 + DISCOVERY: ${DISCOVERY:-false}
97 IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
98
99 API_PORT: 4017
docs/src/routes/api-reference/+page.md
+5
@@ -122,6 +122,11 @@ Portal accepts only USDC gasless stablecoin address-balance payments.
122 `X402_PAY_TO` is the relay-owned payment recipient. Tunnel payment recipients
123 are local tunnel configuration and are not part of the relay lease API.
124
125 +Paid routed HTTP tunnels additionally expose `/x402/prepare` and
126 +`/x402/client.js` on the public tunnel origin. Those are tunnel-owned helper
127 +endpoints for app frontends, not relay API routes, and they do not use the
128 +`/api` prefix.
129 +
130 | Method | Path | Auth | Body | Response |
131 |--------|------|------|------|----------|
132 | `GET` | `/api/x402/supported` | None | none | x402 supported kinds |
docs/src/routes/cli-reference/+page.md
+32 -9
@@ -56,9 +56,11 @@ portal expose [flags] <target>
56 Or run routed HTTP mode:
57
58 ```bash
59 -portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]
59 +portal expose [flags] --http-route "PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]" [...]
60 ```
61
62 +The payment suffix is optional; omit it for free routes.
63 +
64 ### Target Formats
65
66 | Format | Example | Resolves to |
@@ -98,8 +100,7 @@ not supported.
100 | `--owner` | string | | Service owner metadata |
101 | `--hide` | bool | `false` | Hide service from relay listing screens |
102 | `--x402-pay-to` | string | | Sui USDC payment recipient address for this tunnel |
101 -| `--x402-amount` | string | | Sui USDC x402 amount mapping in `[METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
102 -| `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM` form; repeatable |
103 +| `--http-route` | string | | HTTP route mapping in `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]` form; repeatable; route amounts require `--x402-pay-to` |
104 | `--tcp` | bool | `false` | Request a dedicated raw TCP port on the relay |
105 | `--udp` | bool | `false` | Enable public UDP relay in addition to the default stream path |
106 | `--udp-addr` | string | | Local UDP target; defaults to the primary target when `--udp` is enabled |
@@ -112,8 +113,8 @@ not supported.
113 - Explicit `--multi-hop` cannot be combined with automatic `--multi-hop-depth`.
114 - Multi-hop currently supports only the default SNI TLS stream transport.
115 - `--tcp` and `--udp` require matching transport support on the relay.
115 -- `--x402-amount` applies only to routed HTTP prefixes and requires a
116 - tunnel-owned `--x402-pay-to`.
116 +- Route payment amounts are part of `--http-route` and require a tunnel-owned
117 + `--x402-pay-to`.
118
119 ### Examples
120
@@ -178,13 +179,35 @@ portal expose 3000 --ban-mitm
179 Publish a paid HTTP route:
180
181 ```bash
181 -portal expose --name myapp \
182 - --http-route /api=http://127.0.0.1:3001 \
182 +portal expose --name paid-app \
183 + --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
184 --http-route /=http://127.0.0.1:5173 \
184 - --x402-pay-to 0x... \
185 - --x402-amount /api=100000
185 + --x402-pay-to 0x...
186 +```
187 +
188 +The optional method list limits which methods require payment; without it, every
189 +method on that route prefix is paid.
190 +
191 +The routed HTTP handler also serves `/x402/client.js` and `/x402/prepare` on the
192 +public tunnel origin. Frontends served by one of the routes can use the shared
193 +browser client for an in-page Sui wallet flow:
194 +
195 +```js
196 +import { getSuiWallets, x402Fetch } from '/x402/client.js';
197 +
198 +const [wallet] = getSuiWallets({ network: 'sui:testnet' });
199 +const [account] = await wallet.accounts('sui:testnet');
200 +
201 +const response = await x402Fetch('/paid/photo', { method: 'GET' }, {
202 + wallet,
203 + account,
204 + network: 'sui:testnet',
205 +});
206 ```
207
208 +The frontend integration is optional. Requests without a valid `X-PAYMENT`
209 +header still receive x402 payment-required responses from the tunnel.
210 +
211 ## `portal list`
212
213 Print relay URLs resolved for the current invocation:
docs/src/routes/concepts/+page.md
+16
@@ -85,6 +85,22 @@ Because HTTP is parsed in the tunnel process, this is the right place for
85 cooperative HTTP policy such as response headers. It is not a relay-enforced
86 policy boundary.
87
88 +Paid routes are also owned by routed HTTP mode. Add `--x402-pay-to` and attach
89 +the amount to the HTTP route:
90 +
91 +```bash
92 +portal expose --name paid-app \
93 + --http-route "/paid=http://127.0.0.1:3001 GET:0.01" \
94 + --http-route /=http://127.0.0.1:5173 \
95 + --x402-pay-to 0x...
96 +```
97 +
98 +The tunnel serves `/x402/client.js` and `/x402/prepare` on the same public
99 +origin. A frontend mounted through the tunnel can import `/x402/client.js` and
100 +call `x402Fetch()` from its own UI, so the Sui wallet flow stays in the app
101 +instead of requiring a separate payment redirect. The tunnel still verifies and
102 +settles the payment before proxying the protected request.
103 +
104 ## Dedicated Raw TCP
105
106 Use raw TCP when clients need a public TCP port instead of a public HTTPS
docs/src/routes/configuration/+page.md
+10 -4
@@ -163,13 +163,12 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
163 | `--thumbnail` | | string | | Service thumbnail URL metadata |
164 | `--hide` | | bool | `false` | Hide service from relay listing screens |
165 | `--x402-pay-to` | | string | | Sui USDC payment recipient address for this tunnel |
166 -| `--x402-amount` | | string | | Sui USDC x402 amount mapping in `[METHOD[,METHOD...]:]PATH=ATOMIC_AMOUNT` form; repeatable; requires `--http-route` and `--x402-pay-to` |
166
167 ### Routing
168
169 | Flag | Env Var | Type | Default | Description |
170 |------|---------|------|---------|-------------|
172 -| `--http-route` | | string | | HTTP route mapping in `PATH=UPSTREAM` form; repeat to aggregate multiple local HTTP services behind one public URL |
171 +| `--http-route` | | string | | HTTP route mapping in `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]` form; repeat to aggregate multiple local HTTP services behind one public URL; route amounts require `--x402-pay-to` |
172
173 ### Transport
174
@@ -227,7 +226,7 @@ x402_pay_to = "0x..."
226 prefix = "/api"
227 upstream = "http://127.0.0.1:3001"
228 methods = ["GET"]
230 -amount = "100000"
229 +amount = "0.01"
230
231 [[tunnels.http_routes]]
232 prefix = "/"
@@ -263,8 +262,15 @@ Tunnel fields mirror `portal expose` flags:
262 | `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
263 | `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
264 | `x402_pay_to` | string | Tunnel-owned Sui USDC x402 payment recipient for paid HTTP routes |
266 -| `http_routes[].amount` | string | Optional Sui USDC x402 atomic amount for one HTTP route prefix; requires `x402_pay_to` |
265 +| `http_routes[].amount` | string | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix; requires `x402_pay_to` |
266 | `http_routes[].methods` | string array | Optional HTTP methods that require payment on that route; empty means every method |
267 +
268 +When any routed HTTP entry has `amount`, the tunnel also serves
269 +`/x402/client.js` and `/x402/prepare` on the public tunnel origin. Frontends
270 +served by another route in the same tunnel can import `/x402/client.js` and use
271 +`x402Fetch()` to run the same Sui wallet payment flow as the standalone payment
272 +app. Payment is still enforced by the tunnel on the paid route prefix.
273 +
274 For a task-oriented walkthrough, see [Portal Agent](/portal-agent).
275
276 ### `identity.json`
docs/src/routes/portal-agent/+page.md
+26 -7
@@ -76,13 +76,18 @@ x402_pay_to = "0x..."
76 prefix = "/api"
77 upstream = "http://127.0.0.1:3001"
78 methods = ["GET"]
79 -amount = "100000"
79 +amount = "0.01"
80
81 [[tunnels.http_routes]]
82 prefix = "/"
83 upstream = "http://127.0.0.1:5173"
84 ```
85
86 +If a route has `amount`, the tunnel serves `/x402/client.js` and
87 +`/x402/prepare` on the public tunnel origin. A frontend served by the `/` route
88 +can import the helper and call `x402Fetch()` from its own UI; the tunnel still
89 +verifies and settles payment before proxying the paid route.
90 +
91 Relative paths in the config are resolved from the config file directory.
92
93 ## Run The Agent
@@ -128,7 +133,7 @@ Dashboard panes:
133
134 | Pane | Purpose |
135 |------|---------|
131 -| Tunnels | Add, select, and delete simple target tunnels |
136 +| Tunnels | Add, select, and delete tunnels |
137 | Settings | Edit max active relays and public metadata |
138 | Relays | Connect or disconnect relays for the selected tunnel |
139 | Multi-hop | Build and apply an ordered multi-hop route |
@@ -149,9 +154,23 @@ Keyboard controls:
154 | `esc` | Cancel input or return to the Tunnels pane |
155 | `ctrl+c` | Exit the dashboard |
156
152 -The Add Tunnel action accepts `name port`, for example `myapp 3000`. It creates
153 -a simple loopback target tunnel. Advanced options such as `http_routes`, UDP,
154 -TCP, custom identity JSON, or explicit multi-hop defaults should be edited in
157 +The Add Tunnel action opens a form. Fill either `Target` for a simple loopback
158 +tunnel or `Routes` for routed HTTP. Routes use this syntax:
159 +
160 +```text
161 +/paid=3001 GET:0.01; /=5173
162 +```
163 +
164 +Each entry is `PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]`. Fill `X402 Pay To`
165 +when any route has an amount. The form also accepts explicit `Relays`,
166 +`Discovery`, and `Max Relays`; max relays caps auto-selected discovery relays
167 +while explicit relays are still included.
168 +
169 +After creation, routed HTTP paths, x402 payment amounts, and discovery mode are
170 +read-only in the Settings pane. To change routes, payment amounts, or discovery
171 +mode, edit `http_routes`, `x402_pay_to`, and `discovery` in `config.toml`, then
172 +restart the agent or tunnel. Other advanced options such as UDP, TCP, custom
173 +identity JSON, or explicit multi-hop defaults are also configured in
174 `config.toml`.
175
176 ## Tunnel Config Fields
@@ -176,7 +195,7 @@ Common fields:
195 | `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only |
196 | `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata |
197 | `x402_pay_to` | Tunnel-owned Sui USDC x402 recipient for paid HTTP routes |
179 -| `http_routes[].amount` | Optional Sui USDC x402 atomic amount for one HTTP route prefix |
198 +| `http_routes[].amount` | Optional Sui USDC x402 amount, such as `0.01`, for one HTTP route prefix |
199 | `http_routes[].methods` | Optional HTTP methods that require payment on that route; empty means every method |
200
201 Constraints match `portal expose`:
@@ -222,7 +241,7 @@ Control endpoints:
241 |--------|------|------|---------|
242 | `GET` | `/agent/status` | Bearer token or wallet session | Read agent and tunnel status |
243 | `POST` | `/agent/shutdown` | Bearer token | Ask the agent to stop |
225 -| `POST` | `/agent/tunnels` | Bearer token | Add a simple target tunnel |
244 +| `POST` | `/agent/tunnels` | Bearer token | Add a tunnel |
245 | `PATCH` | `/agent/tunnels/{id}` | Bearer token | Update metadata or max active relays |
246 | `DELETE` | `/agent/tunnels/{id}` | Bearer token | Delete a tunnel |
247 | `POST` | `/agent/tunnels/{id}/relays` | Bearer token | Connect a relay |
go.mod
+1
@@ -11,6 +11,7 @@ require (
11 github.com/charmbracelet/bubbles v1.0.0
12 github.com/charmbracelet/bubbletea v1.3.10
13 github.com/charmbracelet/lipgloss v1.1.0
14 + github.com/cockroachdb/apd/v3 v3.2.3
15 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
16 github.com/go-acme/lego/v4 v4.34.0
17 github.com/go-jose/go-jose/v4 v4.1.4
go.sum
+2
@@ -97,6 +97,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
97 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
98 github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
99 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
100 +github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=
101 +github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
102 github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
103 github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
104 github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
portal/x402/client.js new
+209
@@ -0,0 +1,209 @@
1 +import { getWallets } from 'https://esm.sh/@wallet-standard/app';
2 +import { Transaction } from 'https://esm.sh/@mysten/sui/transactions';
3 +
4 +const walletAPI = getWallets();
5 +
6 +export function getSuiWallets({ network = '' } = {}) {
7 + network = String(network || '').trim();
8 + return walletAPI.get().map((wallet, index) => {
9 + const features = wallet.features || {};
10 + const connect = features['standard:connect']?.connect?.bind(features['standard:connect']);
11 + const sign = features['sui:signTransaction']?.signTransaction?.bind(features['sui:signTransaction']);
12 + const signBlock = features['sui:signTransactionBlock']?.signTransactionBlock?.bind(features['sui:signTransactionBlock']);
13 + if (!connect || (!sign && !signBlock)) {
14 + return null;
15 + }
16 +
17 + const out = {
18 + id: wallet.name || `wallet-${index}`,
19 + name: wallet.name || `Wallet ${index + 1}`,
20 + async accounts(chain = network) {
21 + const connected = await connect();
22 + return normalizeAccounts(connected?.accounts || wallet.accounts || [])
23 + .filter((account) => !chain || !Array.isArray(account.chains) || account.chains.includes(chain));
24 + },
25 + async connect(chain = network, address = '') {
26 + const accounts = await out.accounts(chain);
27 + const normalizedAddress = String(address || '').trim().toLowerCase();
28 + if (normalizedAddress) {
29 + const account = accounts.find((candidate) => String(candidate.address || '').trim().toLowerCase() === normalizedAddress);
30 + if (!account) {
31 + throw new Error('Selected account is not available in the connected wallet');
32 + }
33 + return account;
34 + }
35 + if (accounts.length !== 1) {
36 + if (accounts.length > 1) {
37 + throw new Error('Select a Sui account before paying');
38 + }
39 + throw new Error('Connected wallet did not return an account');
40 + }
41 + return accounts[0];
42 + },
43 + async signTransaction(account, transaction, chain = network) {
44 + if (sign) {
45 + return sign({ account, chain, transaction });
46 + }
47 + return signBlock({ account, chain, transactionBlock: transaction });
48 + },
49 + };
50 +
51 + const execute = features['sui:signAndExecuteTransaction']?.signAndExecuteTransaction?.bind(features['sui:signAndExecuteTransaction']);
52 + const executeBlock = features['sui:signAndExecuteTransactionBlock']?.signAndExecuteTransactionBlock?.bind(features['sui:signAndExecuteTransactionBlock']);
53 + if (execute || executeBlock) {
54 + out.executeTransaction = async (account, transaction, chain = network) => {
55 + if (execute) {
56 + return execute({ account, chain, transaction, options: { showEffects: true } });
57 + }
58 + return executeBlock({ account, chain, transactionBlock: transaction, options: { showEffects: true } });
59 + };
60 + }
61 + return out;
62 + }).filter(Boolean);
63 +}
64 +
65 +export function onSuiWalletChange(callback) {
66 + if (typeof walletAPI.on !== 'function') {
67 + return () => {};
68 + }
69 + const offRegister = walletAPI.on('register', callback);
70 + const offUnregister = walletAPI.on('unregister', callback);
71 + return () => {
72 + offRegister?.();
73 + offUnregister?.();
74 + };
75 +}
76 +
77 +export async function prepareX402Payment(options = {}) {
78 + const fetcher = options.fetch || fetch.bind(globalThis);
79 + const method = String(options.method || 'GET').trim().toUpperCase();
80 + const path = String(options.path || '').trim();
81 + if (!path || !path.startsWith('/')) {
82 + throw new Error(path ? 'path must start with /' : 'path is required');
83 + }
84 +
85 + const wallet = options.wallet || getSuiWallets({ network: options.network })[0];
86 + if (!wallet) {
87 + throw new Error('No Sui wallet selected');
88 + }
89 +
90 + options.onStatus?.('Connecting wallet');
91 + const network = String(options.network || '').trim();
92 + const account = options.account || await wallet.connect(network, options.address);
93 + if (!account?.address) {
94 + throw new Error('Connected wallet did not return an account');
95 + }
96 + if (network && Array.isArray(account.chains) && !account.chains.includes(network)) {
97 + throw new Error(`Connected account does not advertise ${network}`);
98 + }
99 +
100 + options.onStatus?.('Preparing USDC transaction');
101 + const prepareURL = options.preparePath || '/x402/prepare';
102 + const prepareBody = { sender: account.address, method, path };
103 + let prepared = await requestPrepare(fetcher, prepareURL, prepareBody);
104 + let paymentNetwork = String(prepared.paymentRequirements?.network || network).trim();
105 + if (paymentNetwork && Array.isArray(account.chains) && !account.chains.includes(paymentNetwork)) {
106 + throw new Error(`Connected account does not advertise ${paymentNetwork}`);
107 + }
108 +
109 + if (prepared.prepareTransaction?.transaction) {
110 + if (!wallet.executeTransaction) {
111 + throw new Error('Selected wallet cannot execute the USDC prepare transaction');
112 + }
113 + options.onStatus?.('Preparing object balance in wallet');
114 + const prepareResult = await wallet.executeTransaction(account, Transaction.from(fromBase64(prepared.prepareTransaction.transaction)), paymentNetwork);
115 + const prepareStatus = prepareResult?.effects?.status?.status || prepareResult?.effects?.status;
116 + if (prepareStatus && prepareStatus !== 'success') {
117 + throw new Error(prepareResult.effects?.status?.error || 'USDC prepare transaction failed');
118 + }
119 +
120 + options.onStatus?.('Waiting for prepared balance');
121 + for (let attempt = 0; attempt < 20 && prepared.prepareTransaction?.transaction; attempt += 1) {
122 + await new Promise((resolve) => setTimeout(resolve, 1000));
123 + prepared = await requestPrepare(fetcher, prepareURL, prepareBody);
124 + paymentNetwork = String(prepared.paymentRequirements?.network || paymentNetwork).trim();
125 + }
126 + if (prepared.prepareTransaction?.transaction) {
127 + throw new Error('Prepared USDC balance is not indexed yet');
128 + }
129 + }
130 +
131 + if (!prepared.paymentTransaction?.transaction) {
132 + throw new Error('Payment prepare response is missing a payment transaction');
133 + }
134 + options.onStatus?.('Signing x402 payment');
135 + const signed = await wallet.signTransaction(account, Transaction.from(fromBase64(prepared.paymentTransaction.transaction)), paymentNetwork);
136 + const signature = typeof signed?.signature === 'string' ? signed.signature : (Array.isArray(signed?.signatures) ? signed.signatures[0] : '');
137 + const transaction = signed?.bytes || signed?.transactionBlockBytes || prepared.paymentTransaction.transaction;
138 + const transactionBytes = transaction instanceof Uint8Array ? toBase64(transaction) : transaction;
139 + if (!signature || !transactionBytes) {
140 + throw new Error('Wallet did not return a signed payment transaction');
141 + }
142 + if (transactionBytes !== prepared.paymentTransaction.transaction) {
143 + throw new Error('Wallet returned different payment transaction bytes');
144 + }
145 +
146 + const payload = {
147 + x402Version: prepared.x402Version,
148 + payload: {
149 + signature,
150 + transaction: transactionBytes,
151 + },
152 + accepted: prepared.paymentRequirements,
153 + resource: prepared.resource,
154 + };
155 + return {
156 + account,
157 + prepared,
158 + payload,
159 + paymentHeader: toBase64(new TextEncoder().encode(JSON.stringify(payload))),
160 + };
161 +}
162 +
163 +export async function x402Fetch(input, init = {}, options = {}) {
164 + const request = input instanceof Request ? new Request(input, init) : new Request(new URL(String(input), globalThis.location.href).href, init);
165 + const paid = await prepareX402Payment({
166 + ...options,
167 + method: request.method,
168 + path: options.path || new URL(request.url).pathname,
169 + });
170 +
171 + options.onStatus?.('Settling payment');
172 + const headers = new Headers(request.headers);
173 + headers.set('X-PAYMENT', paid.paymentHeader);
174 + return (options.fetch || fetch.bind(globalThis))(new Request(request, { headers }));
175 +}
176 +
177 +async function requestPrepare(fetcher, prepareURL, prepareBody) {
178 + const response = await fetcher(prepareURL, {
179 + method: 'POST',
180 + headers: { 'Content-Type': 'application/json' },
181 + body: JSON.stringify(prepareBody),
182 + });
183 + if (!response.ok) {
184 + throw new Error(await response.text());
185 + }
186 + return response.json();
187 +}
188 +
189 +function normalizeAccounts(value) {
190 + const accounts = Array.isArray(value) ? value : (value ? [value] : []);
191 + return accounts.map((account) => {
192 + if (typeof account === 'string') {
193 + return { address: account };
194 + }
195 + return account && typeof account.address === 'string' ? account : null;
196 + }).filter(Boolean);
197 +}
198 +
199 +function fromBase64(value) {
200 + return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
201 +}
202 +
203 +function toBase64(bytes) {
204 + let binary = '';
205 + for (let i = 0; i < bytes.length; i += 0x8000) {
206 + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
207 + }
208 + return btoa(binary);
209 +}
portal/x402/handler.go
+17
@@ -2,6 +2,7 @@ package x402
2
3 import (
4 "context"
5 + _ "embed"
6 "encoding/json"
7 "errors"
8 "fmt"
@@ -12,6 +13,22 @@ import (
13 "github.com/gosuda/portal-tunnel/v2/utils"
14 )
15
16 +//go:embed client.js
17 +var clientJS []byte
18 +
19 +// ServeClientJS serves the shared browser x402 wallet/payment client.
20 +func ServeClientJS(w http.ResponseWriter, r *http.Request) {
21 + if r.Method != http.MethodHead && !utils.RequireMethod(w, r, http.MethodGet) {
22 + return
23 + }
24 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
25 + w.Header().Set("Cache-Control", "no-store")
26 + if r.Method == http.MethodHead {
27 + return
28 + }
29 + _, _ = w.Write(clientJS)
30 +}
31 +
32 // USDCPaymentHandler serves both the wallet prepare endpoint and one protected resource.
33 type USDCPaymentHandler struct {
34 payment *Payment
portal/x402/payment.go
+46 -5
@@ -7,9 +7,9 @@ import (
7 "errors"
8 "fmt"
9 "net/http"
10 - "strconv"
10 "strings"
11
12 + "github.com/cockroachdb/apd/v3"
13 facilitatorcore "github.com/gosuda/x402-facilitator/facilitator"
14 suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
15 facilitatortypes "github.com/gosuda/x402-facilitator/types"
@@ -18,6 +18,8 @@ import (
18 "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 +const usdcDecimals = 6
22 +
23 // Payment owns one Sui USDC x402 payment contract and its facilitator runtime.
24 type Payment struct {
25 payment types.X402Payment
@@ -39,10 +41,9 @@ func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
41 if payTo == "" {
42 return nil, errors.New("x402 USDC payment requires a Sui pay-to address")
43 }
42 - amount := strings.TrimSpace(payment.Amount)
43 - n, err := strconv.ParseUint(amount, 10, 64)
44 - if err != nil || n == 0 {
45 - return nil, fmt.Errorf("x402 USDC payment amount must be a positive atomic amount: %s", amount)
44 + amount, err := USDCAmountToAtomic(payment.Amount)
45 + if err != nil {
46 + return nil, err
47 }
48 maxTimeoutSeconds := payment.MaxTimeoutSeconds
49 if maxTimeoutSeconds <= 0 {
@@ -88,6 +89,46 @@ func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
89 }, nil
90 }
91
92 +// USDCAmountToAtomic converts a human USDC amount, such as "0.01", to atomic
93 +// units for the x402 facilitator. USDC has 6 decimals.
94 +func USDCAmountToAtomic(amount string) (string, error) {
95 + amount = strings.TrimSpace(amount)
96 + if amount == "" {
97 + return "", errors.New("x402 USDC payment amount is required")
98 + }
99 + d, _, err := new(apd.Decimal).SetString(amount)
100 + if err != nil {
101 + return "", fmt.Errorf("x402 USDC payment amount must be a decimal USDC amount: %s", amount)
102 + }
103 + d.Exponent += int32(usdcDecimals)
104 + d.Reduce(d)
105 + if d.Form != apd.Finite || d.Sign() <= 0 {
106 + return "", fmt.Errorf("x402 USDC payment amount must be positive: %s", amount)
107 + }
108 + if d.Exponent < 0 {
109 + return "", fmt.Errorf("x402 USDC payment amount supports up to %d decimals: %s", usdcDecimals, amount)
110 + }
111 + return fmt.Sprintf("%f", d), nil
112 +}
113 +
114 +// FormatUSDCAtomicAmount renders an atomic USDC amount as a human amount.
115 +func FormatUSDCAtomicAmount(amount string) string {
116 + amount = strings.TrimSpace(amount)
117 + if amount == "" {
118 + return ""
119 + }
120 + d, _, err := new(apd.Decimal).SetString(amount)
121 + if err != nil {
122 + return amount + " atomic USDC"
123 + }
124 + d.Exponent -= int32(usdcDecimals)
125 + d.Reduce(d)
126 + if d.Form != apd.Finite || d.Sign() < 0 {
127 + return amount + " atomic USDC"
128 + }
129 + return fmt.Sprintf("%f USDC", d)
130 +}
131 +
132 func (p *Payment) Verify(ctx context.Context, w http.ResponseWriter, r *http.Request) (*facilitatortypes.PaymentPayload, bool) {
133 if p == nil {
134 http.Error(w, "payment is not configured", http.StatusInternalServerError)
sdk/http.go
+5
@@ -132,6 +132,7 @@ type HTTPRouteConfig struct {
132 // Methods limits payment to these HTTP methods. Empty means every method.
133 Methods []string
134 // Amount enables Sui USDC x402 payment for this public path prefix.
135 + // It is a human USDC amount such as "0.01"; x402 converts it to atomic units.
136 Amount string
137 }
138
@@ -178,6 +179,10 @@ func (h *HTTPRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
179 path = r.URL.Path
180 }
181 path = utils.NormalizeURLPath(path)
182 + if path == types.X402ClientPath {
183 + x402.ServeClientJS(w, r)
184 + return
185 + }
186 prepare := path == types.X402PreparePath
187 var paymentSender string
188 paymentMethod := http.MethodGet
types/agent.go
+18 -4
@@ -14,12 +14,22 @@ type AgentTunnelStatus struct {
14 State string `json:"state"`
15 TargetAddr string `json:"target_addr,omitempty"`
16 LastError string `json:"last_error,omitempty"`
17 + Discovery bool `json:"discovery"`
18 MaxActiveRelays int `json:"max_active_relays,omitempty"`
19 Metadata LeaseMetadata `json:"metadata,omitempty"`
20 MultiHop []string `json:"multi_hop,omitempty"`
21 + X402PayTo string `json:"x402_pay_to,omitempty"`
22 + HTTPRoutes []AgentHTTPRoute `json:"http_routes,omitempty"`
23 Relays []AgentRelayStatus `json:"relays,omitempty"`
24 }
25
26 +type AgentHTTPRoute struct {
27 + Prefix string `json:"prefix"`
28 + Upstream string `json:"upstream"`
29 + Methods []string `json:"methods,omitempty"`
30 + Amount string `json:"amount,omitempty"`
31 +}
32 +
33 type AgentRelayStatus struct {
34 RelayURL string `json:"relay_url"`
35 PublicURL string `json:"public_url,omitempty"`
@@ -34,10 +44,14 @@ type AgentRelayStatus struct {
44 }
45
46 type AgentTunnelRequest struct {
37 - ID string `json:"id"`
38 - Name string `json:"name,omitempty"`
39 - TargetAddr string `json:"target_addr,omitempty"`
40 - RelayURLs []string `json:"relays,omitempty"`
47 + ID string `json:"id"`
48 + Name string `json:"name,omitempty"`
49 + TargetAddr string `json:"target_addr,omitempty"`
50 + HTTPRoutes []AgentHTTPRoute `json:"http_routes,omitempty"`
51 + RelayURLs []string `json:"relays,omitempty"`
52 + Discovery *bool `json:"discovery,omitempty"`
53 + MaxActiveRelays int `json:"max_active_relays,omitempty"`
54 + X402PayTo string `json:"x402_pay_to,omitempty"`
55 }
56
57 type AgentRelayRequest struct {
types/paths.go
+1
@@ -44,6 +44,7 @@ const (
44
45 const (
46 X402PreparePath = "/x402/prepare"
47 + X402ClientPath = "/x402/client.js"
48
49 PathAgentPrefix = "/agent"
50 PathAgentStatus = PathAgentPrefix + "/status"