Skip to content

Stop losing payout addresses, and let a CoinPay link be taken back - #550

Merged
ralyodio merged 2 commits into
masterfrom
fix/issues-535-536-537
Sep 6, 2026
Merged

ralyodio merged 2 commits into
masterfrom
fix/issues-535-536-537

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Three reports from people trying to get paid, all of which end with money that cannot move.

#536 — a partial profile update wiped the seller's payout addresses

profileSchema gives skills, ai_tools, portfolio_urls and wallet_addresses a .default([]), so Zod materialised empty arrays for every key the caller left out, and the route spread the whole parsed object into the UPDATE. Sending {"is_available": true} returned 200 while silently clearing ten skills, three portfolio URLs and three payout wallets. Scalars survived, so both the response and the profile still looked fine at a glance.

PUT and PATCH now write only the keys actually present in the request body:

  • an omitted key is left alone, an explicit [] still clears — nothing that worked before stops working
  • profile_completed is computed from the merged result rather than from whichever fragment was in the request, so a one-field update no longer marks a full profile incomplete
  • an agent account no longer has to resend agent_name just to touch an unrelated field

#537 — a CoinPay account could get stranded on the wrong profile

oauth_identities is UNIQUE(provider, provider_user_id) and there was no unlink anywhere in the UI or the API. Whichever profile claimed a CoinPay account first kept it forever, and the profile that actually needed it for payouts could never take it — which is how an approved bounty submission ended up with no way to be paid.

  • DELETE /api/auth/coinpay releases the link on your own profile, and Settings → Connections grows a Disconnect control
  • deliberately narrow: only your own link, never another profile's
  • refused with 409 while a bounty payout or invoice that depends on it is in flight
  • deleting the row destroys the stored access and refresh tokens, which is the only revocation available — CoinPay publishes no revocation endpoint (POST /api/oauth/revoke is a 404) and no discovery document
  • already_linked was itself a dead end, never saying which profile held the account. It now returns ?coinpay=already_linked&linked_to=<username> and the page explains how to release it. The caller has just completed an authorization for that exact CoinPay account, so this discloses a public username to someone who has proven they own the identity.
  • connects, reconnects, rejected links and disconnects are recorded in a new append-only oauth_identity_events table — readable by its owner, writable only by the service role — so the link history outlives the link

#535 — the Bounties API was undocumented

/api/bounties, its detail route, submissions and submission review are live but appeared nowhere in openapi.json, so the submission body had to be brute-forced.

  • all four routes documented, with Bounty, BountyInput, BountyQuestion, BountyAnswer, BountySubmission
  • the list route's pagination is spelled out inline, because it returns total_pages where the shared Pagination shape says totalPages — reusing the shared schema would have documented the wrong key
  • a wrong submission body now returns an error naming the path and the expected shape (answers.0.value: …) instead of a bare Invalid input
  • skill.md gains a Bounties section with a worked curl
  • listing_type was already documented in Let an agent sign up with a plus-address, and find the for-hire listing #549; this is the remainder of what the issue asked for

Also corrected while in there: the spec marked username as required on profile update, which the route has never enforced and which contradicts merge semantics.

Migration

supabase/migrations/20260906120000_oauth_identity_events.sql — additive and idempotent. The audit write is fire-and-forget, so the disconnect works whether or not it has been applied yet.

Verification

  • 29 new tests; full suite 2093 passing across 218 files
  • tsc --noEmit clean
  • lint unchanged at 0 errors, no new warnings in the added files
  • pnpm build green

Fixes #535
Fixes #536
Fixes #537

🤖 Generated with Claude Code

https://claude.ai/code/session_0147Rh8HANCeTR6GVPT1oRqH

Three reports from people trying to get paid, all of which end with money
that cannot move.

**A partial profile update wiped the seller's payout addresses (#536).**
profileSchema gives skills, ai_tools, portfolio_urls and wallet_addresses a
.default([]), so Zod materialised empty arrays for every key the caller left
out, and the route spread the whole parsed object into the UPDATE. Sending
{"is_available": true} therefore returned 200 while silently clearing ten
skills, three portfolio URLs and three payout wallets. Scalars survived, so
the response and the profile both still looked fine.

PUT and PATCH now write only the keys actually present in the request body.
An omitted key is left alone; an explicit [] still clears, so nothing that
worked before stops working. profile_completed is computed from the merged
result rather than from whichever fragment was in the request, and an agent
account no longer has to resend agent_name to touch an unrelated field.

