Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions agent-node/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CreateStreamModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/hooks/useAgentStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/app/CreateStream.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -123,6 +125,7 @@ export default function CreateStream() {
repo: form.githubRepo,
recipient: form.recipient,
ratePerSecond: ratePerSecond.toString(),
authFetch,
});
}
}
Expand Down
30 changes: 16 additions & 14 deletions frontend/src/pages/app/Setup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<label className="label">Username <span className="text-muted/50 normal-case tracking-normal font-normal">(set, can't be changed)</span></label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-muted font-mono text-sm select-none">@</span>
<input value={value} disabled className="input pl-8 opacity-60 cursor-not-allowed" />
</div>
</div>
);
}

// 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; }

Expand All @@ -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 (
<div>
<label className="label">Username <span className="text-muted/50 normal-case tracking-normal font-normal">(set, can't be changed)</span></label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-muted font-mono text-sm select-none">@</span>
<input value={value} disabled className="input pl-8 opacity-60 cursor-not-allowed" />
</div>
</div>
);
}

const hint = {
idle: null,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/app/StreamDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading