Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
// PayPal REST API client — OAuth2 client-credentials + a thin fetch helper.
|
|
//
|
|
// Enabled only when PAYPAL_CLIENT_ID and PAYPAL_SECRET are set (mirrors the
|
|
// gating used for the other optional integrations). PAYPAL_ENVIRONMENT selects
|
|
// the sandbox (default) or live host.
|
|
|
|
const ENVIRONMENT = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
|
|
|
|
const BASE_URL =
|
|
ENVIRONMENT === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
|
|
|
|
export function paypalConfigured(): boolean {
|
|
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET)
|
|
}
|
|
|
|
export function paypalEnvironment() {
|
|
return ENVIRONMENT
|
|
}
|
|
|
|
// Access tokens live ~9h; cache in-process (the app runs a persistent Node
|
|
// server, so this survives across requests) and refresh a minute early.
|
|
let cachedToken: { token: string; expiresAt: number } | null = null
|
|
|
|
async function getAccessToken(): Promise<string> {
|
|
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) return cachedToken.token
|
|
|
|
const id = process.env.PAYPAL_CLIENT_ID
|
|
const secret = process.env.PAYPAL_SECRET
|
|
if (!id || !secret) throw new Error("PayPal is not configured")
|
|
|
|
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: "grant_type=client_credentials",
|
|
})
|
|
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text().catch(() => "")}`)
|
|
|
|
const json = (await res.json()) as { access_token: string; expires_in: number }
|
|
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
|
|
return cachedToken.token
|
|
}
|
|
|
|
/** Authenticated fetch against the PayPal REST API. Path is relative (e.g. "/v1/..."). */
|
|
export async function paypalFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
const token = await getAccessToken()
|
|
return fetch(`${BASE_URL}${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
...(init.headers ?? {}),
|
|
},
|
|
})
|
|
}
|