From 98744618cec8f86abfe3bf1c8cb21678a30b2ebb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 10:33:20 +0000 Subject: [PATCH] Stop showing "Connected" for a CoinPay link that cannot send invoices A worker connected CoinPay, saw a green "Connected" badge on Settings > Connections, and still got "Connect your CoinPay account before sending an invoice" from the same account. Reconnecting did not help, because the two surfaces were asking different questions: the page called any oauth_identities row connected, while the invoice route additionally required wallet:read on the stored token and treated a link without it as absent. The tokens really did lack the scope. The ugig.net OAuth client on CoinPay was registered for openid/profile/email (plus two scopes CoinPay does not define), never wallet:read. That went unnoticed until coinpayportal #257 started intersecting requested scopes with the client's registration, from which point CoinPay filtered wallet:read out of every grant. Every link made since carries openid profile email and can never read wallets, so no amount of reconnecting could fix it from this side. The client registration has been corrected separately; this change is about the contradiction the user was shown. Both surfaces now share one predicate, coinpayLinkCanReadWallets(), so a link that cannot do the job it exists for reports "Reconnect required" with an explanation on the connections page instead of claiming to be connected. The invoice gate is unchanged in behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C5k3DbNSgAqZ1HGjicgLoB --- src/app/settings/connections/page.tsx | 23 ++++++++++-- src/lib/coinpay-oauth.test.ts | 52 +++++++++++++++++++++++++++ src/lib/coinpay-oauth.ts | 33 ++++++++++++----- 3 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/lib/coinpay-oauth.test.ts diff --git a/src/app/settings/connections/page.tsx b/src/app/settings/connections/page.tsx index 5f3edd91..2ee0a4fd 100644 --- a/src/app/settings/connections/page.tsx +++ b/src/app/settings/connections/page.tsx @@ -4,7 +4,8 @@ import { Header } from "@/components/layout/Header"; import { createClient } from "@/lib/supabase/server"; import { createServiceClient } from "@/lib/supabase/service"; import { buttonVariants } from "@/components/ui/button"; -import { CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react"; +import { coinpayLinkCanReadWallets } from "@/lib/coinpay-oauth"; import { DisconnectCoinpayButton } from "./DisconnectCoinpayButton"; export const metadata = { @@ -58,6 +59,10 @@ export default async function OAuthConnectionsPage({ const connectedAt = typeof metadata.connected_at === "string" ? metadata.connected_at : coinpayIdentity?.updated_at || null; const tokenExpiresAt = typeof metadata.expires_at === "string" ? metadata.expires_at : null; + // A link whose token lacks wallet:read exists but cannot do the one job this + // connection is for. Say so here rather than letting the invoice form be the + // first place the user finds out. + const needsReconnect = Boolean(coinpayIdentity) && !coinpayLinkCanReadWallets(metadata); const params = await searchParams; const message = statusMessage(params.coinpay, params.linked_to); @@ -83,12 +88,18 @@ export default async function OAuthConnectionsPage({

CoinPay

- {coinpayIdentity && ( + {coinpayIdentity && !needsReconnect && ( Connected )} + {needsReconnect && ( + + + Reconnect required + + )}

Connect your CoinPay account so ugig can read your CoinPay global wallet addresses @@ -112,6 +123,14 @@ export default async function OAuthConnectionsPage({

+ {needsReconnect && ( +
+ This CoinPay account is linked, but the link was granted without permission to + read your wallet addresses, so ugig cannot send invoices with it. Click + Reconnect above to re-authorize — nothing else on your account changes. +
+ )} + {coinpayIdentity ? (
diff --git a/src/lib/coinpay-oauth.test.ts b/src/lib/coinpay-oauth.test.ts new file mode 100644 index 00000000..6b7ab060 --- /dev/null +++ b/src/lib/coinpay-oauth.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { coinpayLinkCanReadWallets } from "./coinpay-oauth"; + +// The connections page and the invoice gate must agree on what a usable CoinPay +// link is. They did not: any oauth_identities row rendered as "Connected", while +// invoicing rejected a token without wallet:read. A worker saw a green +// "Connected" badge and "Connect your CoinPay account before sending an invoice" +// from the same account, and reconnecting could not fix it — the ugig.net OAuth +// client was registered without wallet:read, so CoinPay filtered the scope out +// of every grant. +describe("coinpayLinkCanReadWallets", () => { + it("accepts a token granted wallet:read", () => { + expect( + coinpayLinkCanReadWallets({ + access_token: "tok", + scope: "openid profile email wallet:read", + }) + ).toBe(true); + }); + + it("rejects a token granted without wallet:read", () => { + expect( + coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid profile email" }) + ).toBe(false); + }); + + it("rejects a link with no recorded scope", () => { + expect(coinpayLinkCanReadWallets({ access_token: "tok", scope: null })).toBe(false); + }); + + it("rejects a link with no access token", () => { + expect(coinpayLinkCanReadWallets({ scope: "openid wallet:read" })).toBe(false); + }); + + it("rejects a blank access token", () => { + expect( + coinpayLinkCanReadWallets({ access_token: " ", scope: "openid wallet:read" }) + ).toBe(false); + }); + + it("does not match wallet:read as a substring of another scope", () => { + expect( + coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid wallet:readwrite" }) + ).toBe(false); + }); + + it("treats a missing or non-object metadata as unusable", () => { + expect(coinpayLinkCanReadWallets(null)).toBe(false); + expect(coinpayLinkCanReadWallets(undefined)).toBe(false); + expect(coinpayLinkCanReadWallets("openid wallet:read")).toBe(false); + }); +}); diff --git a/src/lib/coinpay-oauth.ts b/src/lib/coinpay-oauth.ts index c68fde28..fa28af27 100644 --- a/src/lib/coinpay-oauth.ts +++ b/src/lib/coinpay-oauth.ts @@ -4,7 +4,25 @@ function metadataObject(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } -const REQUIRED_COINPAY_SCOPE = "wallet:read"; +export const REQUIRED_COINPAY_SCOPE = "wallet:read"; + +/** + * Does this stored CoinPay link carry the scope we need to read wallets? + * + * The connection UI and the invoice gate have to answer this the same way. They + * did not: the UI called any `oauth_identities` row "Connected", while invoicing + * additionally required `wallet:read` and rejected the link without it. A user + * whose token lacked the scope saw a green "Connected" badge and + * "Connect your CoinPay account before sending an invoice" from the same + * account, with no way to reconcile the two. + */ +export function coinpayLinkCanReadWallets(metadata: unknown): boolean { + const meta = metadataObject(metadata); + const accessToken = typeof meta.access_token === "string" ? meta.access_token.trim() : ""; + if (!accessToken) return false; + const scope = typeof meta.scope === "string" ? meta.scope : ""; + return scope.split(/\s+/).filter(Boolean).includes(REQUIRED_COINPAY_SCOPE); +} const TOKEN_URL = "https://coinpayportal.com/api/oauth/token"; // Refresh if token expires within 5 minutes const EXPIRY_BUFFER_MS = 5 * 60 * 1000; @@ -80,14 +98,11 @@ export async function getConnectedCoinpayAccessToken(userId: string): Promise