**A CoinPay account could get stranded on the wrong profile (#537).**
oauth_identities is UNIQUE(provider, provider_user_id), and there was no
unlink anywhere in the UI or the API. Whichever profile claimed a CoinPay
account first kept it forever, and the profile that actually needed it for
payouts could never take it, so an approved bounty submission had no way to
be paid.

DELETE /api/auth/coinpay releases the link on your own profile, and Settings
-> Connections grows a Disconnect control. The release is deliberately narrow:
only your own link, never another profile's, and it is refused with a 409
while a bounty payout or an invoice that depends on it is still in flight.
Deleting the row also destroys the stored access and refresh tokens, which is
the only revocation available -- CoinPay publishes no revocation endpoint
(POST /api/oauth/revoke is a 404) and no discovery document.

already_linked was itself a dead end: it never said which profile held the
account, so the owner had nowhere to go. It now names the profile
(?coinpay=already_linked&linked_to=<username>) and the page says how to
release it. The caller has just completed an authorization for that exact
CoinPay account, so this discloses a public username to someone who has
proven they own the identity. Connects, reconnects, rejected links and
disconnects are recorded in a new append-only oauth_identity_events table,
readable by its owner and writable only by the service role, so the link
history outlives the link.

**The Bounties API was undocumented (#535).** /api/bounties, its detail
route, submissions and submission review are live but appeared nowhere in
openapi.json, so the submission body had to be brute-forced. All four are
documented now, along with Bounty, BountyInput, BountyQuestion, BountyAnswer
and BountySubmission, and the list route's pagination is spelled out inline
because it returns total_pages where the shared Pagination shape says
totalPages. A wrong submission body now gets an error naming the path and the
expected shape instead of a bare "Invalid input". listing_type was already
documented in #549; this is the rest of what that issue asked for.

Also corrected while in there: the spec marked username as required on
profile update, which the route has never enforced and which contradicts
merge semantics.

29 new tests. Full suite 2093 passing, tsc clean, lint unchanged at 0 errors,
build green.

Fixes #535
Fixes #536
Fixes #537

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147Rh8HANCeTR6GVPT1oRqH
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

45 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 36

Severity Rule Location
HIGH js-ssrf-outbound-request scripts/scan-all-skills.ts:38
MEDIUM js-open-redirect src/app/agent-login/AgentLoginForm.tsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:79
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:90
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:113
MEDIUM js-open-redirect src/app/dashboard/subscription/page.tsx:144
MEDIUM js-open-redirect src/components/funding/FundingClient.tsx:137
MEDIUM js-dynamic-code-execution src/lib/skills/metadata-extract.ts:300
MEDIUM js-dynamic-code-execution src/lib/skills/security-scan.ts:48
LOW secret-generic-credential cli/src/commands/auth.test.ts:66
LOW secret-generic-credential cli/src/commands/auth.test.ts:85
LOW secret-generic-api-key docs/agents/integration-guide.md:893
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:53
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:68
LOW secret-generic-credential src/app/api/auth/login/route.test.ts:87
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:158
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:182
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:193
LOW secret-generic-credential src/app/api/auth/signup/route.test.ts:232
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:212
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:223
LOW js-dynamic-code-execution src/app/api/skills/[slug]/scan/route.test.ts:239
LOW secret-generic-credential src/lib/api.test.ts:126
LOW secret-generic-credential src/lib/api.test.ts:131
LOW js-dynamic-code-execution src/lib/skills/composite-scanner.test.ts:106
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:36
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:44
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:66
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:81
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:94
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:103
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:118
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:144
LOW js-dynamic-code-execution src/lib/skills/secureclaw-scanner.test.ts:162
LOW js-dynamic-code-execution src/lib/skills/security-scan.test.ts:28
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:178
LOW js-dynamic-code-execution src/lib/skills/url-import.test.ts:191
LOW secret-generic-credential src/lib/validations.test.ts:148
LOW secret-generic-credential src/lib/validations.test.ts:512
LOW secret-generic-credential src/lib/validations.test.ts:523
LOW secret-generic-credential src/lib/validations.test.ts:538
LOW secret-generic-credential src/lib/validations.test.ts:548
LOW secret-generic-credential src/lib/validations.test.ts:557
LOW secret-generic-credential src/lib/validations.test.ts:567
LOW secret-generic-credential src/lib/validations.test.ts:582

Snippets are redacted; ThreatCrush never prints matched credential material.

Applied via the Supabase MCP, which stamps schema_migrations with its own
generated timestamp rather than the filename. It recorded 20260906061207, so
the file is renamed to match; otherwise supabase migration list reports the
file as pending and db push would re-run it.

Applied to ojgvudxovrbdikzyoeex: oauth_identity_events exists, RLS enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147Rh8HANCeTR6GVPT1oRqH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant