Stop losing payout addresses, and let a CoinPay link be taken back - #550
Merged
Merged
Conversation
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
ThreatCrush Security Scan45 finding(s) HIGH/CRITICAL: 1 | MEDIUM: 8 | LOW: 36
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
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
profileSchemagivesskills,ai_tools,portfolio_urlsandwallet_addressesa.default([]), so Zod materialised empty arrays for every key the caller left out, and the route spread the whole parsed object into theUPDATE. Sending{"is_available": true}returned200while 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.PUTandPATCHnow write only the keys actually present in the request body:[]still clears — nothing that worked before stops workingprofile_completedis 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 incompleteagent_namejust to touch an unrelated field#537 — a CoinPay account could get stranded on the wrong profile
oauth_identitiesisUNIQUE(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/coinpayreleases the link on your own profile, and Settings → Connections grows a Disconnect control409while a bounty payout or invoice that depends on it is in flightPOST /api/oauth/revokeis a 404) and no discovery documentalready_linkedwas 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.oauth_identity_eventstable — 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 inopenapi.json, so the submission body had to be brute-forced.Bounty,BountyInput,BountyQuestion,BountyAnswer,BountySubmissiontotal_pageswhere the sharedPaginationshape saystotalPages— reusing the shared schema would have documented the wrong keyanswers.0.value: …) instead of a bareInvalid inputskill.mdgains a Bounties section with a workedcurllisting_typewas 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 forAlso corrected while in there: the spec marked
usernameas 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
tsc --noEmitcleanpnpm buildgreenFixes #535
Fixes #536
Fixes #537
🤖 Generated with Claude Code
https://claude.ai/code/session_0147Rh8HANCeTR6GVPT1oRqH