From aff36c6f2a66fb1b52ef891fb4ae1dd1d6c7a022 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:00:36 +0000 Subject: [PATCH] feat: add CoinPay group.call integration --- src/app/api/coinpay/callback/route.ts | 90 +++++++++++++++++-- .../api/coinpay/groupcall/connect/route.ts | 15 ++++ .../api/coinpay/groupcall/disconnect/route.ts | 15 ++++ src/app/api/integrations/groupcall/route.ts | 30 +++++++ .../integrations/groupcall/identity/route.ts | 22 +++++ src/app/settings/page.tsx | 2 + src/components/groupcall-integration-card.tsx | 74 +++++++++++++++ src/lib/coinpay/start.ts | 20 +++-- src/lib/groupcall.ts | 4 + 9 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 src/app/api/coinpay/groupcall/connect/route.ts create mode 100644 src/app/api/coinpay/groupcall/disconnect/route.ts create mode 100644 src/app/api/integrations/groupcall/route.ts create mode 100644 src/app/api/v1/integrations/groupcall/identity/route.ts create mode 100644 src/components/groupcall-integration-card.tsx create mode 100644 src/lib/groupcall.ts diff --git a/src/app/api/coinpay/callback/route.ts b/src/app/api/coinpay/callback/route.ts index 6ba9ef76..d822acfd 100644 --- a/src/app/api/coinpay/callback/route.ts +++ b/src/app/api/coinpay/callback/route.ts @@ -11,6 +11,8 @@ import { supabaseCoinpayStore, takeCallback, } from "@/lib/coinpay/signin"; +import { generateApiKey, hashApiKey } from "@/lib/api-auth"; +import { GROUPCALL_API_KEY_NAME, GROUPCALL_BASE_URL } from "@/lib/groupcall"; export const dynamic = "force-dynamic"; @@ -23,7 +25,8 @@ export const dynamic = "force-dynamic"; * The exchange sends the PKCE verifier and the client secret; the id_token's * iss/aud/exp/nonce are checked and userinfo's sub must match it. The account * is chosen by sub only (lib/coinpay/signin.ts); a sign-in then gets a normal - * Supabase session minted server side. OAuth tokens are never stored. + * Supabase session minted server side. OAuth tokens are never stored; group.call + * modes forward the access token once over HTTPS server-to-server. */ export async function GET(request: NextRequest) { const site = getSiteUrl(); @@ -51,15 +54,24 @@ export async function GET(request: NextRequest) { }); if (!checked.ok) return to(`/login?error=coinpay_${checked.code}`); const { pending } = checked; - const fail = (reason: string) => - to(pending.mode === "connect" ? `/settings?coinpay=error&reason=${reason}` : `/login?error=coinpay_${reason}`); + const groupcallAction = pending.mode === "connect" && pending.next === "/settings?groupcall=connect" + ? "connect" + : pending.mode === "connect" && pending.next === "/settings?groupcall=disconnect" + ? "disconnect" + : null; + const fail = (reason: string) => { + if (groupcallAction) { + return to(`/settings?groupcall=error&reason=${reason}`); + } + return to(pending.mode === "connect" ? `/settings?coinpay=error&reason=${reason}` : `/login?error=coinpay_${reason}`); + }; const code = url.searchParams.get("code"); if (url.searchParams.get("error") || !code) return fail("cancelled"); - let claims; + let result: Awaited>; try { - const result = await finishSignIn({ + result = await finishSignIn({ code, verifier: pending.verifier, nonce: pending.nonce, @@ -67,15 +79,77 @@ export async function GET(request: NextRequest) { clientSecret: cfg.clientSecret, redirectUri: cfg.redirectUri, }); - claims = { sub: result.sub, email: result.email, name: result.name }; } catch (err) { console.error("[coinpay] sign-in failed", err instanceof Error ? err.message : err); return fail("token_exchange"); } - const resolved = await resolveCoinpayUser({ mode: pending.mode, currentUserId: current?.id ?? null, claims }, store); + const claims = { sub: result.sub, email: result.email, name: result.name }; + const resolved = await resolveCoinpayUser({ + mode: pending.mode === "login" ? "login" : "connect", + currentUserId: current?.id ?? null, + claims, + }, store); if (!resolved.ok) return fail(resolved.code); - if (pending.mode === "connect") return to("/settings?coinpay=connected"); + if (pending.mode === "connect" && !groupcallAction) return to("/settings?coinpay=connected"); + + if (groupcallAction) { + const groupcallHeaders: HeadersInit = { + authorization: `Bearer ${result.tokens.accessToken}`, + ...(groupcallAction === "connect" ? { "content-type": "application/json" } : {}), + }; + + if (groupcallAction === "disconnect") { + let response: Response; + try { + response = await fetch(`${GROUPCALL_BASE_URL}/api/integrations/smshub`, { + method: "DELETE", + headers: groupcallHeaders, + signal: AbortSignal.timeout(15_000), + }); + } catch { + return fail("groupcall_unavailable"); + } + if (!response.ok) return fail("groupcall_unlink"); + const { error } = await admin.from("api_keys").delete().eq("user_id", resolved.userId).eq("name", GROUPCALL_API_KEY_NAME); + if (error) return fail("smshub_cleanup"); + return to("/settings?groupcall=disconnected"); + } + + const rawKey = generateApiKey(); + const { data: created, error: createError } = await admin + .from("api_keys") + .insert({ user_id: resolved.userId, key_hash: hashApiKey(rawKey), name: GROUPCALL_API_KEY_NAME }) + .select("id") + .single(); + if (createError || !created) return fail("key_create"); + + let response: Response; + try { + response = await fetch(`${GROUPCALL_BASE_URL}/api/integrations/smshub`, { + method: "POST", + headers: groupcallHeaders, + body: JSON.stringify({ key: rawKey }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + await admin.from("api_keys").delete().eq("id", created.id).eq("user_id", resolved.userId); + return fail("groupcall_unavailable"); + } + if (!response.ok) { + await admin.from("api_keys").delete().eq("id", created.id).eq("user_id", resolved.userId); + return fail("groupcall_link"); + } + + const { error: cleanupError } = await admin + .from("api_keys") + .delete() + .eq("user_id", resolved.userId) + .eq("name", GROUPCALL_API_KEY_NAME) + .neq("id", created.id); + if (cleanupError) console.error("[group.call] old integration key cleanup failed"); + return to("/settings?groupcall=connected"); + } const minted = await mintMagicLinkHash(admin, resolved.userId); if (!minted.ok) return fail("session"); diff --git a/src/app/api/coinpay/groupcall/connect/route.ts b/src/app/api/coinpay/groupcall/connect/route.ts new file mode 100644 index 00000000..3c4658b0 --- /dev/null +++ b/src/app/api/coinpay/groupcall/connect/route.ts @@ -0,0 +1,15 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getSiteUrl } from "@/lib/site-url"; +import { startCoinpay } from "@/lib/coinpay/start"; + +export const dynamic = "force-dynamic"; + +/** Start CoinPay authorization for linking the signed-in SMSHub account to group.call. */ +export async function POST(request: NextRequest) { + const origin = request.headers.get("origin"); + if (!origin || origin !== new URL(getSiteUrl()).origin) { + return NextResponse.json({ error: "Invalid request origin" }, { status: 403 }); + } + return startCoinpay(request, "connect", "/settings?groupcall=connect", true); +} diff --git a/src/app/api/coinpay/groupcall/disconnect/route.ts b/src/app/api/coinpay/groupcall/disconnect/route.ts new file mode 100644 index 00000000..1f5b7153 --- /dev/null +++ b/src/app/api/coinpay/groupcall/disconnect/route.ts @@ -0,0 +1,15 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getSiteUrl } from "@/lib/site-url"; +import { startCoinpay } from "@/lib/coinpay/start"; + +export const dynamic = "force-dynamic"; + +/** Reauthorize with CoinPay before removing the group.call connection. */ +export async function POST(request: NextRequest) { + const origin = request.headers.get("origin"); + if (!origin || origin !== new URL(getSiteUrl()).origin) { + return NextResponse.json({ error: "Invalid request origin" }, { status: 403 }); + } + return startCoinpay(request, "connect", "/settings?groupcall=disconnect", true); +} diff --git a/src/app/api/integrations/groupcall/route.ts b/src/app/api/integrations/groupcall/route.ts new file mode 100644 index 00000000..6c5c1bcf --- /dev/null +++ b/src/app/api/integrations/groupcall/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { GROUPCALL_API_KEY_NAME } from "@/lib/groupcall"; + +export const dynamic = "force-dynamic"; + +/** Connection status only; the integration credential is never returned. */ +export async function GET() { + const supabase = await createServerSupabaseClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { data, error } = await supabase + .from("api_keys") + .select("id, created_at") + .eq("user_id", user.id) + .eq("name", GROUPCALL_API_KEY_NAME) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error) return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + + return NextResponse.json({ connected: Boolean(data), connected_at: data?.created_at ?? null }, { + headers: { "cache-control": "no-store" }, + }); +} diff --git a/src/app/api/v1/integrations/groupcall/identity/route.ts b/src/app/api/v1/integrations/groupcall/identity/route.ts new file mode 100644 index 00000000..7658da38 --- /dev/null +++ b/src/app/api/v1/integrations/groupcall/identity/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { resolveUser } from "@/lib/request-user"; +import { createServiceClient } from "@/lib/supabase/server"; + +/** Return only the CoinPay subject bound to the API key owner for account-link verification. */ +export async function GET(request: Request) { + const who = await resolveUser(request, { allowSession: false }); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + + const { data, error } = await createServiceClient() + .from("coinpay_connections") + .select("coinpay_user_id") + .eq("user_id", who.user.userId) + .maybeSingle(); + + if (error) return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + if (!data?.coinpay_user_id) { + return NextResponse.json({ error: "Connect CoinPay in SMSHub Settings first" }, { status: 409 }); + } + + return NextResponse.json({ coinpay_sub: data.coinpay_user_id }, { headers: { "cache-control": "no-store" } }); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 60bf0524..1c92609f 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -7,6 +7,7 @@ import { useToast } from "@/contexts/toast-context"; import { ProfileCard } from "@/components/profile-card"; import { RedeemCard } from "@/components/redeem-card"; import { CoinpayCard } from "@/components/coinpay-card"; +import { GroupCallIntegrationCard } from "@/components/groupcall-integration-card"; import { KeyVaultCard } from "@/components/key-vault-card"; import { ProviderTestPanel, @@ -215,6 +216,7 @@ export default function SettingsPage() { + diff --git a/src/components/groupcall-integration-card.tsx b/src/components/groupcall-integration-card.tsx new file mode 100644 index 00000000..ed324bd3 --- /dev/null +++ b/src/components/groupcall-integration-card.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Status = { connected: boolean; connected_at: string | null }; + +const connectionErrors: Record = { + groupcall_unavailable: "group.call did not answer. Try connecting again in a minute.", + groupcall_link: "group.call could not link this SMSHub account. Try again.", + groupcall_unlink: "group.call could not remove the connection. Try again.", + key_create: "SMSHub could not create the private connection. Try again.", + smshub_cleanup: "group.call disconnected, but SMSHub could not finish cleanup. Reload this page.", + token_exchange: "CoinPay sign-in did not finish. Try again.", + linked_elsewhere: "That CoinPay account is linked to a different SMSHub account.", + already_connected: "This SMSHub account is linked to a different CoinPay account.", + wrong_session: "Your SMSHub session changed during sign-in. Start again from Settings.", +}; + +export function GroupCallIntegrationCard() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + + const load = async () => { + try { + const response = await fetch("/api/integrations/groupcall", { cache: "no-store" }); + if (response.ok) setStatus(await response.json()); + } catch { + setError("Could not load group.call connection status."); + } + }; + + useEffect(() => { + void load(); + const params = new URLSearchParams(window.location.search); + if (params.get("groupcall") === "error") { + setError(connectionErrors[params.get("reason") || ""] || "Could not connect group.call. Try again."); + } + }, []); + + return ( +
+
+
+

group.call

+

+ Send group.call room texts through your SMSHub number. CoinPay links the same account on both services. +

+

+ SMSHub creates and sends the integration credential privately; it is never shown or copied to your browser. +

+
+
+
+ +
+ {status?.connected && ( +
+ +
+ )} +
+
+ {status?.connected &&

Connected to group.call.

} + {error &&

{error}

} +
+ ); +} diff --git a/src/lib/coinpay/start.ts b/src/lib/coinpay/start.ts index 56aa3720..e97b08f3 100644 --- a/src/lib/coinpay/start.ts +++ b/src/lib/coinpay/start.ts @@ -23,36 +23,39 @@ const CONNECT_SCOPES = [...SIGN_IN_SCOPES, "wallet:read"]; * host first hops to smshub.dev: the state cookie has to live where the * callback lands. */ -export async function startCoinpay(request: NextRequest, mode: OAuthMode) { +export async function startCoinpay(request: NextRequest, mode: OAuthMode, fallbackNext?: string, minimalScopes = false) { const site = getSiteUrl(); const here = new URL(request.url); const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); if (forwardedHost && forwardedHost !== new URL(site).host) { - return NextResponse.redirect(new URL(here.pathname + here.search, site)); + const canonical = new URL(here.pathname + here.search, site); + if (fallbackNext && !canonical.searchParams.has("next")) canonical.searchParams.set("next", fallbackNext); + return NextResponse.redirect(canonical, { status: 303 }); } const cfg = coinpaySignInConfig(site); if (!cfg) { return NextResponse.redirect( - new URL(mode === "connect" ? "/settings?coinpay=error&reason=not_configured" : "/login?error=coinpay_off", site) + new URL(mode === "login" ? "/login?error=coinpay_off" : "/settings?coinpay=error&reason=not_configured", site), + { status: 303 } ); } let linkUserId: string | null = null; - if (mode === "connect") { + if (mode !== "login") { // Connecting happens only from inside the account it joins. const supabase = await createServerSupabaseClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) return NextResponse.redirect(new URL("/login?next=/settings", site)); + if (!user) return NextResponse.redirect(new URL("/login?next=/settings", site), { status: 303 }); linkUserId = user.id; } const state = randomToken(); const nonce = randomToken(); const { verifier, challenge } = pkce(); - const next = safeNextPath(here.searchParams.get("next"), mode === "connect" ? "/settings" : "/inbox"); + const next = safeNextPath(here.searchParams.get("next"), fallbackNext ?? (mode === "login" ? "/inbox" : "/settings")); await savePending(supabaseCoinpayStore(createServiceClient()), state, { verifier, nonce, mode, next, linkUserId }); const res = NextResponse.redirect( @@ -62,8 +65,9 @@ export async function startCoinpay(request: NextRequest, mode: OAuthMode) { state, challenge, nonce, - scopes: mode === "connect" ? CONNECT_SCOPES : SIGN_IN_SCOPES, - }) + scopes: mode === "login" || minimalScopes ? SIGN_IN_SCOPES : CONNECT_SCOPES, + }), + { status: 303 } ); res.headers.set("cache-control", "no-store"); res.cookies.set(STATE_COOKIE, state, { diff --git a/src/lib/groupcall.ts b/src/lib/groupcall.ts new file mode 100644 index 00000000..24febbe4 --- /dev/null +++ b/src/lib/groupcall.ts @@ -0,0 +1,4 @@ +export const GROUPCALL_API_KEY_NAME = "group.call integration"; + +export const GROUPCALL_BASE_URL = + ((process.env as Record).GROUPCALL_URL || "https://group.call.pit.world").replace(/\/+$/, "");