From 3bbf6f404fd10f968727a7499a34a1ab06b36369 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:47:38 +0000 Subject: [PATCH 1/6] Add SMSHub and CoinPay Portal account integrations --- .env.example | 10 +- .../admin/account-integrations-form.tsx | 427 ++++++++++++++++++ app/(app)/dashboard/admin/page.tsx | 38 +- app/(app)/dashboard/org-controls.tsx | 7 +- app/actions/admin.ts | 116 +++++ app/actions/orgs.ts | 8 +- lib/coinpay-tokens.ts | 13 +- lib/coinpay.ts | 65 ++- lib/env.ts | 8 +- lib/outreach.ts | 50 +- lib/platform-integrations.ts | 279 +++++++++++- lib/sealed/operator.ts | 41 +- ...0261030120000_smshub_outreach_provider.sql | 8 + ...30121000_platform_account_integrations.sql | 14 + 14 files changed, 1043 insertions(+), 41 deletions(-) create mode 100644 app/(app)/dashboard/admin/account-integrations-form.tsx create mode 100644 supabase/migrations/20261030120000_smshub_outreach_provider.sql create mode 100644 supabase/migrations/20261030121000_platform_account_integrations.sql diff --git a/.env.example b/.env.example index 5269521e..28a32358 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ IP_HASH_SALT= # CoinPay credit purchases COINPAY_MERCHANT_ID=merchant_xxx COINPAY_API_KEY=cp_xxx -COINPAY_API_URL=https://coinpayportal.com/api +COINPAY_API_URL=https://coinpayportal.com COINPAY_WEBHOOK_SECRET=change-me-to-a-long-random-string # Resend @@ -42,7 +42,13 @@ SMTP_PASS= SMTP_SECURE=true SMTP_FROM="CrawlProof " -# Recent-scan outreach SMS. Twilio is preferred when configured; Telnyx is the fallback. +# Optional platform SMSHub default. Admin connections are preferred when set; +# this pair is used as a fallback when no platform connection is saved. +SMSHUB_ALERT_API_KEY= +SMSHUB_FROM_PHONE= + +# Recent-scan outreach SMS. SMSHub is preferred when connected, then Twilio, +# then Telnyx. TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_FROM= diff --git a/app/(app)/dashboard/admin/account-integrations-form.tsx b/app/(app)/dashboard/admin/account-integrations-form.tsx new file mode 100644 index 00000000..a28ef192 --- /dev/null +++ b/app/(app)/dashboard/admin/account-integrations-form.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + listSmsHubPlatformSenders, + revealCoinPayPortalPlatformApiKey, + revealSmsHubPlatformApiKey, + saveCoinPayPortalPlatformIntegration, + saveSmsHubPlatformIntegration, +} from "@/app/actions/admin"; + +type ConnectionStatus = { + configured: boolean; + hasApiKey: boolean; + hasWebhookSecret?: boolean; + status: string; + updatedAt: string | null; +}; + +export function SmsHubConnectionForm({ + configured, + hasApiKey, + status, + updatedAt, + fromPhone, + endpoint, +}: ConnectionStatus & { fromPhone: string; endpoint: string }) { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [sender, setSender] = useState(fromPhone); + const [senders, setSenders] = useState>([]); + const [revealedKey, setRevealedKey] = useState(null); + const [message, setMessage] = useState(null); + const [pending, start] = useTransition(); + + function save(event: React.FormEvent) { + event.preventDefault(); + setMessage(null); + start(async () => { + const result = await saveSmsHubPlatformIntegration({ apiKey, fromPhone: sender }); + if (!result.ok) { + setMessage(result.error); + return; + } + setApiKey(""); + setRevealedKey(null); + setMessage("SMSHub connected."); + router.refresh(); + }); + } + + function disconnect() { + if (!confirm("Disconnect SMSHub from CrawlProof?")) return; + setMessage(null); + start(async () => { + const result = await saveSmsHubPlatformIntegration({ clear: true }); + if (!result.ok) { + setMessage(result.error); + return; + } + setApiKey(""); + setSender(""); + setRevealedKey(null); + setMessage("SMSHub disconnected."); + router.refresh(); + }); + } + + async function reveal() { + setMessage(null); + start(async () => { + const result = await revealSmsHubPlatformApiKey(); + if (!result.ok) { + setMessage(result.error); + return; + } + setRevealedKey(result.apiKey); + }); + } + + function loadSenders() { + setMessage(null); + start(async () => { + const result = await listSmsHubPlatformSenders({ apiKey }); + if (!result.ok) { + setMessage(result.error); + return; + } + setSenders(result.senders); + setMessage(`Loaded ${result.senders.length} active SMSHub sender numbers. Choose the one CrawlProof should use.`); + }); + } + + async function copyKey() { + const key = revealedKey; + if (!key) return; + try { + await navigator.clipboard.writeText(key); + setMessage("SMSHub key copied."); + } catch { + setMessage("Could not copy the SMSHub key."); + } + } + + return ( +
+ +
+ +
+ + + Select the number CrawlProof should send from. + +
+ {senders.length > 0 && ( + + )} + setSender(event.target.value)} + placeholder={senders.length ? "Or enter a different number" : "+15551234567"} + autoComplete="off" + /> +
+
+ + setApiKey(event.target.value)} + placeholder={hasApiKey ? "Leave blank to keep the saved key" : "Paste API key"} + autoComplete="new-password" + /> +

+ Sent through {endpoint}. The key is sealed to your operator key. +

