diff --git a/apps/web/public/settings.html b/apps/web/public/settings.html
index 00ebf85..5e3837d 100644
--- a/apps/web/public/settings.html
+++ b/apps/web/public/settings.html
@@ -46,6 +46,14 @@
+ CoinPay account
+ Sign in to TronBrowser with your CoinPay account. One CoinPay account works across every Profullstack app.
+
+
Default AI provider
Used by the AI sidebar. Pick a provider and model below; keys sync to your account (cloud).
diff --git a/apps/web/public/settings.js b/apps/web/public/settings.js
index f905502..d0f9455 100644
--- a/apps/web/public/settings.js
+++ b/apps/web/public/settings.js
@@ -63,9 +63,35 @@ async function requireAuth() {
const me = await api('/api/auth/me').then((r) => r.json()).catch(() => ({}));
if (!me.signedIn) { location.href = '/login'; return null; }
$('who').textContent = me.email || (me.id ? me.id.slice(0, 8) : 'signed in');
+ showCoinpay(me);
return me;
}
+// Settings -> CoinPay: Connect (signed in only) or Disconnect (refused when
+// CoinPay is the only way into the account).
+const COINPAY_MSG = {
+ connected: 'CoinPay connected.',
+ linked_elsewhere: 'That CoinPay account is already connected to another TronBrowser account.',
+ already_connected: 'This account is already connected to a different CoinPay account. Disconnect it first.',
+ not_signed_in: 'Sign in first, then connect CoinPay.',
+};
+function showCoinpay(me) {
+ const flash = COINPAY_MSG[new URLSearchParams(location.search).get('coinpay')] || '';
+ $('coinpayConnect').hidden = !!me.coinpayConnected;
+ $('coinpayDisconnect').hidden = !me.canDisconnectCoinpay;
+ $('coinpayState').textContent = (flash ? flash + ' ' : '') + (me.coinpayConnected
+ ? (me.canDisconnectCoinpay ? 'Connected.' : 'Connected. This account was made with CoinPay, so it stays connected.')
+ : 'Not connected.');
+}
+$('coinpayDisconnect').addEventListener('click', async () => {
+ if (!confirm('Disconnect CoinPay from this account?')) return;
+ const r = await api('/api/auth/coinpay/disconnect', { method: 'POST' });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) { $('coinpayState').textContent = d.error || 'Could not disconnect.'; return; }
+ const me = await api('/api/auth/me').then((x) => x.json()).catch(() => ({}));
+ showCoinpay(me);
+});
+
// A provider counts as "configured" once it has a key (cloud) or base URL (local).
function isConfigured(p, cur) {
return p.keyless ? !!(cur && cur.baseUrl) : !!(cur && cur.apiKey);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba01585..706a953 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -290,19 +290,19 @@ importers:
version: 1.3.1(@hono/node-server@1.19.17(hono@4.13.14))(hono@4.13.14)
'@langchain/anthropic':
specifier: ^1.6.0
- version: 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
+ version: 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
'@langchain/langgraph':
specifier: ^1.5.2
- version: 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76)
+ version: 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)
'@langchain/openai':
specifier: ^1.7.0
- version: 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0)
+ version: 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0)
'@logicsrc/agentswarm':
specifier: ^0.1.0
- version: 0.1.0(l473pxqvlkadjwuwp52lekqhce)
+ version: 0.1.0(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))
'@profullstack/coinpay-onboard':
- specifier: 0.2.0
- version: 0.2.0(@cfworker/json-schema@4.1.1)
+ specifier: 0.3.1
+ version: 0.3.1(@cfworker/json-schema@4.1.1)
'@profullstack/libsql-pg':
specifier: ^0.1.4
version: 0.1.4
@@ -317,7 +317,7 @@ importers:
version: link:../../packages/sdk
deepagents:
specifier: ^1.15.1
- version: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
+ version: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
fflate:
specifier: ^0.8.3
version: 0.8.3
@@ -1306,8 +1306,8 @@ packages:
'@oxc-project/types@0.155.0':
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
- '@profullstack/coinpay-onboard@0.2.0':
- resolution: {integrity: sha512-FxIiTefcAswyCyEapEcC+vSai3IZcOPPxaBGnT7L/VPGfZJYjH1kHoN6DCRMBQx0l2IbJ2aDLqrYUhqRUaI2cA==}
+ '@profullstack/coinpay-onboard@0.3.1':
+ resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==}
engines: {node: '>=20'}
hasBin: true
@@ -2884,6 +2884,7 @@ packages:
libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
+ cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32]
lighthouse-logger@1.4.2:
@@ -5242,18 +5243,18 @@ snapshots:
'@keyv/serialize@1.1.1': {}
- '@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))':
+ '@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))':
dependencies:
'@anthropic-ai/sdk': 0.129.0(zod@4.6.7)
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
zod: 4.6.7
- '@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)':
+ '@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
js-tiktoken: 1.0.21
- langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
mustache: 4.2.0
p-queue: 6.6.2
zod: 4.6.7
@@ -5264,13 +5265,13 @@ snapshots:
- openai
- ws
- '@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))':
+ '@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))':
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
- '@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)':
+ '@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)':
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
'@langchain/protocol': 0.0.20
'@types/json-schema': 7.0.15
p-queue: 9.3.5
@@ -5278,23 +5279,11 @@ snapshots:
optionalDependencies:
react: 19.2.3
- '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76)':
+ '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)':
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
- '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
- '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)
- '@langchain/protocol': 0.0.20
- '@standard-schema/spec': 1.1.0
- zod: 3.25.76
- transitivePeerDependencies:
- - react
- - react-dom
-
- '@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)':
- dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
- '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
- '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
+ '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
+ '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)
'@langchain/protocol': 0.0.20
'@standard-schema/spec': 1.1.0
zod: 4.6.7
@@ -5302,9 +5291,9 @@ snapshots:
- react
- react-dom
- '@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0)':
+ '@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0)':
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
js-tiktoken: 1.0.21
openai: 7.43.0(ws@8.22.0)(zod@4.6.7)
zod: 4.6.7
@@ -5407,12 +5396,12 @@ snapshots:
'@libsql/win32-x64-msvc@0.5.29':
optional: true
- '@logicsrc/agentswarm@0.1.0(l473pxqvlkadjwuwp52lekqhce)':
+ '@logicsrc/agentswarm@0.1.0(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))':
optionalDependencies:
- '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76)
- deepagents: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
+ '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)
+ deepagents: 1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
- '@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(zod@3.25.76)':
+ '@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.14)
ajv: 8.20.0
@@ -5429,8 +5418,8 @@ snapshots:
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
- zod: 3.25.76
- zod-to-json-schema: 3.25.2(zod@3.25.76)
+ zod: 4.6.7
+ zod-to-json-schema: 3.25.2(zod@4.6.7)
optionalDependencies:
'@cfworker/json-schema': 4.1.1
transitivePeerDependencies:
@@ -5455,10 +5444,10 @@ snapshots:
'@oxc-project/types@0.155.0': {}
- '@profullstack/coinpay-onboard@0.2.0(@cfworker/json-schema@4.1.1)':
+ '@profullstack/coinpay-onboard@0.3.1(@cfworker/json-schema@4.1.1)':
dependencies:
- '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(zod@3.25.76)
- zod: 3.25.76
+ '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(zod@4.6.7)
+ zod: 4.6.7
transitivePeerDependencies:
- '@cfworker/json-schema'
- supports-color
@@ -6348,21 +6337,21 @@ snapshots:
deep-is@0.1.4: {}
- deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)):
+ deepagents@1.15.1(@langchain/anthropic@1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(@langchain/langgraph-checkpoint@1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)))(@langchain/langgraph-sdk@1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3))(@langchain/langgraph@1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7))(@langchain/openai@1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0))(langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0))(langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)):
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
- '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@3.25.76)
- '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
- '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
+ '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)
+ '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
+ '@langchain/langgraph-sdk': 1.12.7(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)
fast-glob: 3.3.3
- langchain: 1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0)
- langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ langchain: 1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0)
+ langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
micromatch: 4.0.8
yaml: 2.9.1
zod: 4.6.7
optionalDependencies:
- '@langchain/anthropic': 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
- '@langchain/openai': 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(ws@8.22.0)
+ '@langchain/anthropic': 1.6.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
+ '@langchain/openai': 1.7.0(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(ws@8.22.0)
deepmerge@4.3.1: {}
@@ -7019,12 +7008,12 @@ snapshots:
lan-network@0.2.1: {}
- langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(react@19.2.3)(ws@8.22.0):
+ langchain@1.5.19(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(react@19.2.3)(ws@8.22.0):
dependencies:
- '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
- '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)
- '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0))
- langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0)
+ '@langchain/core': 1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
+ '@langchain/langgraph': 1.5.2(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))(react@19.2.3)(zod@4.6.7)
+ '@langchain/langgraph-checkpoint': 1.2.1(@langchain/core@1.3.0(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0))
+ langsmith: 0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0)
zod: 4.6.7
transitivePeerDependencies:
- '@opentelemetry/api'
@@ -7035,11 +7024,11 @@ snapshots:
- react-dom
- ws
- langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@3.25.76))(ws@8.22.0):
+ langsmith@0.11.1(openai@7.43.0(ws@8.22.0)(zod@4.6.7))(ws@8.22.0):
dependencies:
p-queue: 6.6.2
optionalDependencies:
- openai: 7.43.0(ws@8.22.0)(zod@3.25.76)
+ openai: 7.43.0(ws@8.22.0)(zod@4.6.7)
ws: 8.22.0
leven@3.1.0: {}
@@ -7629,12 +7618,6 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
- openai@7.43.0(ws@8.22.0)(zod@3.25.76):
- optionalDependencies:
- ws: 8.22.0
- zod: 3.25.76
- optional: true
-
openai@7.43.0(ws@8.22.0)(zod@4.6.7):
optionalDependencies:
ws: 8.22.0
@@ -8500,9 +8483,9 @@ snapshots:
yocto-queue@0.1.0: {}
- zod-to-json-schema@3.25.2(zod@3.25.76):
+ zod-to-json-schema@3.25.2(zod@4.6.7):
dependencies:
- zod: 3.25.76
+ zod: 4.6.7
zod@3.25.76: {}
diff --git a/services/api/package.json b/services/api/package.json
index 85b93bf..4dd3a47 100644
--- a/services/api/package.json
+++ b/services/api/package.json
@@ -20,7 +20,7 @@
"@langchain/langgraph": "^1.5.2",
"@langchain/openai": "^1.7.0",
"@logicsrc/agentswarm": "^0.1.0",
- "@profullstack/coinpay-onboard": "0.2.0",
+ "@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/libsql-pg": "^0.1.4",
"@profullstack/profile": "0.1.6",
"@profullstack/x402-gateway": "^0.13.1",
diff --git a/services/api/src/coinpay-login.test.ts b/services/api/src/coinpay-login.test.ts
index ee2fb91..bfcef0d 100644
--- a/services/api/src/coinpay-login.test.ts
+++ b/services/api/src/coinpay-login.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
-import { coinpayEmail, releasableEmailHolder, resolveCoinpayUser, type CoinpayUser } from './coinpay-login.js';
+import { authorizeUrl, checkIdToken, pkce, randomToken, sameState } from '@profullstack/coinpay-onboard/oauth';
+import {
+ coinpayEmail, coinpayIssuer, connectCoinpay, decodePending, encodePending, releasableEmailHolder, resolveCoinpayUser, type CoinpayUser,
+} from './coinpay-login.js';
function fakeDb(seed: CoinpayUser[]) {
const rows = [...seed];
@@ -79,3 +82,80 @@ describe('coinpayEmail', () => {
expect(coinpayEmail({})).toBeNull();
});
});
+
+
+describe('connectCoinpay (settings)', () => {
+ const linkFake = (rows: CoinpayUser[]) => ({
+ userByCoinpaySub: async (sub: string) => rows.find((r) => r.coinpay_sub === sub) ?? null,
+ linkCoinpaySub: async (id: string, sub: string) => {
+ if (rows.some((r) => r.coinpay_sub === sub)) return 'conflict' as const;
+ const u = rows.find((r) => r.id === id);
+ if (!u || u.coinpay_sub) return 'conflict' as const;
+ u.coinpay_sub = sub;
+ return 'ok' as const;
+ },
+ });
+
+ it('needs a signed-in session', async () => {
+ const rows = [{ ...victim }];
+ expect(await connectCoinpay(linkFake(rows), null, 's')).toEqual({ ok: false, code: 'not_signed_in' });
+ expect(rows[0].coinpay_sub).toBeNull();
+ });
+
+ it('links the sub to the signed-in account, and a later CoinPay login reaches it', async () => {
+ const rows = [{ ...victim }];
+ const deps = linkFake(rows);
+ expect(await connectCoinpay(deps, rows[0], 'vs')).toEqual({ ok: true });
+ const { deps: loginDeps } = fakeDb(rows);
+ expect((await resolveCoinpayUser(loginDeps, 'vs', { email: 'someone@else.example' })).id).toBe('victim');
+ });
+
+ it('refuses a sub bound to another account, or a second CoinPay account', async () => {
+ const other: CoinpayUser = { id: 'o', auth_method: 'coinpay', coinpay_sub: 'taken', email: null, email_verified: 0 };
+ const rows = [{ ...victim }, other];
+ expect(await connectCoinpay(linkFake(rows), rows[0], 'taken')).toEqual({ ok: false, code: 'linked_elsewhere' });
+ rows[0].coinpay_sub = 'mine';
+ expect(await connectCoinpay(linkFake(rows), rows[0], 'new')).toEqual({ ok: false, code: 'already_connected' });
+ });
+});
+
+describe('PKCE, state and nonce', () => {
+ it('the authorize URL carries PKCE S256, state, nonce and the exact redirect', () => {
+ const { verifier, challenge } = pkce();
+ const state = randomToken();
+ const nonce = randomToken();
+ const u = new URL(authorizeUrl({ base: coinpayIssuer('https://coinpayportal.com/api/oauth/authorize'), clientId: 'cp_x', redirectUri: 'https://tronbrowser.dev/api/auth/coinpay/callback', scopes: ['openid', 'profile', 'email'], state, nonce, challenge }));
+ expect(u.origin + u.pathname).toBe('https://coinpayportal.com/api/oauth/authorize');
+ expect(u.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(u.searchParams.get('code_challenge')).toBe(challenge);
+ expect(u.searchParams.get('state')).toBe(state);
+ expect(u.searchParams.get('nonce')).toBe(nonce);
+ expect(u.searchParams.get('scope')).toBe('openid profile email');
+ expect(verifier).not.toBe(challenge);
+ });
+
+ it('the pending cookie round-trips and junk is refused', () => {
+ const p = { state: 's', nonce: 'n', verifier: 'v', mode: 'connect' as const, redirect: '/x' };
+ expect(decodePending(encodePending(p))).toEqual(p);
+ expect(decodePending('junk')).toBeNull();
+ expect(decodePending(undefined)).toBeNull();
+ });
+
+ it('a forged or missing state is refused', () => {
+ expect(sameState('abc', 'abc')).toBe(true);
+ expect(sameState('abc', 'abd')).toBe(false);
+ expect(sameState('abc', undefined)).toBe(false);
+ });
+
+ it('an id_token with the wrong nonce is refused', () => {
+ const jwt = (c: object) => ['h', Buffer.from(JSON.stringify(c)).toString('base64url'), 's'].join('.');
+ const good = { iss: 'https://coinpayportal.com', aud: 'cp_x', sub: 's', nonce: 'n1', exp: Math.floor(Date.now() / 1000) + 60 };
+ expect(checkIdToken({ idToken: jwt(good), clientId: 'cp_x', nonce: 'n1' }).sub).toBe('s');
+ expect(() => checkIdToken({ idToken: jwt({ ...good, nonce: 'evil' }), clientId: 'cp_x', nonce: 'n1' })).toThrow();
+ });
+
+ it('derives the issuer from the configured authorize URL', () => {
+ expect(coinpayIssuer('https://pay.example.com/api/oauth/authorize')).toBe('https://pay.example.com');
+ expect(coinpayIssuer(undefined)).toBe('https://coinpayportal.com');
+ });
+});
diff --git a/services/api/src/coinpay-login.ts b/services/api/src/coinpay-login.ts
index b361f05..6daffe7 100644
--- a/services/api/src/coinpay-login.ts
+++ b/services/api/src/coinpay-login.ts
@@ -68,3 +68,62 @@ export async function resolveCoinpayUser(
export function releasableEmailHolder(holder: CoinpayUser | null): boolean {
return !!holder && holder.auth_method === 'coinpay' && !holder.password_hash && !holder.email_verified;
}
+
+/* ---------------------------------------------------------------- connect -- */
+
+export type ConnectResult = { ok: true } | { ok: false; code: 'not_signed_in' | 'linked_elsewhere' | 'already_connected' };
+
+/**
+ * Connect CoinPay to the signed-in account (settings). Only ever with a session:
+ * a CoinPay login never claims an account by its email.
+ */
+export async function connectCoinpay(
+ deps: {
+ userByCoinpaySub(sub: string): Promise;
+ linkCoinpaySub(userId: string, sub: string): Promise<'ok' | 'conflict'>;
+ },
+ current: { id: string; coinpay_sub: string | null } | null,
+ sub: string,
+): Promise {
+ if (!current) return { ok: false, code: 'not_signed_in' };
+ const bound = await deps.userByCoinpaySub(sub);
+ if (bound) return bound.id === current.id ? { ok: true } : { ok: false, code: 'linked_elsewhere' };
+ if (current.coinpay_sub) return { ok: false, code: 'already_connected' };
+ return (await deps.linkCoinpaySub(current.id, sub)) === 'ok' ? { ok: true } : { ok: false, code: 'linked_elsewhere' };
+}
+
+/* ---------------------------------------------------- pending OAuth request -- */
+
+/**
+ * What the start route keeps in a short-lived httpOnly cookie: state, the OIDC
+ * nonce, the PKCE verifier, the mode, and where to land.
+ */
+export interface PendingCoinpay {
+ state: string;
+ nonce: string;
+ verifier: string;
+ mode: 'login' | 'connect';
+ redirect: string;
+}
+
+export function encodePending(p: PendingCoinpay): string {
+ return Buffer.from(JSON.stringify({ s: p.state, n: p.nonce, v: p.verifier, m: p.mode, r: p.redirect })).toString('base64url');
+}
+
+export function decodePending(raw: string | undefined | null): PendingCoinpay | null {
+ if (!raw) return null;
+ try {
+ const o = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
+ if (!o || typeof o.s !== 'string' || typeof o.n !== 'string' || typeof o.v !== 'string') return null;
+ if (o.m !== 'login' && o.m !== 'connect') return null;
+ return { state: o.s, nonce: o.n, verifier: o.v, mode: o.m, redirect: typeof o.r === 'string' ? o.r : '' };
+ } catch {
+ return null;
+ }
+}
+
+/** The CoinPay issuer from an endpoint URL (`/api/oauth/`), else the hosted one. */
+export function coinpayIssuer(endpoint: string | undefined): string {
+ const m = /^(https?:\/\/[^/]+(?:\/.*?)?)\/api\/oauth\/[a-z_]+\/?$/.exec(String(endpoint ?? ''));
+ return m?.[1] ?? 'https://coinpayportal.com';
+}
diff --git a/services/api/src/db.ts b/services/api/src/db.ts
index b0c2b45..2c4f7b2 100644
--- a/services/api/src/db.ts
+++ b/services/api/src/db.ts
@@ -84,6 +84,35 @@ export async function createUser(u: {
});
}
+/**
+ * Connect CoinPay to a signed-in account: set its sub only while the account has
+ * none. 'conflict' when the sub is already bound elsewhere (coinpay_sub is UNIQUE)
+ * or the account already has a different one.
+ */
+export async function linkCoinpaySub(userId: string, sub: string): Promise<'ok' | 'conflict'> {
+ try {
+ const r = await db().execute({
+ sql: "UPDATE users SET coinpay_sub = ?, updated_at = datetime('now') WHERE id = ? AND coinpay_sub IS NULL",
+ args: [sub, userId],
+ });
+ return r.rowsAffected ? 'ok' : 'conflict';
+ } catch (err) {
+ if (/unique|duplicate/i.test(String((err as Error)?.message ?? err))) return 'conflict';
+ throw err;
+ }
+}
+
+/** Disconnect CoinPay (callers check the account has a password first). */
+export async function unlinkCoinpaySub(userId: string): Promise {
+ await db().execute({ sql: "UPDATE users SET coinpay_sub = NULL, updated_at = datetime('now') WHERE id = ?", args: [userId] });
+}
+
+/** Whether the account can sign in without CoinPay (it has a password). */
+export async function hasPassword(userId: string): Promise {
+ const r = await db().execute({ sql: 'SELECT password_hash FROM users WHERE id = ?', args: [userId] });
+ return !!(r.rows[0] as unknown as { password_hash: string | null } | undefined)?.password_hash;
+}
+
/** Drop an unverified email from an account (see releasableEmailHolder). */
export async function releaseEmail(userId: string): Promise {
await db().execute({ sql: "UPDATE users SET email = NULL, updated_at = datetime('now') WHERE id = ? AND email_verified = 0", args: [userId] });
diff --git a/services/api/src/index.ts b/services/api/src/index.ts
index fd9c7a8..0be2e57 100644
--- a/services/api/src/index.ts
+++ b/services/api/src/index.ts
@@ -6,7 +6,7 @@ import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import {
createUser, userByCoinpaySub, userByEmail, createSession, userBySession,
deleteSession, putEmailToken, consumeEmailToken, setEmailVerified, releaseEmail,
- getSettings, putSettings, type User,
+ getSettings, putSettings, linkCoinpaySub, unlinkCoinpaySub, hasPassword, type User,
} from './db.js';
import { token, uuid, hashPassword, verifyPassword, SESSION_TTL, EMAIL_TOKEN_TTL } from './auth.js';
import { sendEmail } from './email.js';
@@ -16,7 +16,12 @@ import { dnsRoutes } from './dns.js';
import { tronRelay } from './mcp/tron.js';
import { safeRedirect } from './redirect.js';
import { extLoginTarget } from './ext-login.js';
-import { resolveCoinpayUser, releasableEmailHolder } from './coinpay-login.js';
+import {
+ resolveCoinpayUser, releasableEmailHolder, connectCoinpay, encodePending, decodePending, coinpayIssuer,
+} from './coinpay-login.js';
+import {
+ SIGN_IN_SCOPES, authorizeUrl as coinpayAuthorizeUrl, finishSignIn, pkce, randomToken, sameState,
+} from '@profullstack/coinpay-onboard/oauth';
import { assertDatabaseUrl, db } from './db.js';
import { pushService } from './push/routes.js';
import { createCrawlGate, crawlCheck } from './crawl-gate.js';
@@ -26,15 +31,19 @@ import { mountProfile, profileStore, saveSignup, signupProfileErrors } from './p
// condition to limp along under (there is no file-database fallback).
assertDatabaseUrl();
+// Sign in with CoinPay: confidential client, auth code + PKCE S256 + state +
+// nonce, client secret on the code exchange, id_token iss/aud/exp/nonce checked
+// and the userinfo sub matched (@profullstack/coinpay-onboard/oauth). Scopes are
+// exactly what sign-in needs; the registered client must include them, or
+// CoinPay silently drops the rest.
const CP = {
clientId: process.env.COINPAY_CLIENT_ID || '',
clientSecret: process.env.COINPAY_CLIENT_SECRET || '',
- redirectUri: process.env.COINPAY_REDIRECT_URI || 'https://tronbrowser.com/api/auth/coinpay/callback',
- authorizeUrl: process.env.COINPAY_AUTHORIZE_URL || 'https://coinpayportal.com/api/oauth/authorize',
- tokenUrl: process.env.COINPAY_TOKEN_URL || 'https://coinpayportal.com/api/oauth/token',
- userinfoUrl: process.env.COINPAY_USERINFO_URL || 'https://coinpayportal.com/api/oauth/userinfo',
- scopes: (process.env.COINPAY_SCOPES || 'openid profile email did wallet:read').split(/\s+/).filter(Boolean),
+ redirectUri: process.env.COINPAY_REDIRECT_URI || 'https://tronbrowser.dev/api/auth/coinpay/callback',
+ base: coinpayIssuer(process.env.COINPAY_AUTHORIZE_URL),
};
+const CP_COOKIE = 'cp_pending';
+const CP_COOKIE_PATH = '/api/auth/coinpay';
const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
const app = new Hono();
@@ -107,65 +116,79 @@ app.get('/api/auth/ext-login', async (c) => {
});
/* ---------- CoinPay OAuth (preferred) ---------- */
-app.get('/api/auth/coinpay/login', (c) => {
- if (!CP.clientId) return c.text('CoinPay not configured', 500);
- const state = token();
- const redirect = c.req.query('redirect') || '';
- setCookie(c, 'cp_state', state, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: 600 });
- setCookie(c, 'cp_redirect', redirect, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', maxAge: 600 });
- const u = new URL(CP.authorizeUrl);
- u.searchParams.set('response_type', 'code');
- u.searchParams.set('client_id', CP.clientId);
- u.searchParams.set('redirect_uri', CP.redirectUri);
- u.searchParams.set('scope', CP.scopes.join(' '));
- u.searchParams.set('state', state);
- return c.redirect(u.toString());
+// mode=login (default) signs in, or makes an account bound to the CoinPay sub.
+// mode=connect links CoinPay to the signed-in account (settings) and needs a
+// session; a CoinPay login never claims an account by its email.
+app.get('/api/auth/coinpay/login', async (c) => {
+ if (!CP.clientId || !CP.clientSecret) return c.text('CoinPay not configured', 500);
+ const mode = c.req.query('mode') === 'connect' ? 'connect' : 'login';
+ if (mode === 'connect' && !(await currentUser(c))) return c.redirect(`${APP_URL}/login`);
+ const { verifier, challenge } = pkce();
+ const pending = { state: randomToken(), nonce: randomToken(), verifier, mode, redirect: c.req.query('redirect') || '' } as const;
+ setCookie(c, CP_COOKIE, encodePending(pending), { httpOnly: true, secure: true, sameSite: 'Lax', path: CP_COOKIE_PATH, maxAge: 600 });
+ c.header('cache-control', 'no-store');
+ return c.redirect(coinpayAuthorizeUrl({
+ base: CP.base,
+ clientId: CP.clientId,
+ redirectUri: CP.redirectUri,
+ scopes: SIGN_IN_SCOPES,
+ state: pending.state,
+ nonce: pending.nonce,
+ challenge,
+ }));
});
app.get('/api/auth/coinpay/callback', async (c) => {
+ const pending = decodePending(getCookie(c, CP_COOKIE));
+ deleteCookie(c, CP_COOKIE, { path: CP_COOKIE_PATH });
+ c.header('cache-control', 'no-store');
const code = c.req.query('code');
- const state = c.req.query('state');
- const expectedState = getCookie(c, 'cp_state');
- // Require a non-empty state that matches the cookie. Without the `!state`/
- // `!expectedState` guards, a missing cookie AND missing state param both read
- // as undefined, so `undefined !== undefined` is false and the CSRF check is
- // silently bypassed (login CSRF).
- if (!code || !state || !expectedState || state !== expectedState) {
+ // A non-empty state that matches the cookie's, compared in constant time.
+ if (!pending || !code || !sameState(pending.state, c.req.query('state'))) {
return c.text('invalid oauth state', 400);
}
- const redirect = safeRedirect(getCookie(c, 'cp_redirect'), APP_URL);
-
- const basic = Buffer.from(`${CP.clientId}:${CP.clientSecret}`).toString('base64');
- const tokRes = await fetch(CP.tokenUrl, {
- method: 'POST',
- headers: {
- 'content-type': 'application/x-www-form-urlencoded',
- authorization: `Basic ${basic}`,
- },
- body: new URLSearchParams({
- grant_type: 'authorization_code', code, redirect_uri: CP.redirectUri,
- client_id: CP.clientId, client_secret: CP.clientSecret,
- }),
- });
- if (!tokRes.ok) return c.text('coinpay token exchange failed: ' + (await tokRes.text()), 502);
- const tok: any = await tokRes.json();
-
- let info: any = {};
+ let claims: { sub: string; email: string | null };
try {
- const r = await fetch(CP.userinfoUrl, { headers: { authorization: `Bearer ${tok.access_token}` } });
- if (r.ok) info = await r.json();
- } catch { /* ignore */ }
- const sub = info.sub || info.id || tok.sub;
- if (!sub) return c.text('coinpay userinfo missing subject', 502);
+ const r = await finishSignIn({
+ code,
+ verifier: pending.verifier,
+ nonce: pending.nonce,
+ clientId: CP.clientId,
+ clientSecret: CP.clientSecret,
+ redirectUri: CP.redirectUri,
+ base: CP.base,
+ });
+ claims = { sub: r.sub, email: r.email };
+ } catch (err) {
+ console.error('[coinpay] sign-in failed:', (err as Error)?.message ?? err);
+ return c.text('coinpay sign-in failed', 502);
+ }
+
+ if (pending.mode === 'connect') {
+ const res = await connectCoinpay({ userByCoinpaySub, linkCoinpaySub }, await currentUser(c), claims.sub);
+ return c.redirect(`${APP_URL}/settings?coinpay=${res.ok ? 'connected' : res.code}`);
+ }
// Bound to the CoinPay sub only. CoinPay never verifies emails, so its email
// never links to an existing account and is never marked verified.
- const user = await resolveCoinpayUser({ userByCoinpaySub, userByEmail, createUser, newId: uuid }, String(sub), info);
- deleteCookie(c, 'cp_state'); deleteCookie(c, 'cp_redirect');
+ const user = await resolveCoinpayUser({ userByCoinpaySub, userByEmail, createUser, newId: uuid }, claims.sub, { email: claims.email });
+ const redirect = safeRedirect(pending.redirect, APP_URL);
const r = await startSession(c, user.id, redirect);
return redirect ? r : c.redirect(`${APP_URL}/?signedin=1`);
});
+// Settings -> Disconnect CoinPay. Refused when CoinPay is the only way in.
+app.post('/api/auth/coinpay/disconnect', async (c) => {
+ const user = await currentUser(c);
+ if (!user) return c.json({ error: 'unauthorized' }, 401);
+ if (!user.coinpay_sub) return c.json({ ok: true });
+ if (!(await hasPassword(user.id))) {
+ return c.json({ error: 'CoinPay is the only way to sign in to this account, so it cannot be disconnected.' }, 409);
+ }
+ await unlinkCoinpaySub(user.id);
+ return c.json({ ok: true });
+});
+
/* ---------- Email + password ---------- */
app.post('/api/auth/signup', async (c) => {
const body = await c.req.json().catch(() => ({}));
@@ -216,7 +239,11 @@ app.post('/api/auth/logout', async (c) => {
app.get('/api/auth/me', async (c) => {
const user = await currentUser(c);
if (!user) return c.json({ signedIn: false });
- return c.json({ signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified });
+ return c.json({
+ signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified,
+ coinpayConnected: !!user.coinpay_sub,
+ canDisconnectCoinpay: !!user.coinpay_sub && (await hasPassword(user.id)),
+ });
});
/* ---------- Shared profile (@profullstack/profile): /api/v1/me/profile ---------- */