From c1df10f09b22942e07fc59655ba7c8dc077e76bc Mon Sep 17 00:00:00 2001 From: Adebanjo Date: Tue, 9 Jun 2026 15:37:59 +0100 Subject: [PATCH] fix: rules-of-hooks crash in onboarding (#300); lock down register-stream with JWT + on-chain ownership --- agent-node/src/server.js | 37 ++++++++++++++----- frontend/src/components/CreateStreamModal.jsx | 2 +- frontend/src/hooks/useAgentStatus.js | 5 ++- frontend/src/pages/app/CreateStream.jsx | 3 ++ frontend/src/pages/app/Setup.jsx | 30 ++++++++------- frontend/src/pages/app/StreamDetail.jsx | 2 +- 6 files changed, 51 insertions(+), 28 deletions(-) diff --git a/agent-node/src/server.js b/agent-node/src/server.js index 6423855..6f54b08 100644 --- a/agent-node/src/server.js +++ b/agent-node/src/server.js @@ -1388,15 +1388,13 @@ app.get('/api/v1/u/:username', async (req, res) => { // ratePerSecond: "1234" (bigint as string) // } -// register-stream — open endpoint, no auth required. -// The frontend calls this immediately after the tx confirms with data decoded -// directly from the StreamCreated event log. We trust it and upsert. -// When the on-chain event listener fires seconds later it also upserts — the -// two calls merge cleanly because stream_registry uses ON CONFLICT DO UPDATE. -// Security: verificationTarget can be set by anyone, but the verification -// engine gates extensions on the contractor's registered GitHub handle, so -// pointing a stream at the wrong repo doesn't help an attacker. -app.post('/api/v1/register-stream', async (req, res) => { +// register-stream — JWT-protected and ownership-checked. +// The frontend calls this immediately after the createStream tx confirms. +// Auth: the caller must hold a valid SIWE JWT AND be the stream's on-chain +// `sender` — otherwise anyone could overwrite a stream's verification routing or +// hours-per-week (which sizes extensions). The on-chain read is the source of +// truth, so you can only register/update a stream you actually created. +app.post('/api/v1/register-stream', verifyJwt, async (req, res) => { const { streamId, repo, @@ -1430,6 +1428,25 @@ app.post('/api/v1/register-stream', async (req, res) => { return res.status(400).json({ error: 'Invalid streamId format' }); } + // Ownership: the JWT caller must be the stream's on-chain sender. The chain is + // the source of truth, so you can only register/update a stream you created. + let onChainSender; + try { + const [onChain] = await readStreamBatch([streamId], resolvedChainId); + onChainSender = onChain?.sender ?? null; + if (!onChainSender || onChainSender === '0x0000000000000000000000000000000000000000') { + console.warn(`[register-stream] ✗ Rejected — stream ${streamId.slice(0, 12)}… not found on-chain`); + return res.status(404).json({ error: 'Stream not found on-chain' }); + } + if (onChainSender.toLowerCase() !== req.callerAddress.toLowerCase()) { + console.warn(`[register-stream] ✗ Rejected — caller ${req.callerAddress.slice(0, 8)}… is not the stream creator`); + return res.status(403).json({ error: 'Only the stream creator can register this stream' }); + } + } catch (err) { + console.error('[register-stream] ownership check failed:', err.message); + return res.status(502).json({ error: 'Could not verify stream ownership on-chain' }); + } + try { const contractAddress = resolvedChainId === 46630 ? (process.env.CONTRACT_ADDRESS_ROBINHOOD || process.env.CONTRACT_ADDRESS || null) @@ -1441,7 +1458,7 @@ app.post('/api/v1/register-stream', async (req, res) => { githubRepo: verificationSource === 'github' || !verificationSource ? resolvedTarget : null, verificationSource: verificationSource ?? 'github', verificationTarget: resolvedTarget, - sender: req.body.sender ?? null, + sender: onChainSender, recipient: recipient ?? null, ratePerSecond: ratePerSecond ?? null, token: token ?? null, diff --git a/frontend/src/components/CreateStreamModal.jsx b/frontend/src/components/CreateStreamModal.jsx index e8b35a1..822b916 100644 --- a/frontend/src/components/CreateStreamModal.jsx +++ b/frontend/src/components/CreateStreamModal.jsx @@ -392,7 +392,7 @@ export default function CreateStreamModal() { const delays = [0, 2000, 4000, 8000]; for (const delay of delays) { if (delay) await new Promise(r => setTimeout(r, delay)); - const result = await registerStreamWithAgent(args); + const result = await registerStreamWithAgent({ ...args, authFetch }); if (result?.success) { setRegStatus('ok'); return; } } setRegStatus('failed'); diff --git a/frontend/src/hooks/useAgentStatus.js b/frontend/src/hooks/useAgentStatus.js index 261b492..17a5b9e 100644 --- a/frontend/src/hooks/useAgentStatus.js +++ b/frontend/src/hooks/useAgentStatus.js @@ -58,11 +58,12 @@ export async function registerStreamWithAgent({ extensionDurationSeconds, hoursPerWeek, chainId, - authFetch, // kept for API compat but not required - endpoint is open + authFetch, // required — the register-stream route is JWT-protected }) { const url = `${AGENT_URL}/api/v1/register-stream`; + const _fetch = authFetch ?? fetch; try { - const res = await fetch(url, { + const res = await _fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/app/CreateStream.jsx b/frontend/src/pages/app/CreateStream.jsx index 9d94b5d..8be4c0f 100644 --- a/frontend/src/pages/app/CreateStream.jsx +++ b/frontend/src/pages/app/CreateStream.jsx @@ -4,6 +4,7 @@ import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadCont import { parseUnits, formatUnits, parseAbiItem, maxUint256 } from 'viem'; import { getContractAddress, ROUTER_ABI } from '../../lib/wagmi'; import { registerStreamWithAgent } from '../../hooks/useAgentStatus'; +import { useAuth } from '../../context/AuthContext'; // ─── Token registry ─────────────────────────────────────────────────────────── const TOKENS = [ @@ -53,6 +54,7 @@ function Steps({ current }) { export default function CreateStream() { const navigate = useNavigate(); const { address } = useAccount(); + const { authFetch } = useAuth(); const publicClient = usePublicClient(); const chainId = useChainId(); @@ -123,6 +125,7 @@ export default function CreateStream() { repo: form.githubRepo, recipient: form.recipient, ratePerSecond: ratePerSecond.toString(), + authFetch, }); } } diff --git a/frontend/src/pages/app/Setup.jsx b/frontend/src/pages/app/Setup.jsx index 35900c1..00eab51 100644 --- a/frontend/src/pages/app/Setup.jsx +++ b/frontend/src/pages/app/Setup.jsx @@ -10,20 +10,9 @@ function UsernameField({ value, address, onChange, locked }) { const [status, setStatus] = useState('idle'); // idle | checking | available | taken | invalid const debounce = useRef(null); - // Already set and immutable — show it read-only. - if (locked) { - return ( -
- -
- @ - -
-
- ); - } - + // NOTE: every hook must run before any conditional return (rules of hooks). useEffect(() => { + if (locked) return; if (!value || value.length < 3) { setStatus('idle'); return; } if (!/^[a-z0-9_-]+$/.test(value)) { setStatus('invalid'); return; } @@ -42,7 +31,20 @@ function UsernameField({ value, address, onChange, locked }) { setStatus('idle'); // agent offline - allow continuing } }, 500); - }, [value, address]); + }, [value, address, locked]); + + // Already set and immutable — show it read-only (after all hooks have run). + if (locked) { + return ( +
+ +
+ @ + +
+
+ ); + } const hint = { idle: null, diff --git a/frontend/src/pages/app/StreamDetail.jsx b/frontend/src/pages/app/StreamDetail.jsx index 1555fa3..64addee 100644 --- a/frontend/src/pages/app/StreamDetail.jsx +++ b/frontend/src/pages/app/StreamDetail.jsx @@ -281,7 +281,7 @@ export default function StreamDetail() { if (!target) return; setRegistering(true); try { - const res = await fetch(`${AGENT_URL}/api/v1/register-stream`, { + const res = await authFetch(`${AGENT_URL}/api/v1/register-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({