+
+ {hasApiKey && ( + setRevealedKey(null)} + onCopy={copyKey} + disabled={pending} + label="SMSHub key" + /> + )} + +
+ + {(configured || hasApiKey) && ( + + )} +
+ + ); +} + +export function CoinPayPortalConnectionForm({ + configured, + hasApiKey, + status, + updatedAt, + hasWebhookSecret, + merchantId, + apiUrl, +}: ConnectionStatus & { hasWebhookSecret: boolean; merchantId: string; apiUrl: string }) { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [businessId, setBusinessId] = useState(merchantId); + const [endpoint, setEndpoint] = useState(apiUrl); + const [revealedKey, setRevealedKey] = useState(null); + const [message, setMessage] = useState(null); + const [pending, start] = useTransition(); + + function save(event: React.FormEvent) { + event.preventDefault(); + setMessage(null); + start(async () => { + const result = await saveCoinPayPortalPlatformIntegration({ + apiKey, + merchantId: businessId, + apiUrl: endpoint, + }); + if (!result.ok) { + setMessage(result.error); + return; + } + setApiKey(""); + setRevealedKey(null); + setMessage("CoinPay Portal connected."); + router.refresh(); + }); + } + + function disconnect() { + if (!confirm("Disconnect CoinPay Portal from CrawlProof billing?")) return; + setMessage(null); + start(async () => { + const result = await saveCoinPayPortalPlatformIntegration({ clear: true }); + if (!result.ok) { + setMessage(result.error); + return; + } + setApiKey(""); + setBusinessId(""); + setRevealedKey(null); + setMessage("CoinPay Portal disconnected."); + router.refresh(); + }); + } + + async function reveal() { + setMessage(null); + start(async () => { + const result = await revealCoinPayPortalPlatformApiKey(); + if (!result.ok) { + setMessage(result.error); + return; + } + setRevealedKey(result.apiKey); + }); + } + + async function copyKey() { + const key = revealedKey; + if (!key) return; + try { + await navigator.clipboard.writeText(key); + setMessage("CoinPay Portal key copied."); + } catch { + setMessage("Could not copy the CoinPay Portal key."); + } + } + + return ( +
+ +
+ + +
+ + {hasApiKey && ( + setRevealedKey(null)} + onCopy={copyKey} + disabled={pending} + label="CoinPay Portal key" + /> + )} +

+ Powers CrawlProof credit purchases and payouts. The API key is sealed to your operator key. +

+ {configured && !hasWebhookSecret && ( +

+ The webhook signing secret is missing, so CoinPay payment callbacks + cannot be verified yet. +

+ )} + +
+ + {(configured || hasApiKey) && ( + + )} +
+ + ); +} + +function ConnectionBadge({ + configured, + status, + updatedAt, +}: { + configured: boolean; + status: string; + updatedAt: string | null; +}) { + return ( +
+ + {configured ? "Connected" : "Not connected"} + + Status: {status} + {updatedAt && ( + + Updated {new Date(updatedAt).toLocaleString()} + + )} +
+ ); +} + +function TextField({ + label, + value, + onChange, + placeholder, + type = "text", +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + + ); +} + +function SecretControls({ + value, + onReveal, + onHide, + onCopy, + disabled, + label, +}: { + value: string | null; + onReveal: () => void; + onHide: () => void; + onCopy: () => void; + disabled: boolean; + label: string; +}) { + return ( +
+ +
+ +
+ {value ? ( + + ) : ( + + )} + +
+
+
+ ); +} + +function FormMessage({ message }: { message: string | null }) { + return message ?

{message}

: null; +} diff --git a/app/(app)/dashboard/admin/page.tsx b/app/(app)/dashboard/admin/page.tsx index 261ec582..f6e22c5e 100644 --- a/app/(app)/dashboard/admin/page.tsx +++ b/app/(app)/dashboard/admin/page.tsx @@ -3,10 +3,18 @@ import { createClient } from "@/lib/supabase/server"; import { serviceClient } from "@/lib/supabase/service"; import Link from "next/link"; import { GrantCreditsForm, Vu1nzIntegrationForm } from "./form"; +import { + CoinPayPortalConnectionForm, + SmsHubConnectionForm, +} from "./account-integrations-form"; import { IntegrationsManager } from "./integrations-form"; import { CreditCodesForm } from "./credit-codes-form"; import { env } from "@/lib/env"; -import { getVu1nzIntegrationStatus } from "@/lib/platform-integrations"; +import { + getCoinPayPortalConnectionStatus, + getSmsHubConnectionStatus, + getVu1nzIntegrationStatus, +} from "@/lib/platform-integrations"; import { ProviderCostsPanel } from "./provider-costs-panel"; export const metadata = { @@ -42,7 +50,13 @@ export default async function AdminPage() { // Recent grants — admins see everything; loaded via service client // so we get the granted_by email join cheaply. const svc = serviceClient(); - const [{ data: recentRaw }, { data: integrationsRaw }, vu1nzIntegration] = await Promise.all([ + const [ + { data: recentRaw }, + { data: integrationsRaw }, + vu1nzIntegration, + smsHubIntegration, + coinPayPortalIntegration, + ] = await Promise.all([ svc .from("admin_credit_grants") .select( @@ -57,6 +71,8 @@ export default async function AdminPage() { ) .order("created_at", { ascending: false }), getVu1nzIntegrationStatus(), + getSmsHubConnectionStatus(), + getCoinPayPortalConnectionStatus(), ]); const recent: GrantRow[] = (recentRaw ?? []).map((r: any) => ({ id: r.id, @@ -159,6 +175,24 @@ export default async function AdminPage() { +
+

SMSHub

+

+ Connect SMSHub for CrawlProof outreach. The API key is sealed to your + operator key; set a sender number owned by the connected SMSHub account. +

+ +
+ +
+

CoinPay Portal

+

+ Connect the business account used for CrawlProof credit purchases + and payouts. The API key is sealed to your operator key. +

+ +
+

Autoblog integrations

diff --git a/app/(app)/dashboard/org-controls.tsx b/app/(app)/dashboard/org-controls.tsx index 4dee5d4e..c70e31d1 100644 --- a/app/(app)/dashboard/org-controls.tsx +++ b/app/(app)/dashboard/org-controls.tsx @@ -44,7 +44,7 @@ export type DashboardSenderConfig = { id: string; label: string; channel: "email" | "sms" | "social"; - provider: "smtp" | "resend" | "twilio" | "telnyx" | "manual"; + provider: "smtp" | "resend" | "twilio" | "telnyx" | "smshub" | "manual"; enabled: boolean; is_default: boolean; from_email: string | null; @@ -687,7 +687,7 @@ function OrgInvitationRow({ } type SenderChannel = "email" | "sms" | "social"; -type SenderProvider = "smtp" | "resend" | "twilio" | "telnyx" | "manual"; +type SenderProvider = "smtp" | "resend" | "twilio" | "telnyx" | "smshub" | "manual"; function SenderConfigPanel({ organizationId, @@ -873,6 +873,7 @@ function SenderConfigForm({ organizationId }: { organizationId: string }) { <> + ) : ( @@ -913,6 +914,8 @@ function SenderConfigForm({ organizationId }: { organizationId: string }) { + ) : provider === "smshub" ? ( + ) : ( )} diff --git a/app/actions/admin.ts b/app/actions/admin.ts index 4147ec82..987452ac 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -4,8 +4,15 @@ import { revalidatePath } from "next/cache"; import { createClient } from "@/lib/supabase/server"; import { serviceClient } from "@/lib/supabase/service"; import { + clearCoinPayPortalConnection as disableCoinPayPortalConnection, + clearSmsHubConnection as disableSmsHubConnection, clearVu1nzApiToken, + getCoinPayPortalConnection, + getSmsHubConnection, + listSmsHubSenderNumbers, getVu1nzApiToken, + saveCoinPayPortalConnection as persistCoinPayPortalConnection, + saveSmsHubConnection as persistSmsHubConnection, saveVu1nzApiToken, } from "@/lib/platform-integrations"; @@ -169,3 +176,112 @@ export async function revealVu1nzIntegrationToken(): Promise< }; } } + +export async function saveSmsHubPlatformIntegration(input: { + apiKey?: string; + fromPhone?: string; + clear?: boolean; +}): Promise { + const adminCheck = await assertAdmin(); + if (!adminCheck.ok) return adminCheck; + try { + if (input.clear) { + await disableSmsHubConnection(adminCheck.userId); + } else { + await persistSmsHubConnection({ ...input, userId: adminCheck.userId }); + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Could not save SMSHub integration.", + }; + } + revalidatePath("/dashboard/admin"); + return { ok: true }; +} + +export async function revealSmsHubPlatformApiKey(): Promise< + Ok<{ apiKey: string }> | Err +> { + const adminCheck = await assertAdmin(); + if (!adminCheck.ok) return adminCheck; + try { + const connection = await getSmsHubConnection(); + if (!connection.apiKey) { + return { + ok: false, + error: connection.status === "locked" ? "Unlock operator keys to reveal the SMSHub API key." : "SMSHub API key is not configured.", + }; + } + return { ok: true, apiKey: connection.apiKey }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Could not reveal SMSHub API key.", + }; + } +} + +export async function listSmsHubPlatformSenders(input?: { apiKey?: string }): Promise< + Ok<{ senders: Array<{ number: string; label: string }> }> | Err +> { + const adminCheck = await assertAdmin(); + if (!adminCheck.ok) return adminCheck; + try { + const senders = await listSmsHubSenderNumbers(input); + return { ok: true, senders }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Could not load SMSHub sender numbers.", + }; + } +} + +export async function saveCoinPayPortalPlatformIntegration(input: { + apiKey?: string; + merchantId?: string; + apiUrl?: string; + clear?: boolean; +}): Promise { + const adminCheck = await assertAdmin(); + if (!adminCheck.ok) return adminCheck; + try { + if (input.clear) { + await disableCoinPayPortalConnection(adminCheck.userId); + } else { + await persistCoinPayPortalConnection({ ...input, userId: adminCheck.userId }); + } + } catch (error) { + return { + ok: false, + error: + error instanceof Error ? error.message : "Could not save CoinPay Portal integration.", + }; + } + revalidatePath("/dashboard/admin"); + return { ok: true }; +} + +export async function revealCoinPayPortalPlatformApiKey(): Promise< + Ok<{ apiKey: string }> | Err +> { + const adminCheck = await assertAdmin(); + if (!adminCheck.ok) return adminCheck; + try { + const connection = await getCoinPayPortalConnection(); + if (!connection.apiKey) { + return { + ok: false, + error: connection.status === "locked" ? "Unlock operator keys to reveal the CoinPay Portal API key." : "CoinPay Portal API key is not configured.", + }; + } + return { ok: true, apiKey: connection.apiKey }; + } catch (error) { + return { + ok: false, + error: + error instanceof Error ? error.message : "Could not reveal CoinPay Portal API key.", + }; + } +} diff --git a/app/actions/orgs.ts b/app/actions/orgs.ts index 67250a6a..8dd68d7e 100644 --- a/app/actions/orgs.ts +++ b/app/actions/orgs.ts @@ -190,7 +190,7 @@ export async function saveOrganizationOutreachConfig(input: { organizationId: string; label: string; channel: "email" | "sms" | "social"; - provider: "smtp" | "resend" | "twilio" | "telnyx" | "manual"; + provider: "smtp" | "resend" | "twilio" | "telnyx" | "smshub" | "manual"; fromEmail?: string; fromPhone?: string; replyTo?: string; @@ -408,10 +408,12 @@ function clean(value: string | undefined) { function validOutreachProvider( channel: "email" | "sms" | "social", - provider: "smtp" | "resend" | "twilio" | "telnyx" | "manual", + provider: "smtp" | "resend" | "twilio" | "telnyx" | "smshub" | "manual", ) { if (channel === "email") return provider === "smtp" || provider === "resend"; - if (channel === "sms") return provider === "twilio" || provider === "telnyx"; + if (channel === "sms") { + return provider === "twilio" || provider === "telnyx" || provider === "smshub"; + } return provider === "manual"; } diff --git a/lib/coinpay-tokens.ts b/lib/coinpay-tokens.ts index 43007b19..e878a9f7 100644 --- a/lib/coinpay-tokens.ts +++ b/lib/coinpay-tokens.ts @@ -1,4 +1,5 @@ -import { env } from "./env"; +import { coinPayApiBase } from "@/lib/coinpay"; +import { getCoinPayPortalConnection } from "@/lib/platform-integrations"; // CoinPay returns wallets the merchant has configured for the business. // Upstream endpoint: GET {COINPAY_API_URL}/api/supported-coins @@ -48,16 +49,16 @@ export async function fetchSupportedTokens(opts?: { if (!opts?.force && cache && Date.now() - cache.ts < TTL_MS) { return cache.tokens; } - if (!env.coinpayApiKey || !env.coinpayApiUrl) { + const coinpay = await getCoinPayPortalConnection(); + if (!coinpay.apiKey || !coinpay.apiUrl) { return []; } - const base = env.coinpayApiUrl.replace(/\/$/, ""); - // env.coinpayApiUrl is the bare host (e.g. https://coinpayportal.com); - // the existing createCheckout() appends `/api/...` and we match that. + const base = coinPayApiBase(coinpay.apiUrl); + // The account setting is normalized to the bare host before appending `/api/...`. const url = `${base}/api/supported-coins?active_only=true`; try { const res = await fetch(url, { - headers: { authorization: `Bearer ${env.coinpayApiKey}` }, + headers: { authorization: `Bearer ${coinpay.apiKey}` }, cache: "no-store", }); if (!res.ok) { diff --git a/lib/coinpay.ts b/lib/coinpay.ts index e673e5c6..213656a2 100644 --- a/lib/coinpay.ts +++ b/lib/coinpay.ts @@ -1,6 +1,6 @@ -import { getOperatorSecret } from "@/lib/sealed/operator"; import { verifyCoinPayWebhook } from "@profullstack/stack/coinpay"; import { env } from "./env"; +import { getCoinPayPortalConnection } from "@/lib/platform-integrations"; // CoinPay HTTP client. // @@ -31,15 +31,21 @@ export function isSessionId(id: string | null | undefined): boolean { return typeof id === "string" && /^cs_[A-Za-z0-9]{8,64}$/.test(id); } -function coinpayBase(): string { +export function coinPayApiBase(value: string): string { + return value.replace(/\/+$/, "").replace(/\/api$/, ""); +} + +function coinpayBase(value = env.coinpayApiUrl): string { // COINPAY_API_URL has been set both with and without a trailing /api. - return env.coinpayApiUrl.replace(/\/+$/, "").replace(/\/api$/, ""); + return coinPayApiBase(value); } -function requireConfig(): void { - if (!env.coinpayApiKey || !env.coinpayApiUrl) { +async function requireConfig() { + const connection = await getCoinPayPortalConnection(); + if (!connection.apiKey || !connection.apiUrl) { throw new Error("CoinPay is not configured (COINPAY_API_URL/KEY)."); } + return connection; } export type CreateSessionInput = { @@ -78,15 +84,15 @@ export function checkoutSessionBody(input: CreateSessionInput): Record { - requireConfig(); + const connection = await requireConfig(); if (!input.customerEmail) throw new Error("An email address is required to check out."); - const base = coinpayBase(); + const base = coinpayBase(connection.apiUrl); const apiUrl = `${base}/api/checkout/sessions`; const res = await fetch(apiUrl, { method: "POST", headers: { "content-type": "application/json", - authorization: `Bearer ${env.coinpayApiKey}`, + authorization: `Bearer ${connection.apiKey}`, }, body: JSON.stringify(checkoutSessionBody(input)), }); @@ -126,7 +132,9 @@ export async function createCheckoutSession(input: CreateSessionInput): Promise< export async function selectSessionMethod(sessionId: string, method: string): Promise { if (!isSessionId(sessionId)) return null; try { - const res = await fetch(`${coinpayBase()}/api/checkout/sessions/${sessionId}/select`, { + const connection = await getCoinPayPortalConnection(); + if (!connection.apiUrl) return null; + const res = await fetch(`${coinpayBase(connection.apiUrl)}/api/checkout/sessions/${sessionId}/select`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method: method.toUpperCase() }), @@ -148,9 +156,11 @@ export type SessionStatusResult = { /** Owner view of a session (with our key): status and the payment behind it. */ export async function getCheckoutSession(sessionId: string): Promise { - if (!isSessionId(sessionId) || !env.coinpayApiUrl) return null; - const res = await fetch(`${coinpayBase()}/api/checkout/sessions/${sessionId}`, { - headers: env.coinpayApiKey ? { authorization: `Bearer ${env.coinpayApiKey}` } : {}, + if (!isSessionId(sessionId)) return null; + const connection = await getCoinPayPortalConnection(); + if (!connection.apiUrl) return null; + const res = await fetch(`${coinpayBase(connection.apiUrl)}/api/checkout/sessions/${sessionId}`, { + headers: connection.apiKey ? { authorization: `Bearer ${connection.apiKey}` } : {}, cache: "no-store", }); if (!res.ok) return null; @@ -227,8 +237,9 @@ type CoinPayPayment = { export async function getPaymentStatus( paymentId: string, ): Promise { - if (!env.coinpayApiUrl) return null; - const res = await fetch(`${coinpayBase()}/api/payments/${encodeURIComponent(paymentId)}`, { cache: "no-store" }); + const connection = await getCoinPayPortalConnection(); + if (!connection.apiUrl) return null; + const res = await fetch(`${coinpayBase(connection.apiUrl)}/api/payments/${encodeURIComponent(paymentId)}`, { cache: "no-store" }); if (!res.ok) return null; const json = (await res.json()) as { payment?: CoinPayPayment }; const p = json.payment; @@ -273,14 +284,14 @@ export async function createCryptoPayout( ): Promise { // Payout scope = an operator key: sealed, opens only while the operator is // unlocked. Locked -> retryable, so the payout stays queued (never lost). - const payoutKey = await getOperatorSecret("COINPAY_API_KEY"); - if (!payoutKey) { + const connection = await getCoinPayPortalConnection(); + if (!connection.apiKey) { return { ok: false, error: "locked: operator keys locked, unlock to resume payouts", retryable: true }; } - if (!env.coinpayApiUrl) { + if (!connection.apiUrl) { return { ok: false, error: "CoinPay is not configured.", retryable: true }; } - const base = env.coinpayApiUrl.replace(/\/$/, ""); + const base = coinpayBase(connection.apiUrl); const apiUrl = `${base}/api/payouts/create`; let res: Response; @@ -289,7 +300,7 @@ export async function createCryptoPayout( method: "POST", headers: { "content-type": "application/json", - authorization: `Bearer ${payoutKey}`, + authorization: `Bearer ${connection.apiKey}`, }, body: JSON.stringify({ recipient_email: input.recipientEmail, @@ -355,3 +366,19 @@ export function verifyWebhookSignature( toleranceSeconds: options.tolerance, }); } + +export async function verifyConnectedWebhookSignature( + rawBody: string, + signatureHeader: string | null, + options: { now?: number; tolerance?: number } = {}, +): Promise { + const connection = await getCoinPayPortalConnection(); + if (!connection.webhookSecret) return false; + return verifyCoinPayWebhook({ + signature: signatureHeader, + rawBody, + secret: connection.webhookSecret, + now: options.now, + toleranceSeconds: options.tolerance, + }); +} diff --git a/lib/env.ts b/lib/env.ts index 3db959a5..52a378ee 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -30,7 +30,7 @@ export const env = { // CoinPay — crypto credit purchases. coinpayMerchantId: process.env.COINPAY_MERCHANT_ID ?? "", coinpayApiKey: process.env.COINPAY_API_KEY ?? "", - coinpayApiUrl: process.env.COINPAY_API_URL ?? "https://coinpayportal.com/api", + coinpayApiUrl: process.env.COINPAY_API_URL ?? "https://coinpayportal.com", coinpayWebhookSecret: process.env.COINPAY_WEBHOOK_SECRET ?? "", resendApiKey: process.env.RESEND_API_KEY ?? "", resendFrom: process.env.RESEND_FROM ?? "CrawlProof ", @@ -52,6 +52,12 @@ export const env = { process.env.TELYNX_API_KEY ?? "", telnyxFrom: process.env.TELNYX_FROM ?? process.env.TELYNX_FROM ?? "", + // SMSHub — optional platform default for outbound messages. Organization + // sender configs can override both values and keep their key encrypted. + smsHubApiKey: + process.env.SMSHUB_ALERT_API_KEY ?? process.env.SMSHUB_API_KEY ?? "", + smsHubFrom: + process.env.SMSHUB_FROM_PHONE ?? process.env.SMSHUB_FROM ?? "", // Tor SOCKS5 proxy for reaching .onion targets, e.g. socks5h://127.0.0.1:9050. // Empty = .onion audits/ads are unreachable (fail with a clear message). torSocksUrl: process.env.TOR_SOCKS_URL ?? "", diff --git a/lib/outreach.ts b/lib/outreach.ts index 9992cee2..dcda783f 100644 --- a/lib/outreach.ts +++ b/lib/outreach.ts @@ -3,6 +3,7 @@ import nodemailer from "nodemailer"; import { Resend } from "resend"; import { env } from "@/lib/env"; import { decryptSecret } from "@/lib/sp/vault"; +import { getSmsHubConnection } from "@/lib/platform-integrations"; export type DeliveryResult = { sent: boolean; @@ -166,6 +167,13 @@ export async function sendOutreachSms(input: { if (input.config?.provider === "telnyx") { return sendTelnyxSms(input, input.config); } + if (input.config?.provider === "smshub") { + return sendSmsHubSms(input, input.config); + } + const smsHub = await getSmsHubConnection(); + if (smsHub.apiKey && smsHub.fromPhone) { + return sendSmsHubSms(input, { api_key: smsHub.apiKey, from_phone: smsHub.fromPhone }); + } if (env.twilioAccountSid && env.twilioAuthToken && env.twilioFrom) { return sendTwilioSms(input); } @@ -175,10 +183,50 @@ export async function sendOutreachSms(input: { return { sent: false, provider: "sms", - error: "TWILIO_* or TELNYX_* SMS env vars are required.", + error: "Connect SMSHub, or set the TWILIO_* or TELNYX_* SMS env vars.", }; } +async function sendSmsHubSms( + input: { to: string; body: string }, + config?: OutreachConfig | null, +): Promise { + let apiKey = config?.api_key; + let from = config?.from_phone; + if (!apiKey || !from) { + const platform = await getSmsHubConnection(); + apiKey ||= platform.apiKey ?? undefined; + from ||= platform.fromPhone; + } + if (!apiKey || !from) { + return { sent: false, provider: "smshub", error: "SMSHub API key and sender phone are required." }; + } + try { + const response = await fetch("https://smshub.dev/api/v1/messages/send", { + method: "POST", + headers: { "X-API-Key": apiKey, "content-type": "application/json" }, + body: JSON.stringify({ to: input.to, from, message: input.body }), + }); + const json = (await response.json().catch(() => null)) as + | { error?: string; message?: { id?: string } } + | null; + if (!response.ok) { + return { + sent: false, + provider: "smshub", + error: json?.error ?? `SMSHub returned HTTP ${response.status}.`, + }; + } + return { sent: true, provider: "smshub", providerMessageId: json?.message?.id }; + } catch (error) { + return { + sent: false, + provider: "smshub", + error: error instanceof Error ? error.message : "SMSHub send failed.", + }; + } +} + async function secretValue( encrypted: string | null | undefined, plaintext: string | null | undefined, diff --git a/lib/platform-integrations.ts b/lib/platform-integrations.ts index f487ff85..f952581f 100644 --- a/lib/platform-integrations.ts +++ b/lib/platform-integrations.ts @@ -1,6 +1,13 @@ import { serviceClient } from "@/lib/supabase/service"; import { decryptSecret, encryptSecret, canOpen } from "@/lib/sp/vault"; -import { noteOperatorLocked } from "@/lib/sealed/operator"; +import { env } from "@/lib/env"; +import { + clearOperatorSecret, + getOperatorSecret, + noteOperatorLocked, + operatorStatus, + saveOperatorSecret, +} from "@/lib/sealed/operator"; const VU1NZ_PROVIDER = "vu1nz"; const VU1NZ_NAME = "website-scanner"; @@ -127,3 +134,273 @@ export async function clearVu1nzApiToken(userId: string) { }); if (error) throw error; } + +const SMSHUB_PROVIDER = "smshub"; +const SMSHUB_NAME = "outbound-sms"; +const SMSHUB_ENDPOINT = "https://smshub.dev/api/v1/messages/send"; +const COINPAY_PROVIDER = "coinpayportal"; +const COINPAY_NAME = "crawlproof-billing"; +const COINPAY_ENDPOINT = "https://coinpayportal.com"; + +async function getPlatformConnectionRow(provider: string, name: string): Promise { + const { data, error } = await serviceClient() + .from("integrations") + .select("id, user_id, status, config, encrypted_credentials, updated_at") + .is("org_id", null) + .eq("provider", provider) + .eq("name", name) + .order("updated_at", { ascending: false }) + .limit(1); + if (error) throw error; + return ((data ?? [])[0] as IntegrationRow | undefined) ?? null; +} + +function rowConfig(row: IntegrationRow | null, key: string): string { + const value = row?.config?.[key]; + return typeof value === "string" ? value : ""; +} + +async function savePlatformConnectionRow(input: { + provider: string; + name: string; + direction: "outbound" | "bidirectional"; + userId: string; + config: Record; +}) { + const row = await getPlatformConnectionRow(input.provider, input.name); + const payload = { + user_id: input.userId, + provider: input.provider, + direction: input.direction, + name: input.name, + status: "enabled", + config: input.config, + encrypted_credentials: {}, + }; + if (row) { + const { error } = await serviceClient().from("integrations").update(payload).eq("id", row.id); + if (error) throw error; + } else { + const { error } = await serviceClient().from("integrations").insert({ ...payload, org_id: null }); + if (error) throw error; + } +} + +async function disablePlatformConnectionRow(input: { + provider: string; + name: string; + direction: "outbound" | "bidirectional"; + userId: string; +}) { + const row = await getPlatformConnectionRow(input.provider, input.name); + const payload = { + user_id: input.userId, + provider: input.provider, + direction: input.direction, + name: input.name, + status: "disabled", + config: {}, + encrypted_credentials: {}, + }; + if (row) { + const { error } = await serviceClient().from("integrations").update(payload).eq("id", row.id); + if (error) throw error; + } else { + const { error } = await serviceClient().from("integrations").insert({ ...payload, org_id: null }); + if (error) throw error; + } +} + +async function operatorKeyState(name: "SMSHUB_API_KEY" | "COINPAY_API_KEY") { + return (await operatorStatus()).find((entry) => entry.name === name); +} + +export async function getSmsHubConnection() { + const row = await getPlatformConnectionRow(SMSHUB_PROVIDER, SMSHUB_NAME); + const explicitlyDisabled = row?.status === "disabled"; + const [state, savedKey] = await Promise.all([ + operatorKeyState("SMSHUB_API_KEY"), + explicitlyDisabled ? Promise.resolve(null) : getOperatorSecret("SMSHUB_API_KEY"), + ]); + const apiKey = explicitlyDisabled ? null : savedKey; + const fromPhone = explicitlyDisabled ? "" : rowConfig(row, "from_phone") || env.smsHubFrom; + const hasApiKey = !explicitlyDisabled && Boolean(state?.sealed || state?.envFallback); + const locked = Boolean(state?.sealed && !state.unlocked); + return { + configured: hasApiKey && Boolean(fromPhone), + hasApiKey, + status: explicitlyDisabled ? "disabled" : locked ? "locked" : hasApiKey ? (fromPhone ? "enabled" : "needs_sender_phone") : "disabled", + updatedAt: row?.updated_at ?? null, + endpoint: SMSHUB_ENDPOINT, + fromPhone, + apiKey, + }; +} + +export async function getSmsHubConnectionStatus() { + const connection = await getSmsHubConnection(); + return { + configured: connection.configured, + hasApiKey: connection.hasApiKey, + status: connection.status, + updatedAt: connection.updatedAt, + endpoint: connection.endpoint, + fromPhone: connection.fromPhone, + }; +} + +export async function listSmsHubSenderNumbers(input?: { apiKey?: string }) { + const apiKey = input?.apiKey?.trim() || (await getOperatorSecret("SMSHUB_API_KEY")); + if (!apiKey) throw new Error("Unlock operator keys or paste an SMSHub API key first."); + const response = await fetch("https://smshub.dev/api/v1/numbers", { + headers: { "X-API-Key": apiKey, accept: "application/json" }, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401) throw new Error("SMSHub rejected this API key."); + if (!response.ok) throw new Error(`SMSHub sender lookup returned HTTP ${response.status}.`); + const json = (await response.json().catch(() => null)) as + | { numbers?: Array<{ number?: unknown; friendly_name?: unknown; status?: unknown }> } + | null; + const senders = (json?.numbers ?? []) + .filter((item) => item.status === "active" && typeof item.number === "string" && item.number.trim()) + .map((item) => ({ + number: (item.number as string).trim(), + label: typeof item.friendly_name === "string" && item.friendly_name.trim() + ? item.friendly_name.trim() + : (item.number as string).trim(), + })); + return senders; +} + +export async function saveSmsHubConnection(input: { apiKey?: string; fromPhone?: string; userId: string }) { + const row = await getPlatformConnectionRow(SMSHUB_PROVIDER, SMSHUB_NAME); + const explicitKey = input.apiKey?.trim() ?? ""; + const state = await operatorKeyState("SMSHUB_API_KEY"); + const apiKey = explicitKey || (await getOperatorSecret("SMSHUB_API_KEY")) || ""; + const fromPhone = input.fromPhone?.trim() || rowConfig(row, "from_phone") || env.smsHubFrom; + if (!apiKey) { + throw new Error(state?.sealed ? "Unlock operator keys or paste a replacement SMSHub API key." : "SMSHub API key is required."); + } + if (!fromPhone) throw new Error("SMSHub sender phone is required."); + + const probe = await fetch(SMSHUB_ENDPOINT, { + method: "POST", + headers: { "X-API-Key": apiKey, "content-type": "application/json" }, + body: "{}", + cache: "no-store", + }); + const probeBody = (await probe.json().catch(() => null)) as { error?: string } | null; + if (probe.status === 401) throw new Error("SMSHub rejected this API key."); + const missingMessageFields = probeBody?.error?.match(/^Missing required fields:\s*(.*)$/i)?.[1] + .split(/\s*,\s*/) + .map((field) => field.toLowerCase()) ?? []; + if ( + probe.status !== 400 || + !missingMessageFields.includes("to") || + !missingMessageFields.includes("message") + ) { + throw new Error(`SMSHub key check returned HTTP ${probe.status}.`); + } + + if (explicitKey) await saveOperatorSecret("SMSHUB_API_KEY", explicitKey, input.userId); + await savePlatformConnectionRow({ + provider: SMSHUB_PROVIDER, + name: SMSHUB_NAME, + direction: "outbound", + userId: input.userId, + config: { endpoint: SMSHUB_ENDPOINT, from_phone: fromPhone }, + }); +} + +export async function clearSmsHubConnection(userId: string) { + await disablePlatformConnectionRow({ provider: SMSHUB_PROVIDER, name: SMSHUB_NAME, direction: "outbound", userId }); + await clearOperatorSecret("SMSHUB_API_KEY"); +} + +export async function getCoinPayPortalConnection() { + const row = await getPlatformConnectionRow(COINPAY_PROVIDER, COINPAY_NAME); + const explicitlyDisabled = row?.status === "disabled"; + const [state, savedKey] = await Promise.all([ + operatorKeyState("COINPAY_API_KEY"), + explicitlyDisabled ? Promise.resolve(null) : getOperatorSecret("COINPAY_API_KEY"), + ]); + const apiKey = explicitlyDisabled ? null : savedKey; + const merchantId = explicitlyDisabled ? "" : rowConfig(row, "merchant_id") || env.coinpayMerchantId; + const apiUrl = explicitlyDisabled ? COINPAY_ENDPOINT : rowConfig(row, "api_url") || env.coinpayApiUrl || COINPAY_ENDPOINT; + const webhookSecret = env.coinpayWebhookSecret; + const hasApiKey = !explicitlyDisabled && Boolean(state?.sealed || state?.envFallback); + const locked = Boolean(state?.sealed && !state.unlocked); + return { + configured: hasApiKey && Boolean(merchantId), + hasApiKey, + hasWebhookSecret: Boolean(webhookSecret), + status: explicitlyDisabled ? "disabled" : locked ? "locked" : hasApiKey ? (merchantId ? "enabled" : "needs_business_id") : "disabled", + updatedAt: row?.updated_at ?? null, + apiKey, + merchantId, + apiUrl, + webhookSecret, + }; +} + +export async function getCoinPayPortalConnectionStatus() { + const connection = await getCoinPayPortalConnection(); + return { + configured: connection.configured, + hasApiKey: connection.hasApiKey, + hasWebhookSecret: connection.hasWebhookSecret, + status: connection.status, + updatedAt: connection.updatedAt, + merchantId: connection.merchantId, + apiUrl: connection.apiUrl, + }; +} + +export async function saveCoinPayPortalConnection(input: { + apiKey?: string; + merchantId?: string; + apiUrl?: string; + userId: string; +}) { + const row = await getPlatformConnectionRow(COINPAY_PROVIDER, COINPAY_NAME); + const explicitKey = input.apiKey?.trim() ?? ""; + const state = await operatorKeyState("COINPAY_API_KEY"); + const apiKey = explicitKey || (await getOperatorSecret("COINPAY_API_KEY")) || ""; + const merchantId = input.merchantId?.trim() || rowConfig(row, "merchant_id") || env.coinpayMerchantId; + const apiUrl = input.apiUrl?.trim() || rowConfig(row, "api_url") || env.coinpayApiUrl || COINPAY_ENDPOINT; + if (!state?.sealed && !state?.envFallback && !apiKey) throw new Error("CoinPay Portal API key is required."); + if (!merchantId) throw new Error("CoinPay Portal business ID is required."); + const base = normalizeCoinPayApiBase(apiUrl); + const parsedBase = new URL(base); + if (parsedBase.protocol !== "https:" || parsedBase.hostname !== "coinpayportal.com") { + throw new Error("CoinPay Portal API URL must use https://coinpayportal.com."); + } + if (apiKey) { + const probe = await fetch(`${base}/api/supported-coins?active_only=true`, { + headers: { authorization: `Bearer ${apiKey}` }, + cache: "no-store", + }); + if (!probe.ok) throw new Error(`CoinPay Portal rejected this key (HTTP ${probe.status}).`); + } else if (!(state?.sealed && !state.unlocked)) { + throw new Error("CoinPay Portal API key is not available."); + } + + if (explicitKey) await saveOperatorSecret("COINPAY_API_KEY", explicitKey, input.userId); + await savePlatformConnectionRow({ + provider: COINPAY_PROVIDER, + name: COINPAY_NAME, + direction: "bidirectional", + userId: input.userId, + config: { api_url: base, merchant_id: merchantId }, + }); +} + +export async function clearCoinPayPortalConnection(userId: string) { + await disablePlatformConnectionRow({ provider: COINPAY_PROVIDER, name: COINPAY_NAME, direction: "bidirectional", userId }); + await clearOperatorSecret("COINPAY_API_KEY"); +} + +function normalizeCoinPayApiBase(value: string) { + return value.replace(/\/+$/, "").replace(/\/api$/i, ""); +} diff --git a/lib/sealed/operator.ts b/lib/sealed/operator.ts index d1bd53cf..d29e789b 100644 --- a/lib/sealed/operator.ts +++ b/lib/sealed/operator.ts @@ -1,6 +1,7 @@ /** - * Operator keys (ours, not a user's): COINPAY_API_KEY for payouts, - * X402_PRIVATE_KEY for the crawler wallet. Same rule as users: the operator is + * Operator keys (ours, not a user's): COINPAY_API_KEY for billing and payouts, + * SMSHUB_API_KEY for outbound outreach, and X402_PRIVATE_KEY for the crawler + * wallet. Same rule as users: the operator is * just an admin account with a key passphrase. Each value lives sealed in * operator_sealed_secret, sealed to that admin's key, and opens only while the * admin is unlocked (default 7d for operator unlocks, Lock any time). @@ -22,7 +23,11 @@ async function serviceClient() { } import { canOpen, decryptSecret, encryptSecret } from "./vault"; -export const OPERATOR_SECRET_NAMES = ["COINPAY_API_KEY", "X402_PRIVATE_KEY"] as const; +export const OPERATOR_SECRET_NAMES = [ + "COINPAY_API_KEY", + "SMSHUB_API_KEY", + "X402_PRIVATE_KEY", +] as const; export type OperatorSecretName = (typeof OPERATOR_SECRET_NAMES)[number]; const FIELD = (name: string) => `operator.${name}`; @@ -82,7 +87,9 @@ export function setOperatorRowLoaderForTests(fn: OperatorRowLoader | null): void } function envValue(name: string): string | null { - const v = process.env[name]; + const v = + process.env[name] ?? + (name === "SMSHUB_API_KEY" ? process.env.SMSHUB_ALERT_API_KEY : undefined); return v && v.trim() ? v : null; } @@ -129,6 +136,32 @@ export async function importOperatorSecretsFromEnv(adminId: string, names: reado return { imported, missing }; } +/** Seal a newly supplied operator key without routing it through an env file. */ +export async function saveOperatorSecret( + name: OperatorSecretName, + value: string, + ownerId: string, +): Promise { + const plaintext = value.trim(); + if (!plaintext) throw new Error(`${name} is required.`); + const enc_value = await encryptSecret(plaintext, { ownerId, field: FIELD(name) }); + const { error } = await (await serviceClient()) + .from("operator_sealed_secret") + .upsert({ name, owner_id: ownerId, enc_value, updated_at: new Date().toISOString() }, { onConflict: "name" }); + if (error) throw new Error(`could not store ${name}: ${error.message}`); + rowCache.delete(name); +} + +/** Remove a sealed operator key; provider connection tombstones suppress env fallback. */ +export async function clearOperatorSecret(name: OperatorSecretName): Promise { + const { error } = await (await serviceClient()) + .from("operator_sealed_secret") + .delete() + .eq("name", name); + if (error) throw new Error(`could not clear ${name}: ${error.message}`); + rowCache.delete(name); +} + export async function operatorStatus() { const { data } = await (await serviceClient()).from("operator_sealed_secret").select("name, owner_id, updated_at"); const rows = (data as { name: string; owner_id: string; updated_at: string }[] | null) ?? []; diff --git a/supabase/migrations/20261030120000_smshub_outreach_provider.sql b/supabase/migrations/20261030120000_smshub_outreach_provider.sql new file mode 100644 index 00000000..1740d441 --- /dev/null +++ b/supabase/migrations/20261030120000_smshub_outreach_provider.sql @@ -0,0 +1,8 @@ +-- Add SMSHub as an organization-level outbound SMS provider. + +alter table public.organization_outreach_configs + drop constraint if exists organization_outreach_configs_provider_check; + +alter table public.organization_outreach_configs + add constraint organization_outreach_configs_provider_check + check (provider in ('smtp', 'resend', 'twilio', 'telnyx', 'smshub', 'manual')); diff --git a/supabase/migrations/20261030121000_platform_account_integrations.sql b/supabase/migrations/20261030121000_platform_account_integrations.sql new file mode 100644 index 00000000..7dd8ddc7 --- /dev/null +++ b/supabase/migrations/20261030121000_platform_account_integrations.sql @@ -0,0 +1,14 @@ +-- Keep one global SMSHub sender and one global CoinPay Portal billing +-- connection. These platform connections are managed from /dashboard/admin. + +create unique index if not exists integrations_smshub_platform_unique + on public.integrations(provider, name) + where org_id is null + and provider = 'smshub' + and name = 'outbound-sms'; + +create unique index if not exists integrations_coinpayportal_platform_unique + on public.integrations(provider, name) + where org_id is null + and provider = 'coinpayportal' + and name = 'crawlproof-billing'; From e7021f82f713bff3cd9d28571d758f93b4e71d53 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:49:11 +0000 Subject: [PATCH 2/6] Fix SMSHub provider config typing --- lib/outreach.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/outreach.ts b/lib/outreach.ts index dcda783f..237785bc 100644 --- a/lib/outreach.ts +++ b/lib/outreach.ts @@ -172,7 +172,11 @@ export async function sendOutreachSms(input: { } const smsHub = await getSmsHubConnection(); if (smsHub.apiKey && smsHub.fromPhone) { - return sendSmsHubSms(input, { api_key: smsHub.apiKey, from_phone: smsHub.fromPhone }); + return sendSmsHubSms(input, { + provider: "smshub", + api_key: smsHub.apiKey, + from_phone: smsHub.fromPhone, + }); } if (env.twilioAccountSid && env.twilioAuthToken && env.twilioFrom) { return sendTwilioSms(input); From 22d40a3a782035a6159dea592b08d6c283a39aa8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:51:18 +0000 Subject: [PATCH 3/6] Make integration loaders testable --- lib/platform-integrations.ts | 17 ++++++++++++++++- lib/sealed/operator.ts | 9 +++++---- tests/setup.ts | 2 ++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/platform-integrations.ts b/lib/platform-integrations.ts index f952581f..66171a9a 100644 --- a/lib/platform-integrations.ts +++ b/lib/platform-integrations.ts @@ -22,6 +22,11 @@ type IntegrationRow = { updated_at: string; }; +export type PlatformConnectionRowLoader = ( + provider: string, + name: string, +) => Promise; + async function getVu1nzRow(): Promise { const { data, error } = await serviceClient() .from("integrations") @@ -142,7 +147,7 @@ const COINPAY_PROVIDER = "coinpayportal"; const COINPAY_NAME = "crawlproof-billing"; const COINPAY_ENDPOINT = "https://coinpayportal.com"; -async function getPlatformConnectionRow(provider: string, name: string): Promise { +let platformConnectionRowLoader: PlatformConnectionRowLoader = async (provider, name) => { const { data, error } = await serviceClient() .from("integrations") .select("id, user_id, status, config, encrypted_credentials, updated_at") @@ -153,6 +158,16 @@ async function getPlatformConnectionRow(provider: string, name: string): Promise .limit(1); if (error) throw error; return ((data ?? [])[0] as IntegrationRow | undefined) ?? null; +}; + +export function setPlatformConnectionRowLoaderForTests( + loader: PlatformConnectionRowLoader | null, +): void { + if (loader) platformConnectionRowLoader = loader; +} + +async function getPlatformConnectionRow(provider: string, name: string): Promise { + return platformConnectionRowLoader(provider, name); } function rowConfig(row: IntegrationRow | null, key: string): string { diff --git a/lib/sealed/operator.ts b/lib/sealed/operator.ts index d29e789b..25051bb4 100644 --- a/lib/sealed/operator.ts +++ b/lib/sealed/operator.ts @@ -163,10 +163,11 @@ export async function clearOperatorSecret(name: OperatorSecretName): Promise { - const r = rows.find((x) => x.name === name); + const rows = await Promise.all( + OPERATOR_SECRET_NAMES.map((name) => rowLoader(name).catch(() => null)), + ); + return OPERATOR_SECRET_NAMES.map((name, index) => { + const r = rows[index]; return { name, sealed: !!r, diff --git a/tests/setup.ts b/tests/setup.ts index 1d756630..7b8328ae 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -24,4 +24,6 @@ process.env.SOCIAL_VAULT_KEY ??= Buffer.alloc(32, 7).toString("base64"); // applies unless a test installs its own loader). const { setOperatorRowLoaderForTests } = await import("../lib/sealed/operator"); setOperatorRowLoaderForTests(async () => null); +const { setPlatformConnectionRowLoaderForTests } = await import("../lib/platform-integrations"); +setPlatformConnectionRowLoaderForTests(async () => null); export {}; From cdfdd1450b7592abce2e3c3d09ce1a1f3a727681 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:52:21 +0000 Subject: [PATCH 4/6] Preserve operator key status timestamps --- lib/sealed/operator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/sealed/operator.ts b/lib/sealed/operator.ts index 25051bb4..573db1e0 100644 --- a/lib/sealed/operator.ts +++ b/lib/sealed/operator.ts @@ -67,7 +67,7 @@ export function noteOperatorLocked(what: string, fetchImpl: typeof fetch = fetch }).catch(() => {}); } -type Row = { name: string; owner_id: string; enc_value: string }; +type Row = { name: string; owner_id: string; enc_value: string; updated_at?: string }; export type OperatorRowLoader = (name: string) => Promise; // Ciphertext rows cached for a minute so a paid crawl does not hit the DB per @@ -76,7 +76,7 @@ const rowCache = new Map(); let rowLoader: OperatorRowLoader = async (name) => { const hit = rowCache.get(name); if (hit && Date.now() - hit.at < 60_000) return hit.row; - const { data, error } = await (await serviceClient()).from("operator_sealed_secret").select("name, owner_id, enc_value").eq("name", name).maybeSingle(); + const { data, error } = await (await serviceClient()).from("operator_sealed_secret").select("name, owner_id, enc_value, updated_at").eq("name", name).maybeSingle(); if (error) throw new Error(error.message); const row = (data as Row | null) ?? null; rowCache.set(name, { row, at: Date.now() }); From 8d68fea5707c7c3953e28dd89ab9fd620abcbcda Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:54:40 +0000 Subject: [PATCH 5/6] Scope platform DB stubs to CoinPay tests --- tests/ads-deposit-session.test.ts | 3 +++ tests/contract/coinpay.test.ts | 3 +++ tests/setup.ts | 2 -- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ads-deposit-session.test.ts b/tests/ads-deposit-session.test.ts index dced25b3..1b7cd7fa 100644 --- a/tests/ads-deposit-session.test.ts +++ b/tests/ads-deposit-session.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanProperty, cleanReturnUrl, createAdDeposit, parseDepositRequest } from "@/lib/ads/deposits"; +import { setPlatformConnectionRowLoaderForTests } from "@/lib/platform-integrations"; + +setPlatformConnectionRowLoaderForTests(async () => null); // Partner ad-budget deposits as a hosted CoinPay checkout session (ad.network). diff --git a/tests/contract/coinpay.test.ts b/tests/contract/coinpay.test.ts index 5463a50f..b66c91e9 100644 --- a/tests/contract/coinpay.test.ts +++ b/tests/contract/coinpay.test.ts @@ -5,6 +5,9 @@ const SECRET = "stub_webhook_secret"; process.env.COINPAY_WEBHOOK_SECRET = SECRET; import { createCheckoutSession, readCoinpayWebhook, verifyWebhookSignature } from "@/lib/coinpay"; +import { setPlatformConnectionRowLoaderForTests } from "@/lib/platform-integrations"; + +setPlatformConnectionRowLoaderForTests(async () => null); function sign(body: string, ts: number, secret = SECRET): string { const sig = crypto diff --git a/tests/setup.ts b/tests/setup.ts index 7b8328ae..1d756630 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -24,6 +24,4 @@ process.env.SOCIAL_VAULT_KEY ??= Buffer.alloc(32, 7).toString("base64"); // applies unless a test installs its own loader). const { setOperatorRowLoaderForTests } = await import("../lib/sealed/operator"); setOperatorRowLoaderForTests(async () => null); -const { setPlatformConnectionRowLoaderForTests } = await import("../lib/platform-integrations"); -setPlatformConnectionRowLoaderForTests(async () => null); export {}; From e3028856dbaa2acd61c74ee4d5e647c2de3de672 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 22:57:43 +0000 Subject: [PATCH 6/6] Pin CoinPay Portal connection origin --- .../admin/account-integrations-form.tsx | 27 ++++++------------- app/actions/admin.ts | 1 - lib/platform-integrations.ts | 17 +++--------- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/app/(app)/dashboard/admin/account-integrations-form.tsx b/app/(app)/dashboard/admin/account-integrations-form.tsx index a28ef192..cd5c944e 100644 --- a/app/(app)/dashboard/admin/account-integrations-form.tsx +++ b/app/(app)/dashboard/admin/account-integrations-form.tsx @@ -188,12 +188,10 @@ export function CoinPayPortalConnectionForm({ updatedAt, hasWebhookSecret, merchantId, - apiUrl, -}: ConnectionStatus & { hasWebhookSecret: boolean; merchantId: string; apiUrl: string }) { +}: ConnectionStatus & { hasWebhookSecret: boolean; merchantId: string }) { const router = useRouter(); const [apiKey, setApiKey] = useState(""); const [businessId, setBusinessId] = useState(merchantId); - const [endpoint, setEndpoint] = useState(apiUrl); const [revealedKey, setRevealedKey] = useState(null); const [message, setMessage] = useState(null); const [pending, start] = useTransition(); @@ -205,7 +203,6 @@ export function CoinPayPortalConnectionForm({ const result = await saveCoinPayPortalPlatformIntegration({ apiKey, merchantId: businessId, - apiUrl: endpoint, }); if (!result.ok) { setMessage(result.error); @@ -261,20 +258,12 @@ export function CoinPayPortalConnectionForm({ return (

-
- - -
+ )}

- Powers CrawlProof credit purchases and payouts. The API key is sealed to your operator key. + Connects to https://coinpayportal.com for CrawlProof credit purchases and payouts. The API key is sealed to your operator key.

{configured && !hasWebhookSecret && (

diff --git a/app/actions/admin.ts b/app/actions/admin.ts index 987452ac..9473e6ec 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -241,7 +241,6 @@ export async function listSmsHubPlatformSenders(input?: { apiKey?: string }): Pr export async function saveCoinPayPortalPlatformIntegration(input: { apiKey?: string; merchantId?: string; - apiUrl?: string; clear?: boolean; }): Promise { const adminCheck = await assertAdmin(); diff --git a/lib/platform-integrations.ts b/lib/platform-integrations.ts index 66171a9a..750dc4c1 100644 --- a/lib/platform-integrations.ts +++ b/lib/platform-integrations.ts @@ -342,7 +342,7 @@ export async function getCoinPayPortalConnection() { ]); const apiKey = explicitlyDisabled ? null : savedKey; const merchantId = explicitlyDisabled ? "" : rowConfig(row, "merchant_id") || env.coinpayMerchantId; - const apiUrl = explicitlyDisabled ? COINPAY_ENDPOINT : rowConfig(row, "api_url") || env.coinpayApiUrl || COINPAY_ENDPOINT; + const apiUrl = env.coinpayApiUrl || COINPAY_ENDPOINT; const webhookSecret = env.coinpayWebhookSecret; const hasApiKey = !explicitlyDisabled && Boolean(state?.sealed || state?.envFallback); const locked = Boolean(state?.sealed && !state.unlocked); @@ -375,7 +375,6 @@ export async function getCoinPayPortalConnectionStatus() { export async function saveCoinPayPortalConnection(input: { apiKey?: string; merchantId?: string; - apiUrl?: string; userId: string; }) { const row = await getPlatformConnectionRow(COINPAY_PROVIDER, COINPAY_NAME); @@ -383,16 +382,10 @@ export async function saveCoinPayPortalConnection(input: { const state = await operatorKeyState("COINPAY_API_KEY"); const apiKey = explicitKey || (await getOperatorSecret("COINPAY_API_KEY")) || ""; const merchantId = input.merchantId?.trim() || rowConfig(row, "merchant_id") || env.coinpayMerchantId; - const apiUrl = input.apiUrl?.trim() || rowConfig(row, "api_url") || env.coinpayApiUrl || COINPAY_ENDPOINT; if (!state?.sealed && !state?.envFallback && !apiKey) throw new Error("CoinPay Portal API key is required."); if (!merchantId) throw new Error("CoinPay Portal business ID is required."); - const base = normalizeCoinPayApiBase(apiUrl); - const parsedBase = new URL(base); - if (parsedBase.protocol !== "https:" || parsedBase.hostname !== "coinpayportal.com") { - throw new Error("CoinPay Portal API URL must use https://coinpayportal.com."); - } if (apiKey) { - const probe = await fetch(`${base}/api/supported-coins?active_only=true`, { + const probe = await fetch(`${COINPAY_ENDPOINT}/api/supported-coins?active_only=true`, { headers: { authorization: `Bearer ${apiKey}` }, cache: "no-store", }); @@ -407,7 +400,7 @@ export async function saveCoinPayPortalConnection(input: { name: COINPAY_NAME, direction: "bidirectional", userId: input.userId, - config: { api_url: base, merchant_id: merchantId }, + config: { merchant_id: merchantId }, }); } @@ -415,7 +408,3 @@ export async function clearCoinPayPortalConnection(userId: string) { await disablePlatformConnectionRow({ provider: COINPAY_PROVIDER, name: COINPAY_NAME, direction: "bidirectional", userId }); await clearOperatorSecret("COINPAY_API_KEY"); } - -function normalizeCoinPayApiBase(value: string) { - return value.replace(/\/+$/, "").replace(/\/api$/i, ""); -}