@blockrun/franklin 3.17.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/panel/html.js +403 -0
- package/dist/panel/server.js +217 -6
- package/dist/phone/cache.d.ts +44 -0
- package/dist/phone/cache.js +74 -0
- package/dist/phone/client.d.ts +50 -0
- package/dist/phone/client.js +162 -0
- package/package.json +1 -1
package/dist/panel/html.js
CHANGED
|
@@ -410,6 +410,63 @@ a:hover { text-decoration:underline; }
|
|
|
410
410
|
.btn-danger { background:oklch(0.65 0.20 25 / 18%); color:var(--danger); border-color:oklch(0.65 0.20 25 / 35%); }
|
|
411
411
|
.btn-danger:hover { background:oklch(0.65 0.20 25 / 30%); }
|
|
412
412
|
|
|
413
|
+
.nav-badge {
|
|
414
|
+
margin-left:auto; font-size:10px; font-weight:700; letter-spacing:0.3px;
|
|
415
|
+
padding:2px 7px; border-radius:8px;
|
|
416
|
+
background:oklch(0.65 0.20 25 / 22%); color:var(--danger);
|
|
417
|
+
border:1px solid oklch(0.65 0.20 25 / 35%);
|
|
418
|
+
}
|
|
419
|
+
.nav-badge.warn { background:oklch(0.78 0.14 85 / 22%); color:var(--gold); border-color:oklch(0.78 0.14 85 / 35%); }
|
|
420
|
+
|
|
421
|
+
.phone-list { display:flex; flex-direction:column; gap:10px; }
|
|
422
|
+
.phone-row {
|
|
423
|
+
display:grid; grid-template-columns:auto 1fr auto; gap:14px; align-items:center;
|
|
424
|
+
padding:14px 16px; background:var(--bg-card); border:1px solid var(--border);
|
|
425
|
+
border-radius:var(--radius); transition:border-color 0.15s, background 0.15s;
|
|
426
|
+
}
|
|
427
|
+
.phone-row:hover { background:var(--bg-card-hover); }
|
|
428
|
+
.phone-row.warn { border-color:oklch(0.78 0.14 85 / 50%); }
|
|
429
|
+
.phone-row.crit { border-color:oklch(0.65 0.20 25 / 55%); }
|
|
430
|
+
.phone-row.expired { opacity:0.65; border-color:oklch(0.65 0.20 25 / 45%); }
|
|
431
|
+
.phone-icon-bubble {
|
|
432
|
+
width:36px; height:36px; border-radius:10px; display:grid; place-items:center;
|
|
433
|
+
background:oklch(0.68 0.16 260 / 18%); color:var(--brand);
|
|
434
|
+
}
|
|
435
|
+
.phone-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
|
|
436
|
+
.phone-num {
|
|
437
|
+
font-family:var(--mono); font-size:15px; font-weight:600; color:var(--text);
|
|
438
|
+
letter-spacing:0.02em;
|
|
439
|
+
}
|
|
440
|
+
.phone-meta { font-size:12px; color:var(--text-muted); display:flex; gap:10px; flex-wrap:wrap; }
|
|
441
|
+
.phone-meta .chip {
|
|
442
|
+
display:inline-flex; align-items:center; gap:4px;
|
|
443
|
+
padding:2px 7px; border-radius:6px; background:oklch(0 0 0 / 25%);
|
|
444
|
+
font-size:10.5px; letter-spacing:0.5px; text-transform:uppercase; font-weight:700;
|
|
445
|
+
}
|
|
446
|
+
.phone-meta .chip.green { color:var(--success); background:oklch(0.65 0.18 145 / 18%); }
|
|
447
|
+
.phone-meta .chip.amber { color:var(--gold); background:oklch(0.78 0.14 85 / 20%); }
|
|
448
|
+
.phone-meta .chip.red { color:var(--danger); background:oklch(0.65 0.20 25 / 20%); }
|
|
449
|
+
.phone-row .phone-actions { display:flex; align-items:center; gap:6px; }
|
|
450
|
+
.phone-row.expired .phone-num { text-decoration:line-through; }
|
|
451
|
+
|
|
452
|
+
.phone-empty {
|
|
453
|
+
padding:24px; text-align:center; border:1px dashed var(--border);
|
|
454
|
+
border-radius:var(--radius); color:var(--text-muted); font-size:13px;
|
|
455
|
+
line-height:1.6;
|
|
456
|
+
}
|
|
457
|
+
.phone-empty strong { color:var(--text); font-weight:600; display:block; margin-bottom:6px; font-size:14px; }
|
|
458
|
+
|
|
459
|
+
.phone-buy-form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:10px; }
|
|
460
|
+
.phone-buy-form select, .phone-buy-form input {
|
|
461
|
+
padding:7px 10px; background:oklch(0 0 0 / 35%); color:var(--text);
|
|
462
|
+
border:1px solid var(--border); border-radius:7px; font-size:13px;
|
|
463
|
+
font-family:var(--mono);
|
|
464
|
+
}
|
|
465
|
+
.phone-buy-form input { width:120px; }
|
|
466
|
+
.phone-status { font-size:12px; color:var(--text-muted); }
|
|
467
|
+
.phone-status.ok { color:var(--success); }
|
|
468
|
+
.phone-status.err { color:var(--danger); }
|
|
469
|
+
|
|
413
470
|
@media (max-width:768px) {
|
|
414
471
|
body { flex-direction:column; }
|
|
415
472
|
.sidebar { width:100%; min-width:100%; flex-direction:row; padding:8px; overflow-x:auto; border-right:none; border-bottom:1px solid var(--border); }
|
|
@@ -455,6 +512,11 @@ a:hover { text-decoration:underline; }
|
|
|
455
512
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 5-5"/></svg>
|
|
456
513
|
Markets
|
|
457
514
|
</button>
|
|
515
|
+
<button class="nav-item" data-tab="phone">
|
|
516
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
|
517
|
+
Phone
|
|
518
|
+
<span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
|
|
519
|
+
</button>
|
|
458
520
|
<button class="nav-item" data-tab="sessions">
|
|
459
521
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
460
522
|
Sessions
|
|
@@ -677,6 +739,46 @@ a:hover { text-decoration:underline; }
|
|
|
677
739
|
</div>
|
|
678
740
|
</div>
|
|
679
741
|
|
|
742
|
+
<!-- Phone & Voice -->
|
|
743
|
+
<div class="tab" id="tab-phone">
|
|
744
|
+
<div class="content-header">
|
|
745
|
+
<h2>Phone & Voice</h2>
|
|
746
|
+
<p>Numbers your wallet owns. Leases run 30 days — renew before they expire or set auto-renew.</p>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<div class="card" style="margin-bottom:16px">
|
|
750
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
|
|
751
|
+
<h3 style="margin:0">Your numbers</h3>
|
|
752
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
753
|
+
<span class="phone-status" id="phone-list-status"></span>
|
|
754
|
+
<button class="btn btn-ghost" id="phone-refresh-btn" title="Refetch from BlockRun ($0.001)">Refresh</button>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div id="phone-list" style="margin-top:12px">
|
|
758
|
+
<div class="phone-empty">Loading…</div>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<div class="card">
|
|
763
|
+
<h3 style="margin:0 0 6px">Add another number</h3>
|
|
764
|
+
<p class="wallet-hint">
|
|
765
|
+
$5 USDC for a fresh number, bound to your wallet for 30 days. <strong>This adds
|
|
766
|
+
a new number alongside any you already own — nothing is replaced.</strong>
|
|
767
|
+
Use it as caller ID for outbound AI voice calls, or (soon) to receive inbound calls.
|
|
768
|
+
Multiple numbers are fine; release any you no longer need to stop paying renewals on them.
|
|
769
|
+
</p>
|
|
770
|
+
<div class="phone-buy-form">
|
|
771
|
+
<select id="phone-buy-country">
|
|
772
|
+
<option value="US">United States (+1)</option>
|
|
773
|
+
<option value="CA">Canada (+1)</option>
|
|
774
|
+
</select>
|
|
775
|
+
<input id="phone-buy-areacode" placeholder="Area code (opt)" maxlength="6" />
|
|
776
|
+
<button class="btn btn-warn" id="phone-buy-btn">Buy for $5</button>
|
|
777
|
+
<span class="phone-status" id="phone-buy-status"></span>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
680
782
|
<!-- Learnings -->
|
|
681
783
|
<div class="tab" id="tab-learnings">
|
|
682
784
|
<div class="content-header">
|
|
@@ -1544,6 +1646,307 @@ document.addEventListener('visibilitychange', () => {
|
|
|
1544
1646
|
|
|
1545
1647
|
document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
|
|
1546
1648
|
|
|
1649
|
+
// ─── Phone & Voice ──────────────────────────────────────────────────────
|
|
1650
|
+
// Renders the user's wallet-owned numbers, days-remaining countdown,
|
|
1651
|
+
// renew / release / auto-renew controls, and the buy form. Drives the
|
|
1652
|
+
// sidebar nav badge so users with an expiring number see it even from
|
|
1653
|
+
// the Overview tab. Notification ladder uses the Notifications API,
|
|
1654
|
+
// dedupe-keyed in sessionStorage so we don't spam the user every open.
|
|
1655
|
+
|
|
1656
|
+
const phoneState = { data: null, countdownTimer: null };
|
|
1657
|
+
|
|
1658
|
+
function formatPhoneNumber(e164) {
|
|
1659
|
+
// E.164 → human, for display only. Keep raw value for actions.
|
|
1660
|
+
if (!e164) return '—';
|
|
1661
|
+
const m = String(e164).match(/^\\+1(\\d{3})(\\d{3})(\\d{4})$/);
|
|
1662
|
+
if (m) return '+1 (' + m[1] + ') ' + m[2] + '-' + m[3];
|
|
1663
|
+
return e164;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function daysLeft(expiresAt) {
|
|
1667
|
+
const expiry = new Date(expiresAt).getTime();
|
|
1668
|
+
if (isNaN(expiry)) return 0;
|
|
1669
|
+
return Math.floor((expiry - Date.now()) / 86400000);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function phoneTier(days) {
|
|
1673
|
+
if (days < 0) return 'expired';
|
|
1674
|
+
if (days <= 2) return 'crit';
|
|
1675
|
+
if (days <= 7) return 'warn';
|
|
1676
|
+
return 'ok';
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function phoneChipClass(tier) {
|
|
1680
|
+
if (tier === 'expired' || tier === 'crit') return 'red';
|
|
1681
|
+
if (tier === 'warn') return 'amber';
|
|
1682
|
+
return 'green';
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function phoneCountdownLabel(days) {
|
|
1686
|
+
if (days < 0) return 'expired ' + Math.abs(days) + 'd ago';
|
|
1687
|
+
if (days === 0) return 'expires today';
|
|
1688
|
+
if (days === 1) return '1 day left';
|
|
1689
|
+
return days + ' days left';
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function updatePhoneNavBadge(numbers) {
|
|
1693
|
+
const badge = document.getElementById('phone-nav-badge');
|
|
1694
|
+
if (!badge) return;
|
|
1695
|
+
let worst = 999;
|
|
1696
|
+
let anyExpired = false;
|
|
1697
|
+
numbers.forEach(n => {
|
|
1698
|
+
const d = daysLeft(n.expires_at);
|
|
1699
|
+
if (d < 0) anyExpired = true;
|
|
1700
|
+
if (d < worst) worst = d;
|
|
1701
|
+
});
|
|
1702
|
+
if (anyExpired) {
|
|
1703
|
+
badge.textContent = '!'; badge.className = 'nav-badge'; badge.style.display = '';
|
|
1704
|
+
} else if (worst <= 2) {
|
|
1705
|
+
badge.textContent = worst + 'd'; badge.className = 'nav-badge'; badge.style.display = '';
|
|
1706
|
+
} else if (worst <= 7) {
|
|
1707
|
+
badge.textContent = worst + 'd'; badge.className = 'nav-badge warn'; badge.style.display = '';
|
|
1708
|
+
} else {
|
|
1709
|
+
badge.style.display = 'none';
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function maybeNotifyExpiry(numbers) {
|
|
1714
|
+
if (typeof Notification === 'undefined') return;
|
|
1715
|
+
if (Notification.permission !== 'granted') return;
|
|
1716
|
+
numbers.forEach(n => {
|
|
1717
|
+
const d = daysLeft(n.expires_at);
|
|
1718
|
+
let mark = null;
|
|
1719
|
+
if (d < 0) mark = 'expired';
|
|
1720
|
+
else if (d <= 1) mark = 't1';
|
|
1721
|
+
else if (d <= 3) mark = 't3';
|
|
1722
|
+
else if (d <= 7) mark = 't7';
|
|
1723
|
+
if (!mark) return;
|
|
1724
|
+
const key = 'phone:notify:' + n.phone_number + ':' + mark;
|
|
1725
|
+
if (sessionStorage.getItem(key)) return;
|
|
1726
|
+
sessionStorage.setItem(key, '1');
|
|
1727
|
+
const human = formatPhoneNumber(n.phone_number);
|
|
1728
|
+
const title = 'Franklin: ' + human;
|
|
1729
|
+
const body = mark === 'expired'
|
|
1730
|
+
? 'This number has expired. Provision a new one in the Phone tab.'
|
|
1731
|
+
: (mark === 't1'
|
|
1732
|
+
? 'Expires in 1 day. Click to renew for $5.'
|
|
1733
|
+
: (mark === 't3'
|
|
1734
|
+
? 'Expires in 3 days. Click to renew for $5.'
|
|
1735
|
+
: 'Expires in a week. Renew when convenient.'));
|
|
1736
|
+
try {
|
|
1737
|
+
const notif = new Notification(title, { body, tag: key });
|
|
1738
|
+
notif.onclick = () => {
|
|
1739
|
+
try { window.focus(); } catch (e) {}
|
|
1740
|
+
location.hash = 'phone';
|
|
1741
|
+
activateTab('phone');
|
|
1742
|
+
notif.close();
|
|
1743
|
+
};
|
|
1744
|
+
} catch (e) { /* ignore */ }
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function renderPhoneNumbers(data) {
|
|
1749
|
+
const list = document.getElementById('phone-list');
|
|
1750
|
+
if (!list) return;
|
|
1751
|
+
const numbers = (data && data.numbers) || [];
|
|
1752
|
+
updatePhoneNavBadge(numbers);
|
|
1753
|
+
|
|
1754
|
+
if (!numbers.length) {
|
|
1755
|
+
list.innerHTML = '<div class="phone-empty">' +
|
|
1756
|
+
'<strong>No numbers yet</strong>' +
|
|
1757
|
+
'Provision a number below to give Franklin a phone identity. ' +
|
|
1758
|
+
'Numbers cost $5 for a 30-day lease and are bound to your wallet.' +
|
|
1759
|
+
'</div>';
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const html = numbers.map(n => {
|
|
1764
|
+
const d = daysLeft(n.expires_at);
|
|
1765
|
+
const tier = phoneTier(d);
|
|
1766
|
+
const chipCls = phoneChipClass(tier);
|
|
1767
|
+
const rowCls = tier === 'ok' ? '' : (' ' + tier);
|
|
1768
|
+
const human = formatPhoneNumber(n.phone_number);
|
|
1769
|
+
const renewBtn = tier === 'expired'
|
|
1770
|
+
? ''
|
|
1771
|
+
: '<button class="btn btn-warn" data-phone-renew="' + n.phone_number + '">Renew $5</button>';
|
|
1772
|
+
const releaseLabel = tier === 'expired' ? 'Remove' : 'Release';
|
|
1773
|
+
return ''
|
|
1774
|
+
+ '<div class="phone-row' + rowCls + '">'
|
|
1775
|
+
+ ' <div class="phone-icon-bubble">'
|
|
1776
|
+
+ ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
1777
|
+
+ ' <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>'
|
|
1778
|
+
+ ' </svg>'
|
|
1779
|
+
+ ' </div>'
|
|
1780
|
+
+ ' <div class="phone-main">'
|
|
1781
|
+
+ ' <div class="phone-num">' + human + '</div>'
|
|
1782
|
+
+ ' <div class="phone-meta">'
|
|
1783
|
+
+ ' <span class="chip">' + (n.chain || '—') + '</span>'
|
|
1784
|
+
+ ' <span class="chip ' + chipCls + '">' + phoneCountdownLabel(d) + '</span>'
|
|
1785
|
+
+ ' </div>'
|
|
1786
|
+
+ ' </div>'
|
|
1787
|
+
+ ' <div class="phone-actions">'
|
|
1788
|
+
+ renewBtn
|
|
1789
|
+
+ ' <button class="btn btn-ghost" data-phone-release="' + n.phone_number + '" title="' + releaseLabel + ' this number">' + releaseLabel + '</button>'
|
|
1790
|
+
+ ' </div>'
|
|
1791
|
+
+ '</div>';
|
|
1792
|
+
}).join('');
|
|
1793
|
+
|
|
1794
|
+
list.innerHTML = html;
|
|
1795
|
+
|
|
1796
|
+
list.querySelectorAll('[data-phone-renew]').forEach(btn => {
|
|
1797
|
+
btn.addEventListener('click', () => renewPhoneNumber(btn.getAttribute('data-phone-renew')));
|
|
1798
|
+
});
|
|
1799
|
+
list.querySelectorAll('[data-phone-release]').forEach(btn => {
|
|
1800
|
+
btn.addEventListener('click', () => releasePhoneNumber(btn.getAttribute('data-phone-release')));
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
maybeNotifyExpiry(numbers);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async function loadPhone(opts) {
|
|
1807
|
+
const force = !!(opts && opts.force);
|
|
1808
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1809
|
+
if (statusEl) statusEl.textContent = force ? 'Refreshing…' : 'Loading…';
|
|
1810
|
+
try {
|
|
1811
|
+
const url = '/api/phone/numbers';
|
|
1812
|
+
const r = force
|
|
1813
|
+
? await fetch('/api/phone/numbers/refresh', { method: 'POST' })
|
|
1814
|
+
: await fetch(url);
|
|
1815
|
+
const data = await r.json();
|
|
1816
|
+
if (!r.ok) {
|
|
1817
|
+
if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
|
|
1818
|
+
const list = document.getElementById('phone-list');
|
|
1819
|
+
if (list) list.innerHTML = '<div class="phone-empty"><strong>Could not load numbers</strong>' + (data.error || 'Unknown error') + '</div>';
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
phoneState.data = data;
|
|
1823
|
+
renderPhoneNumbers(data);
|
|
1824
|
+
if (statusEl) {
|
|
1825
|
+
statusEl.className = 'phone-status';
|
|
1826
|
+
statusEl.textContent = data.fromCache
|
|
1827
|
+
? 'Cached ' + new Date(data.fetchedAt).toLocaleTimeString()
|
|
1828
|
+
: 'Synced ' + new Date(data.fetchedAt || Date.now()).toLocaleTimeString();
|
|
1829
|
+
}
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function renewPhoneNumber(num) {
|
|
1836
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1837
|
+
if (statusEl) { statusEl.textContent = 'Renewing ' + formatPhoneNumber(num) + '…'; statusEl.className = 'phone-status'; }
|
|
1838
|
+
try {
|
|
1839
|
+
const r = await fetch('/api/phone/numbers/renew', {
|
|
1840
|
+
method: 'POST',
|
|
1841
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1842
|
+
body: JSON.stringify({ phoneNumber: num }),
|
|
1843
|
+
});
|
|
1844
|
+
const data = await r.json();
|
|
1845
|
+
if (!r.ok) {
|
|
1846
|
+
if (statusEl) { statusEl.textContent = data.error || 'Renew failed'; statusEl.className = 'phone-status err'; }
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
// Clear dedupe keys so a renewed number can re-notify if it expires again later
|
|
1850
|
+
Object.keys(sessionStorage).filter(k => k.startsWith('phone:notify:' + num + ':')).forEach(k => sessionStorage.removeItem(k));
|
|
1851
|
+
if (statusEl) { statusEl.textContent = 'Renewed — new expiry ' + new Date(data.expires_at).toLocaleDateString(); statusEl.className = 'phone-status ok'; }
|
|
1852
|
+
await loadPhone({});
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function releasePhoneNumber(num) {
|
|
1859
|
+
if (!confirm('Release ' + formatPhoneNumber(num) + '? This permanently gives up the number and cannot be undone.')) return;
|
|
1860
|
+
const statusEl = document.getElementById('phone-list-status');
|
|
1861
|
+
if (statusEl) { statusEl.textContent = 'Releasing…'; statusEl.className = 'phone-status'; }
|
|
1862
|
+
try {
|
|
1863
|
+
const r = await fetch('/api/phone/numbers/release', {
|
|
1864
|
+
method: 'POST',
|
|
1865
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1866
|
+
body: JSON.stringify({ phoneNumber: num }),
|
|
1867
|
+
});
|
|
1868
|
+
const data = await r.json();
|
|
1869
|
+
if (!r.ok) {
|
|
1870
|
+
if (statusEl) { statusEl.textContent = data.error || 'Release failed'; statusEl.className = 'phone-status err'; }
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
if (statusEl) { statusEl.textContent = 'Released ' + formatPhoneNumber(num); statusEl.className = 'phone-status ok'; }
|
|
1874
|
+
await loadPhone({});
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function buyPhoneNumber() {
|
|
1881
|
+
const country = (document.getElementById('phone-buy-country') || {}).value || 'US';
|
|
1882
|
+
const areaCode = ((document.getElementById('phone-buy-areacode') || {}).value || '').trim();
|
|
1883
|
+
const statusEl = document.getElementById('phone-buy-status');
|
|
1884
|
+
const btn = document.getElementById('phone-buy-btn');
|
|
1885
|
+
const existingCount = ((phoneState.data && phoneState.data.numbers) || []).filter(n => daysLeft(n.expires_at) >= 0).length;
|
|
1886
|
+
const intro = existingCount > 0
|
|
1887
|
+
? 'You already own ' + existingCount + ' active number' + (existingCount === 1 ? '' : 's') + '. This will ADD a new number — nothing is replaced.\\n\\n'
|
|
1888
|
+
: '';
|
|
1889
|
+
if (!confirm(intro + 'Buy a new phone number for $5? It will be charged from your wallet immediately and last 30 days.')) return;
|
|
1890
|
+
if (statusEl) { statusEl.textContent = 'Provisioning…'; statusEl.className = 'phone-status'; }
|
|
1891
|
+
if (btn) btn.disabled = true;
|
|
1892
|
+
try {
|
|
1893
|
+
const r = await fetch('/api/phone/numbers/buy', {
|
|
1894
|
+
method: 'POST',
|
|
1895
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1896
|
+
body: JSON.stringify({ country, areaCode: areaCode || undefined }),
|
|
1897
|
+
});
|
|
1898
|
+
const data = await r.json();
|
|
1899
|
+
if (!r.ok) {
|
|
1900
|
+
if (statusEl) { statusEl.textContent = data.error || 'Purchase failed'; statusEl.className = 'phone-status err'; }
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (statusEl) { statusEl.textContent = 'Got ' + formatPhoneNumber(data.phone_number); statusEl.className = 'phone-status ok'; }
|
|
1904
|
+
await loadPhone({});
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
|
|
1907
|
+
} finally {
|
|
1908
|
+
if (btn) btn.disabled = false;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function startPhoneCountdown() {
|
|
1913
|
+
if (phoneState.countdownTimer) return;
|
|
1914
|
+
// Re-render every minute so countdown chips age in place. Cheap — no
|
|
1915
|
+
// network, just DOM. Pauses when tab not visible (see visibilitychange).
|
|
1916
|
+
phoneState.countdownTimer = setInterval(() => {
|
|
1917
|
+
if (document.visibilityState !== 'visible') return;
|
|
1918
|
+
if (phoneState.data) renderPhoneNumbers(phoneState.data);
|
|
1919
|
+
}, 60000);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function stopPhoneCountdown() {
|
|
1923
|
+
if (phoneState.countdownTimer) {
|
|
1924
|
+
clearInterval(phoneState.countdownTimer);
|
|
1925
|
+
phoneState.countdownTimer = null;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
document.querySelector('[data-tab="phone"]')?.addEventListener('click', () => {
|
|
1930
|
+
// Ask once for notification permission when the user first opens the tab.
|
|
1931
|
+
// We never auto-prompt on page load — that would be annoying.
|
|
1932
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
1933
|
+
Notification.requestPermission().catch(() => {});
|
|
1934
|
+
}
|
|
1935
|
+
loadPhone({});
|
|
1936
|
+
startPhoneCountdown();
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
document.addEventListener('tab:deactivated', (e) => {
|
|
1940
|
+
if (e.detail && e.detail.name === 'phone') stopPhoneCountdown();
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
document.getElementById('phone-refresh-btn')?.addEventListener('click', () => loadPhone({ force: true }));
|
|
1944
|
+
document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumber);
|
|
1945
|
+
|
|
1946
|
+
// Prime the nav badge so an expiring number is visible even before the user
|
|
1947
|
+
// clicks into the Phone tab. Cached read — no network cost.
|
|
1948
|
+
loadPhone({});
|
|
1949
|
+
|
|
1547
1950
|
loadOverview();
|
|
1548
1951
|
loadSessions();
|
|
1549
1952
|
loadMarkets();
|
package/dist/panel/server.js
CHANGED
|
@@ -7,6 +7,8 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { loadChain, saveChain } from '../config.js';
|
|
10
|
+
import { listNumbers as gatewayListNumbers, renewNumber as gatewayRenewNumber, buyNumber as gatewayBuyNumber, releaseNumber as gatewayReleaseNumber, } from '../phone/client.js';
|
|
11
|
+
import { readCache as readPhoneCache, writeCache as writePhoneCache, clearCache as clearPhoneCache, isFresh as isPhoneCacheFresh, } from '../phone/cache.js';
|
|
10
12
|
import { getStatsSummary, getStatsFilePath } from '../stats/tracker.js';
|
|
11
13
|
import { generateInsights } from '../stats/insights.js';
|
|
12
14
|
import { listSessions, loadSessionHistory } from '../session/storage.js';
|
|
@@ -24,7 +26,7 @@ const sseClients = new Set();
|
|
|
24
26
|
function json(res, data, status = 200) {
|
|
25
27
|
res.writeHead(status, {
|
|
26
28
|
'Content-Type': 'application/json',
|
|
27
|
-
'
|
|
29
|
+
'Cache-Control': 'no-store',
|
|
28
30
|
});
|
|
29
31
|
res.end(JSON.stringify(data));
|
|
30
32
|
}
|
|
@@ -37,6 +39,39 @@ function isLoopback(req) {
|
|
|
37
39
|
const addr = req.socket.remoteAddress || '';
|
|
38
40
|
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
39
41
|
}
|
|
42
|
+
function isLocalHostname(hostname) {
|
|
43
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Loopback binding prevents LAN exposure, but it does not stop a malicious
|
|
47
|
+
* website open in the user's browser from issuing requests to localhost.
|
|
48
|
+
* Browsers attach Origin on cross-origin fetches, so spendful and
|
|
49
|
+
* wallet-mutating routes require either no Origin (curl/direct navigation)
|
|
50
|
+
* or the exact same local origin that served the panel page.
|
|
51
|
+
*/
|
|
52
|
+
function isTrustedPanelOrigin(req) {
|
|
53
|
+
const origin = req.headers.origin;
|
|
54
|
+
if (!origin)
|
|
55
|
+
return true;
|
|
56
|
+
if (Array.isArray(origin))
|
|
57
|
+
return false;
|
|
58
|
+
const host = req.headers.host;
|
|
59
|
+
if (!host)
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
const originUrl = new URL(origin);
|
|
63
|
+
const hostUrl = new URL(`http://${host}`);
|
|
64
|
+
return originUrl.protocol === 'http:' &&
|
|
65
|
+
originUrl.host === hostUrl.host &&
|
|
66
|
+
isLocalHostname(originUrl.hostname);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isLocalPanelRequest(req) {
|
|
73
|
+
return isLoopback(req) && isTrustedPanelOrigin(req);
|
|
74
|
+
}
|
|
40
75
|
async function readBody(req, maxBytes = 16 * 1024) {
|
|
41
76
|
return new Promise((resolve, reject) => {
|
|
42
77
|
let size = 0;
|
|
@@ -65,6 +100,25 @@ function broadcast(data) {
|
|
|
65
100
|
}
|
|
66
101
|
}
|
|
67
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the current active wallet address (Base or Solana, depending on
|
|
105
|
+
* the active chain). Used by phone endpoints that key the cache by wallet,
|
|
106
|
+
* and that must sign x402 payments out of the wallet the user owns.
|
|
107
|
+
*
|
|
108
|
+
* Throws if no wallet exists yet — the UI handles this by showing an
|
|
109
|
+
* empty state with a "Create wallet" CTA before any phone calls can be made.
|
|
110
|
+
*/
|
|
111
|
+
async function currentWalletAddress() {
|
|
112
|
+
const chain = loadChain();
|
|
113
|
+
if (chain === 'solana') {
|
|
114
|
+
const { setupAgentSolanaWallet } = await import('@blockrun/llm');
|
|
115
|
+
const client = await setupAgentSolanaWallet({ silent: true });
|
|
116
|
+
return await client.getWalletAddress();
|
|
117
|
+
}
|
|
118
|
+
const { setupAgentWallet } = await import('@blockrun/llm');
|
|
119
|
+
const client = setupAgentWallet({ silent: true });
|
|
120
|
+
return client.getWalletAddress();
|
|
121
|
+
}
|
|
68
122
|
export function createPanelServer(port) {
|
|
69
123
|
const html = getHTML();
|
|
70
124
|
const server = http.createServer(async (req, res) => {
|
|
@@ -239,9 +293,9 @@ export function createPanelServer(port) {
|
|
|
239
293
|
// ─── Wallet secret (loopback only) ──────────────────────────────────
|
|
240
294
|
// Returns the private key so the user can back it up / move it.
|
|
241
295
|
// Hardened: loopback-only (belt-and-suspenders on the 127.0.0.1 bind),
|
|
242
|
-
// no-store cache header, JSON only.
|
|
296
|
+
// same-origin for browser requests, no-store cache header, JSON only.
|
|
243
297
|
if (p === '/api/wallet/secret') {
|
|
244
|
-
if (!
|
|
298
|
+
if (!isLocalPanelRequest(req)) {
|
|
245
299
|
json(res, { error: 'forbidden' }, 403);
|
|
246
300
|
return;
|
|
247
301
|
}
|
|
@@ -271,9 +325,9 @@ export function createPanelServer(port) {
|
|
|
271
325
|
// ─── Wallet import (loopback only) ──────────────────────────────────
|
|
272
326
|
// Overwrites the local wallet with a user-supplied private key.
|
|
273
327
|
// Destructive — overwrites the existing wallet file without backup,
|
|
274
|
-
// so the UI warns the user. Loopback-only.
|
|
328
|
+
// so the UI warns the user. Loopback + same-origin only.
|
|
275
329
|
if (p === '/api/wallet/import' && req.method === 'POST') {
|
|
276
|
-
if (!
|
|
330
|
+
if (!isLocalPanelRequest(req)) {
|
|
277
331
|
json(res, { error: 'forbidden' }, 403);
|
|
278
332
|
return;
|
|
279
333
|
}
|
|
@@ -329,7 +383,7 @@ export function createPanelServer(port) {
|
|
|
329
383
|
// reads and for the *next* agent invocation, but won't flip chain
|
|
330
384
|
// mid-session for an already-running agent. UI copy makes this clear.
|
|
331
385
|
if (p === '/api/chain' && req.method === 'POST') {
|
|
332
|
-
if (!
|
|
386
|
+
if (!isLocalPanelRequest(req)) {
|
|
333
387
|
json(res, { error: 'forbidden' }, 403);
|
|
334
388
|
return;
|
|
335
389
|
}
|
|
@@ -364,6 +418,163 @@ export function createPanelServer(port) {
|
|
|
364
418
|
}
|
|
365
419
|
return;
|
|
366
420
|
}
|
|
421
|
+
// ─── Phone & Voice ──────────────────────────────────────────────────
|
|
422
|
+
// GET /api/phone/numbers — list wallet-owned numbers (cached)
|
|
423
|
+
// POST /api/phone/numbers/refresh — force-refresh from BlockRun ($0.001)
|
|
424
|
+
// POST /api/phone/numbers/renew — extend lease 30d ($5)
|
|
425
|
+
// POST /api/phone/numbers/buy — provision new number ($5)
|
|
426
|
+
// POST /api/phone/numbers/release — release (free)
|
|
427
|
+
//
|
|
428
|
+
// Renewals are explicit user clicks. No silent auto-renew: a wallet
|
|
429
|
+
// that runs dry between charges would fail the renewal and surprise
|
|
430
|
+
// the user. Notifications at T-7/3/1 days keep them in the loop.
|
|
431
|
+
//
|
|
432
|
+
// All spendful/mutating endpoints are loopback + same-origin because
|
|
433
|
+
// they spend money out of the user's wallet. Even the list endpoint can
|
|
434
|
+
// cost $0.001 on cache miss, so it gets the same browser-origin guard.
|
|
435
|
+
if (p === '/api/phone/numbers' && (!req.method || req.method === 'GET')) {
|
|
436
|
+
if (!isLocalPanelRequest(req)) {
|
|
437
|
+
json(res, { error: 'forbidden', numbers: [] }, 403);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const wallet = await currentWalletAddress();
|
|
442
|
+
const chain = loadChain();
|
|
443
|
+
const cache = readPhoneCache();
|
|
444
|
+
if (cache && isPhoneCacheFresh(cache, wallet, chain)) {
|
|
445
|
+
json(res, {
|
|
446
|
+
wallet,
|
|
447
|
+
chain,
|
|
448
|
+
fetchedAt: cache.fetchedAt,
|
|
449
|
+
fromCache: true,
|
|
450
|
+
numbers: cache.numbers,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Cache stale or missing — fetch fresh (costs $0.001).
|
|
455
|
+
// We pay through the panel's wallet, which is the same wallet
|
|
456
|
+
// that owns the numbers, so the gateway returns this user's list.
|
|
457
|
+
const fresh = await gatewayListNumbers({ walletAddress: wallet });
|
|
458
|
+
json(res, {
|
|
459
|
+
wallet,
|
|
460
|
+
chain,
|
|
461
|
+
fetchedAt: Date.now(),
|
|
462
|
+
fromCache: false,
|
|
463
|
+
paid: fresh.paid,
|
|
464
|
+
numbers: fresh.numbers,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
json(res, { error: err.message, numbers: [] }, 500);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (p === '/api/phone/numbers/refresh' && req.method === 'POST') {
|
|
473
|
+
if (!isLocalPanelRequest(req)) {
|
|
474
|
+
json(res, { error: 'forbidden' }, 403);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const wallet = await currentWalletAddress();
|
|
479
|
+
clearPhoneCache();
|
|
480
|
+
const fresh = await gatewayListNumbers({ walletAddress: wallet });
|
|
481
|
+
broadcast({ type: 'phone.refreshed' });
|
|
482
|
+
json(res, {
|
|
483
|
+
wallet,
|
|
484
|
+
chain: loadChain(),
|
|
485
|
+
paid: fresh.paid,
|
|
486
|
+
numbers: fresh.numbers,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
json(res, { error: err.message }, 500);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (p === '/api/phone/numbers/renew' && req.method === 'POST') {
|
|
495
|
+
if (!isLocalPanelRequest(req)) {
|
|
496
|
+
json(res, { error: 'forbidden' }, 403);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const raw = await readBody(req);
|
|
501
|
+
const body = JSON.parse(raw);
|
|
502
|
+
const target = (body.phoneNumber || '').trim();
|
|
503
|
+
if (!target) {
|
|
504
|
+
json(res, { error: 'phoneNumber required' }, 400);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const result = await gatewayRenewNumber(target);
|
|
508
|
+
// Patch the cache in place so the panel UI gets the new expiry
|
|
509
|
+
// without a follow-up $0.001 list call.
|
|
510
|
+
const cache = readPhoneCache();
|
|
511
|
+
if (cache) {
|
|
512
|
+
const idx = cache.numbers.findIndex(n => n.phone_number === target);
|
|
513
|
+
if (idx >= 0) {
|
|
514
|
+
cache.numbers[idx] = {
|
|
515
|
+
...cache.numbers[idx],
|
|
516
|
+
expires_at: result.expires_at,
|
|
517
|
+
active: true,
|
|
518
|
+
};
|
|
519
|
+
writePhoneCache({ wallet: cache.wallet, chain: cache.chain, numbers: cache.numbers });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
broadcast({ type: 'phone.renewed', phoneNumber: target, expires_at: result.expires_at });
|
|
523
|
+
json(res, { ok: true, ...result });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
json(res, { error: err.message }, 500);
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (p === '/api/phone/numbers/buy' && req.method === 'POST') {
|
|
531
|
+
if (!isLocalPanelRequest(req)) {
|
|
532
|
+
json(res, { error: 'forbidden' }, 403);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const raw = await readBody(req);
|
|
537
|
+
const body = JSON.parse(raw);
|
|
538
|
+
const result = await gatewayBuyNumber({
|
|
539
|
+
country: body.country,
|
|
540
|
+
areaCode: body.areaCode,
|
|
541
|
+
});
|
|
542
|
+
clearPhoneCache(); // forces next /api/phone/numbers to re-list
|
|
543
|
+
broadcast({ type: 'phone.bought', phoneNumber: result.phone_number });
|
|
544
|
+
json(res, { ok: true, ...result });
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
json(res, { error: err.message }, 500);
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (p === '/api/phone/numbers/release' && req.method === 'POST') {
|
|
552
|
+
if (!isLocalPanelRequest(req)) {
|
|
553
|
+
json(res, { error: 'forbidden' }, 403);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const raw = await readBody(req);
|
|
558
|
+
const body = JSON.parse(raw);
|
|
559
|
+
const target = (body.phoneNumber || '').trim();
|
|
560
|
+
if (!target) {
|
|
561
|
+
json(res, { error: 'phoneNumber required' }, 400);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const result = await gatewayReleaseNumber(target);
|
|
565
|
+
const cache = readPhoneCache();
|
|
566
|
+
if (cache) {
|
|
567
|
+
const next = cache.numbers.filter(n => n.phone_number !== target);
|
|
568
|
+
writePhoneCache({ wallet: cache.wallet, chain: cache.chain, numbers: next });
|
|
569
|
+
}
|
|
570
|
+
broadcast({ type: 'phone.released', phoneNumber: target });
|
|
571
|
+
json(res, { ok: true, ...result });
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
json(res, { error: err.message }, 500);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
367
578
|
if (p === '/api/markets') {
|
|
368
579
|
// Snapshot of every active data provider for the Markets panel:
|
|
369
580
|
// pipeline wiring (which endpoint serves which asset class), live
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cache for the user's BlockRun-provisioned phone numbers.
|
|
3
|
+
*
|
|
4
|
+
* Why a cache: `POST /v1/phone/numbers/list` costs $0.001 per call. The
|
|
5
|
+
* panel ticks countdowns once per minute and the terminal status bar
|
|
6
|
+
* re-renders on every prompt cycle — both surfaces hitting the gateway
|
|
7
|
+
* directly would burn pointless micropayments. We hit the gateway only
|
|
8
|
+
* on cache-miss, panel-reload, or after a state-changing call (buy /
|
|
9
|
+
* renew / release).
|
|
10
|
+
*
|
|
11
|
+
* Storage: ~/.blockrun/phone-numbers.json. Read by both the Ink terminal
|
|
12
|
+
* status bar and the web panel, so they always agree on which numbers
|
|
13
|
+
* the wallet owns and how many days remain.
|
|
14
|
+
*/
|
|
15
|
+
import { type Chain } from '../config.js';
|
|
16
|
+
export interface PhoneNumberRecord {
|
|
17
|
+
phone_number: string;
|
|
18
|
+
chain: Chain;
|
|
19
|
+
expires_at: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface CacheFile {
|
|
23
|
+
fetchedAt: number;
|
|
24
|
+
wallet: string;
|
|
25
|
+
chain: Chain;
|
|
26
|
+
numbers: PhoneNumberRecord[];
|
|
27
|
+
}
|
|
28
|
+
/** 6 hours — long enough to not thrash the gateway, short enough that
|
|
29
|
+
* a number provisioned on another device shows up the same day. */
|
|
30
|
+
export declare const CACHE_TTL_MS: number;
|
|
31
|
+
export declare function readCache(): CacheFile | null;
|
|
32
|
+
export declare function writeCache(data: {
|
|
33
|
+
wallet: string;
|
|
34
|
+
chain: Chain;
|
|
35
|
+
numbers: PhoneNumberRecord[];
|
|
36
|
+
}): void;
|
|
37
|
+
export declare function clearCache(): void;
|
|
38
|
+
export declare function isFresh(cache: CacheFile | null, wallet: string, chain: Chain): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Compute days-remaining for a number's lease. Negative when expired.
|
|
41
|
+
* UI uses this for the colour ladder (green / amber / red).
|
|
42
|
+
*/
|
|
43
|
+
export declare function daysRemaining(expiresAt: string): number;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cache for the user's BlockRun-provisioned phone numbers.
|
|
3
|
+
*
|
|
4
|
+
* Why a cache: `POST /v1/phone/numbers/list` costs $0.001 per call. The
|
|
5
|
+
* panel ticks countdowns once per minute and the terminal status bar
|
|
6
|
+
* re-renders on every prompt cycle — both surfaces hitting the gateway
|
|
7
|
+
* directly would burn pointless micropayments. We hit the gateway only
|
|
8
|
+
* on cache-miss, panel-reload, or after a state-changing call (buy /
|
|
9
|
+
* renew / release).
|
|
10
|
+
*
|
|
11
|
+
* Storage: ~/.blockrun/phone-numbers.json. Read by both the Ink terminal
|
|
12
|
+
* status bar and the web panel, so they always agree on which numbers
|
|
13
|
+
* the wallet owns and how many days remain.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
18
|
+
const CACHE_PATH = path.join(BLOCKRUN_DIR, 'phone-numbers.json');
|
|
19
|
+
/** 6 hours — long enough to not thrash the gateway, short enough that
|
|
20
|
+
* a number provisioned on another device shows up the same day. */
|
|
21
|
+
export const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
export function readCache() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || !Array.isArray(parsed.numbers))
|
|
27
|
+
return null;
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function writeCache(data) {
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
37
|
+
const payload = {
|
|
38
|
+
fetchedAt: Date.now(),
|
|
39
|
+
wallet: data.wallet,
|
|
40
|
+
chain: data.chain,
|
|
41
|
+
numbers: data.numbers,
|
|
42
|
+
};
|
|
43
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(payload, null, 2) + '\n', {
|
|
44
|
+
mode: 0o600,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* best-effort — cache is an optimization, not a source of truth */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function clearCache() {
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(CACHE_PATH);
|
|
54
|
+
}
|
|
55
|
+
catch { /* not there, fine */ }
|
|
56
|
+
}
|
|
57
|
+
export function isFresh(cache, wallet, chain) {
|
|
58
|
+
if (!cache)
|
|
59
|
+
return false;
|
|
60
|
+
if (cache.wallet !== wallet)
|
|
61
|
+
return false;
|
|
62
|
+
if (cache.chain !== chain)
|
|
63
|
+
return false;
|
|
64
|
+
return Date.now() - cache.fetchedAt < CACHE_TTL_MS;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compute days-remaining for a number's lease. Negative when expired.
|
|
68
|
+
* UI uses this for the colour ladder (green / amber / red).
|
|
69
|
+
*/
|
|
70
|
+
export function daysRemaining(expiresAt) {
|
|
71
|
+
const expiry = new Date(expiresAt).getTime();
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
return Math.floor((expiry - now) / (24 * 60 * 60 * 1000));
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockRun phone API client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over `/v1/phone/numbers/{list,buy,renew,release}` that
|
|
5
|
+
* handles the x402 payment handshake using the wallet Franklin already
|
|
6
|
+
* loads at startup. Pattern mirrors `src/tools/modal.ts` — first POST
|
|
7
|
+
* returns 402 with payment requirements, we sign with the local wallet,
|
|
8
|
+
* retry once with X-PAYMENT.
|
|
9
|
+
*
|
|
10
|
+
* Used by:
|
|
11
|
+
* - panel server (renew button, buy flow, refresh button)
|
|
12
|
+
* - phone cache refresh (background)
|
|
13
|
+
* - future Phone/Call tools surfaced to the agent
|
|
14
|
+
*/
|
|
15
|
+
import { type Chain } from '../config.js';
|
|
16
|
+
import { type PhoneNumberRecord } from './cache.js';
|
|
17
|
+
export interface ListNumbersResult {
|
|
18
|
+
numbers: PhoneNumberRecord[];
|
|
19
|
+
count: number;
|
|
20
|
+
/** $0.001 was paid for this list call. UI can show it. */
|
|
21
|
+
paid: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fetch the wallet's owned numbers from BlockRun. Refreshes the local
|
|
25
|
+
* cache on success so the terminal status bar picks up the same data.
|
|
26
|
+
*/
|
|
27
|
+
export declare function listNumbers(opts: {
|
|
28
|
+
walletAddress: string;
|
|
29
|
+
}): Promise<ListNumbersResult>;
|
|
30
|
+
export interface RenewResult {
|
|
31
|
+
phone_number: string;
|
|
32
|
+
expires_at: string;
|
|
33
|
+
paid: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function renewNumber(phoneNumber: string): Promise<RenewResult>;
|
|
36
|
+
export interface BuyResult {
|
|
37
|
+
phone_number: string;
|
|
38
|
+
expires_at: string;
|
|
39
|
+
chain: Chain;
|
|
40
|
+
paid: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function buyNumber(opts: {
|
|
43
|
+
country?: string;
|
|
44
|
+
areaCode?: string;
|
|
45
|
+
}): Promise<BuyResult>;
|
|
46
|
+
export interface ReleaseResult {
|
|
47
|
+
released: boolean;
|
|
48
|
+
phone_number: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function releaseNumber(phoneNumber: string): Promise<ReleaseResult>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockRun phone API client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over `/v1/phone/numbers/{list,buy,renew,release}` that
|
|
5
|
+
* handles the x402 payment handshake using the wallet Franklin already
|
|
6
|
+
* loads at startup. Pattern mirrors `src/tools/modal.ts` — first POST
|
|
7
|
+
* returns 402 with payment requirements, we sign with the local wallet,
|
|
8
|
+
* retry once with X-PAYMENT.
|
|
9
|
+
*
|
|
10
|
+
* Used by:
|
|
11
|
+
* - panel server (renew button, buy flow, refresh button)
|
|
12
|
+
* - phone cache refresh (background)
|
|
13
|
+
* - future Phone/Call tools surfaced to the agent
|
|
14
|
+
*/
|
|
15
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
16
|
+
import { loadChain, API_URLS, USER_AGENT } from '../config.js';
|
|
17
|
+
import { writeCache } from './cache.js';
|
|
18
|
+
function phoneEndpoint(chain, path) {
|
|
19
|
+
return `${API_URLS[chain]}/v1/phone/${path}`;
|
|
20
|
+
}
|
|
21
|
+
async function extractPaymentReq(response) {
|
|
22
|
+
let header = response.headers.get('payment-required');
|
|
23
|
+
if (!header) {
|
|
24
|
+
try {
|
|
25
|
+
const body = (await response.clone().json());
|
|
26
|
+
if (body.x402 || body.accepts)
|
|
27
|
+
header = btoa(JSON.stringify(body));
|
|
28
|
+
}
|
|
29
|
+
catch { /* not JSON, no header */ }
|
|
30
|
+
}
|
|
31
|
+
return header;
|
|
32
|
+
}
|
|
33
|
+
async function signPayment(response, chain, endpoint, resourceDescription) {
|
|
34
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
35
|
+
if (!paymentHeader)
|
|
36
|
+
return null;
|
|
37
|
+
if (chain === 'solana') {
|
|
38
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
39
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
40
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
41
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
42
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
43
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
44
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
45
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
46
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
47
|
+
extra: details.extra,
|
|
48
|
+
});
|
|
49
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const wallet = getOrCreateWallet();
|
|
53
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
54
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
55
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
56
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
57
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
58
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
59
|
+
extra: details.extra,
|
|
60
|
+
});
|
|
61
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function postWithPayment(endpoint, body, resourceDescription, timeoutMs = 30_000) {
|
|
65
|
+
const chain = loadChain();
|
|
66
|
+
const headers = {
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
'User-Agent': USER_AGENT,
|
|
69
|
+
};
|
|
70
|
+
const ctrl = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const payload = JSON.stringify(body);
|
|
74
|
+
let response = await fetch(endpoint, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
signal: ctrl.signal,
|
|
77
|
+
headers,
|
|
78
|
+
body: payload,
|
|
79
|
+
});
|
|
80
|
+
if (response.status === 402) {
|
|
81
|
+
const paymentHeaders = await signPayment(response, chain, endpoint, resourceDescription);
|
|
82
|
+
if (!paymentHeaders) {
|
|
83
|
+
return { ok: false, status: 402, body: { error: 'payment signing failed' }, raw: '' };
|
|
84
|
+
}
|
|
85
|
+
response = await fetch(endpoint, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
signal: ctrl.signal,
|
|
88
|
+
headers: { ...headers, ...paymentHeaders },
|
|
89
|
+
body: payload,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const raw = await response.text().catch(() => '');
|
|
93
|
+
let parsed = {};
|
|
94
|
+
try {
|
|
95
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
96
|
+
}
|
|
97
|
+
catch { /* leave as {} */ }
|
|
98
|
+
return { ok: response.ok, status: response.status, body: parsed, raw };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetch the wallet's owned numbers from BlockRun. Refreshes the local
|
|
106
|
+
* cache on success so the terminal status bar picks up the same data.
|
|
107
|
+
*/
|
|
108
|
+
export async function listNumbers(opts) {
|
|
109
|
+
const chain = loadChain();
|
|
110
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/list'), {}, 'List wallet-owned BlockRun phone numbers');
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
113
|
+
throw new Error(message);
|
|
114
|
+
}
|
|
115
|
+
const numbers = Array.isArray(result.body.numbers)
|
|
116
|
+
? result.body.numbers
|
|
117
|
+
: [];
|
|
118
|
+
writeCache({ wallet: opts.walletAddress, chain, numbers });
|
|
119
|
+
return { numbers, count: numbers.length, paid: 0.001 };
|
|
120
|
+
}
|
|
121
|
+
export async function renewNumber(phoneNumber) {
|
|
122
|
+
const chain = loadChain();
|
|
123
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/renew'), { phoneNumber }, `Renew BlockRun phone number ${phoneNumber}`);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
126
|
+
throw new Error(message);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
phone_number: String(result.body.phone_number ?? phoneNumber),
|
|
130
|
+
expires_at: String(result.body.expires_at ?? ''),
|
|
131
|
+
paid: 5.0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export async function buyNumber(opts) {
|
|
135
|
+
const chain = loadChain();
|
|
136
|
+
const body = { country: opts.country || 'US' };
|
|
137
|
+
if (opts.areaCode)
|
|
138
|
+
body.areaCode = opts.areaCode;
|
|
139
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/buy'), body, `Provision a new BlockRun phone number (${opts.country || 'US'})`);
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
142
|
+
throw new Error(message);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
phone_number: String(result.body.phone_number ?? ''),
|
|
146
|
+
expires_at: String(result.body.expires_at ?? ''),
|
|
147
|
+
chain: result.body.chain || chain,
|
|
148
|
+
paid: 5.0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export async function releaseNumber(phoneNumber) {
|
|
152
|
+
const chain = loadChain();
|
|
153
|
+
const result = await postWithPayment(phoneEndpoint(chain, 'numbers/release'), { phoneNumber }, `Release BlockRun phone number ${phoneNumber}`);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
|
|
156
|
+
throw new Error(message);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
released: Boolean(result.body.released),
|
|
160
|
+
phone_number: String(result.body.phone_number ?? phoneNumber),
|
|
161
|
+
};
|
|
162
|
+
}
|
package/package.json
CHANGED