Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+28 -9
View File
@@ -1,22 +1,41 @@
import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
// and is tamper-proof, so the callback can't be forged/CSRF'd.
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider,
// a random nonce (bound to a cookie by the connect route for CSRF protection),
// and an issued-at timestamp so a leaked state can't be replayed indefinitely.
export function signState(data: { ownerId: string; provider: string }): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
// Short-lived httpOnly cookie the connect route sets and the callback verifies
// against the state's nonce (binds the OAuth round-trip to the initiating browser).
export const OAUTH_NONCE_COOKIE = "acct_oauth_nonce"
// No insecure fallback: signing/verifying state without the real secret would
// let anyone forge a state for any owner, so we fail closed (mirrors lib/crypto.ts).
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type OAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: OAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): { ownerId: string; provider: string } | null {
export function verifyState(state: string): OAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as OAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
+1
View File
@@ -12,6 +12,7 @@ export type AdminAction =
| "delete_user"
| "resend_verification"
| "maintenance_mode"
| "ai_provider"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+15
View File
@@ -0,0 +1,15 @@
import Anthropic from "@anthropic-ai/sdk"
// Lazily construct the Anthropic client so `next build` does NOT require
// ANTHROPIC_API_KEY — it's only needed at runtime when the admin selects the
// Anthropic (Claude) provider for AI features. Mirrors lib/ai/client.ts.
let _anthropic: Anthropic | null = null
export function getAnthropic(): Anthropic {
if (!_anthropic) {
const key = process.env.ANTHROPIC_API_KEY
if (!key) throw new Error("ANTHROPIC_API_KEY is not set")
_anthropic = new Anthropic({ apiKey: key })
}
return _anthropic
}
+11
View File
@@ -1,5 +1,16 @@
import OpenAI from "openai"
// True when the server has an AI provider key (OpenAI OR Anthropic). AI routes
// check this up front and return a friendly 503 instead of throwing, matching
// how the other optional integrations (Stripe / SMTP / Turnstile) degrade when
// unconfigured. The active provider is chosen by an admin (see lib/ai/provider).
export function aiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY)
}
export const AI_UNCONFIGURED_ERROR =
"AI features aren't configured on this server (no OpenAI or Anthropic API key). Ask your administrator to enable them."
// Lazily construct the OpenAI client so `next build` does NOT require
// OPENAI_API_KEY — it's only needed at runtime. Call sites keep using
// `openai.xxx` unchanged; the Proxy builds the real client on first access.
+146
View File
@@ -0,0 +1,146 @@
import { eq } from "drizzle-orm"
import type OpenAI from "openai"
import { db } from "@/lib/db"
import { app_settings } from "@/lib/db/schema"
import { openai } from "@/lib/ai/client"
import { getAnthropic } from "@/lib/ai/anthropic"
// ============================================================================
// AI provider abstraction — one call site for every AI feature, backed by
// EITHER OpenAI or Anthropic (Claude). The active provider is chosen by an admin
// in Settings → System (persisted in app_settings), falling back to whichever
// provider actually has an API key configured on the server.
// ============================================================================
export type AiProvider = "openai" | "anthropic"
export type AiRole = "system" | "user" | "assistant"
export type AiMessage = { role: AiRole; content: string }
export const AI_PROVIDER_KEY = "ai_provider"
// Models are env-overridable. Both default to each provider's cheapest tier to
// keep token spend low: OpenAI gpt-4o-mini, Anthropic Claude Haiku 4.5 ($1/$5
// per 1M). Pin a stronger model via OPENAI_MODEL / ANTHROPIC_MODEL if desired.
export const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini"
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5"
export function openaiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY)
}
export function anthropicConfigured(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY)
}
function isProvider(v: unknown): v is AiProvider {
return v === "openai" || v === "anthropic"
}
/** The admin-selected provider (defaults to openai). Fails safe to openai. */
export async function getAiProvider(): Promise<AiProvider> {
try {
const row = await db.query.app_settings.findFirst({
where: eq(app_settings.key, AI_PROVIDER_KEY),
})
const v = (row?.value as { provider?: string } | null)?.provider
return isProvider(v) ? v : "openai"
} catch {
return "openai"
}
}
/** Persist the admin's provider choice. Admin-gated by the calling action. */
export async function setAiProvider(provider: AiProvider): Promise<void> {
await db
.insert(app_settings)
.values({ key: AI_PROVIDER_KEY, value: { provider } })
.onConflictDoUpdate({
target: app_settings.key,
set: { value: { provider }, updated_at: new Date().toISOString() },
})
}
/**
* The provider actually used for a request: the selected one, unless it has no
* API key on the server and the other provider does — then we fall back so AI
* features keep working after a provider switch even if the key isn't set yet.
*/
function resolveEffective(selected: AiProvider): AiProvider {
if (selected === "anthropic" && !anthropicConfigured() && openaiConfigured()) return "openai"
if (selected === "openai" && !openaiConfigured() && anthropicConfigured()) return "anthropic"
return selected
}
/** Everything the admin UI needs to render the provider picker. */
export async function aiProviderStatus() {
const selected = await getAiProvider()
return {
selected,
effective: resolveEffective(selected),
openaiConfigured: openaiConfigured(),
anthropicConfigured: anthropicConfigured(),
openaiModel: OPENAI_MODEL,
anthropicModel: ANTHROPIC_MODEL,
}
}
/** Strip a ```json fenced code block, if the model wrapped its JSON in one. */
function stripFences(s: string): string {
return s
.replace(/^\s*```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim()
}
/**
* Provider-agnostic single-shot completion. Returns the model's text output.
*
* `json: true` asks for a JSON object (OpenAI uses response_format; both
* providers rely on the prompt saying "JSON only") and strips any code fences
* so the caller can `JSON.parse` the result directly.
*/
export async function aiComplete(opts: {
messages: AiMessage[]
maxTokens?: number
json?: boolean
}): Promise<string> {
const provider = resolveEffective(await getAiProvider())
const maxTokens = opts.maxTokens ?? 1024
let text: string
if (provider === "anthropic") {
// Anthropic takes a top-level `system`; the rest are user/assistant turns.
const system = opts.messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n")
const convo = opts.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: (m.role === "assistant" ? "assistant" : "user") as "assistant" | "user", content: m.content }))
if (convo.length === 0) convo.push({ role: "user", content: system || "Continue." })
const res = await getAnthropic().messages.create({
model: ANTHROPIC_MODEL,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: convo,
})
text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("")
} else {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = opts.messages.map((m) =>
m.role === "system"
? { role: "system", content: m.content }
: m.role === "assistant"
? { role: "assistant", content: m.content }
: { role: "user", content: m.content }
)
const res = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: maxTokens,
messages,
...(opts.json ? { response_format: { type: "json_object" as const } } : {}),
})
text = res.choices[0]?.message?.content ?? ""
}
return opts.json ? stripFences(text) : text
}
+1
View File
@@ -254,6 +254,7 @@ export function getEnvHealth() {
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"SMTP_HOST",
"SMTP_USER",
"GOOGLE_CLIENT_ID",
@@ -0,0 +1,17 @@
CREATE TABLE "esign_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text,
"expires_at" timestamp with time zone,
"account_id" text,
"base_uri" text,
"account_name" text,
"status" text DEFAULT 'active' NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "esign_connections" ADD CONSTRAINT "esign_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1782994066547,
"tag": "0009_amusing_blackheart",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1783017593260,
"tag": "0010_esign_connections",
"breakpoints": true
}
]
}
+26
View File
@@ -631,6 +631,32 @@ export const signature_requests = pgTable("signature_requests", {
updated_at: updatedAt(),
})
// ============================================================
// E-SIGN CONNECTIONS (per-landlord DocuSign OAuth / Dropbox Sign API key)
// ============================================================
// One row per (owner, provider). Each landlord connects THEIR OWN e-signature
// account, so leases are sent from their brand with their audit trail. DocuSign
// uses OAuth (access + refresh tokens); Dropbox Sign uses an API key stored in
// `access_token`. All secrets are AES-256-GCM encrypted (see lib/crypto.ts).
export const esign_connections = pgTable("esign_connections", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
access_token: text("access_token").notNull(), // encrypted (DocuSign access token / Dropbox Sign API key)
refresh_token: text("refresh_token"), // encrypted (DocuSign only)
expires_at: tstz("expires_at"),
// DocuSign account id + base uri from /oauth/userinfo (null for Dropbox Sign).
account_id: text("account_id"),
base_uri: text("base_uri"),
account_name: text("account_name"),
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
last_error: text("last_error"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
// ============================================================
+104
View File
@@ -0,0 +1,104 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { esign_connections } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { getAdapter } from "./registry"
import type { ESignCredentials, ESignProvider, ESignTokens } from "./types"
// Per-owner e-sign connection storage + credential resolution. Mirrors
// lib/accounting/index.ts: tokens are AES-256-GCM encrypted at rest, decrypted
// on demand, and DocuSign access tokens are transparently refreshed near expiry.
/** Upsert an encrypted connection for (owner, provider). */
export async function saveEsignConnection(ownerId: string, provider: ESignProvider, tokens: ESignTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: tokens.refreshToken ? encrypt(tokens.refreshToken) : null,
expires_at: tokens.expiresAt,
account_id: tokens.accountId,
base_uri: tokens.baseUri,
account_name: tokens.accountName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db
.update(esign_connections)
.set({ ...values, updated_at: new Date().toISOString() })
.where(eq(esign_connections.id, existing.id))
} else {
await db.insert(esign_connections).values(values)
}
}
export async function getEsignConnection(ownerId: string, provider: ESignProvider) {
return db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
})
}
/** Owner-facing list — never leaks tokens. */
export async function listEsignConnections(ownerId: string) {
const rows = await db.query.esign_connections.findMany({ where: eq(esign_connections.user_id, ownerId) })
return rows.map((r) => ({
provider: r.provider,
accountName: r.account_name,
status: r.status,
lastError: r.last_error,
}))
}
export async function disconnectEsign(ownerId: string, provider: ESignProvider) {
await db
.delete(esign_connections)
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
/**
* Resolve ready-to-use credentials for a connected account, refreshing the
* DocuSign access token first if it's near expiry. Returns null when the owner
* hasn't connected this provider.
*/
export async function resolveEsignCreds(ownerId: string, provider: ESignProvider): Promise<ESignCredentials | null> {
const conn = await getEsignConnection(ownerId, provider)
if (!conn || conn.status === "revoked") return null
let accessToken = decrypt(conn.access_token)
const refreshToken = conn.refresh_token ? decrypt(conn.refresh_token) : null
let accountId = conn.account_id
let baseUri = conn.base_uri
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 5 * 60_000
if (nearExpiry && refreshToken) {
const adapter = getAdapter(provider)
if (adapter) {
const next = await adapter.refresh(refreshToken)
// Account id / base uri are stable across refresh — keep the stored ones.
await saveEsignConnection(ownerId, provider, {
...next,
accountId: conn.account_id,
baseUri: conn.base_uri,
accountName: conn.account_name,
})
accessToken = next.accessToken
accountId = conn.account_id
baseUri = conn.base_uri
}
}
return { provider, accessToken, refreshToken, accountId, baseUri }
}
/** Flag a connection as errored (e.g. after a failed send/refresh). */
export async function markEsignError(ownerId: string, provider: ESignProvider, message: string) {
await db
.update(esign_connections)
.set({ status: "error", last_error: message.slice(0, 500), updated_at: new Date().toISOString() })
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
+135 -27
View File
@@ -1,44 +1,139 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types"
import { esignRedirectUri } from "./types"
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
// DocuSign eSignature via per-landlord OAuth (Authorization Code Grant).
// The OPERATOR registers one DocuSign app and sets these; each LANDLORD then
// connects their own DocuSign account through it.
// DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret
// DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod)
const CLIENT_ID = process.env.DOCUSIGN_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.DOCUSIGN_CLIENT_SECRET ?? ""
const OAUTH_BASE = (process.env.DOCUSIGN_OAUTH_BASE ?? "account-d.docusign.com").replace(/^https?:\/\//, "").replace(/\/+$/, "")
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
function extOf(name: string): string {
const e = name.split(".").pop()?.toLowerCase()
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
}
/** Map a DocuSign envelope/event status string to one of our terminal statuses. */
function mapStatus(raw: string): "signed" | "declined" | "voided" | null {
const s = raw.toLowerCase()
if (s.includes("completed") || s.includes("signed")) return "signed"
if (s.includes("declined")) return "declined"
if (s.includes("voided")) return "voided"
return null
}
async function tokenRequest(form: Record<string, string>): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/token`, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`DocuSign token error ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
async function userInfo(accessToken: string): Promise<{ accountId: string | null; baseUri: string | null; accountName: string | null }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
})
if (!res.ok) throw new Error(`DocuSign userinfo ${res.status}`)
const j = (await res.json()) as { accounts?: { account_id: string; base_uri: string; account_name: string; is_default: boolean }[] }
const acct = j.accounts?.find((a) => a.is_default) ?? j.accounts?.[0]
return { accountId: acct?.account_id ?? null, baseUri: acct?.base_uri ?? null, accountName: acct?.account_name ?? null }
}
/** REST API base for the envelopes API, e.g. https://na3.docusign.net/restapi/v2.1/accounts/<id> */
function apiBase(creds: ESignCredentials): string {
return `${(creds.baseUri ?? "").replace(/\/+$/, "")}/restapi/v2.1/accounts/${creds.accountId}`
}
export const docusign: ESignAdapter = {
id: "docusign",
label: "DocuSign",
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
kind: "oauth",
available: () => Boolean(CLIENT_ID && CLIENT_SECRET),
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
getAuthUrl(state) {
const p = new URLSearchParams({
response_type: "code",
// `extended` is required to receive a refresh token.
scope: "signature extended",
client_id: CLIENT_ID,
redirect_uri: esignRedirectUri("docusign"),
state,
})
return `https://${OAUTH_BASE}/oauth/auth?${p.toString()}`
},
async exchangeCode(code): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "authorization_code", code })
const info = await userInfo(t.access_token)
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: info.accountId,
baseUri: info.baseUri,
accountName: info.accountName,
}
},
async refresh(refreshToken): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken })
// Account id / base uri are stable across refreshes; the resolver re-attaches them.
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: null,
baseUri: null,
accountName: null,
}
},
async connectApiKey(): Promise<ESignTokens> {
throw new Error("DocuSign connects via OAuth, not an API key")
},
async send(creds, p: SendParams) {
const envelope = {
emailSubject: subject,
emailSubject: p.subject,
status: "sent",
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }],
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
email: p.signerEmail,
name: p.signerName,
recipientId: "1",
routingOrder: "1",
// Default sign placement (bottom of page 1). Use a template/anchor
// string for precise field placement in production.
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
},
],
},
// Envelope-level Connect: DocuSign pings our webhook on completion so we
// pull the authoritative status. No account-level Connect config needed.
eventNotification: {
url: p.webhookUrl,
loggingEnabled: "true",
requireAcknowledgment: "true",
envelopeEvents: [
{ envelopeEventStatusCode: "completed" },
{ envelopeEventStatusCode: "declined" },
{ envelopeEventStatusCode: "voided" },
],
eventData: { version: "restv2.1" },
},
}
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
const res = await fetch(`${apiBase(creds)}/envelopes`, {
method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(envelope),
})
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`)
@@ -47,19 +142,32 @@ export const docusign: ESignAdapter = {
return { externalId: j.envelopeId }
},
parseWebhook(body): WebhookResult | null {
// DocuSign Connect (JSON format) payload.
peekExternalId(body): string | null {
try {
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
const externalId = j.data?.envelopeId
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
if (!externalId) return null
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
if (status.includes("declined")) return { externalId, status: "declined" }
if (status.includes("voided")) return { externalId, status: "voided" }
return null
const j = JSON.parse(body) as { data?: { envelopeId?: string } }
return j.data?.envelopeId ?? null
} catch {
return null
}
},
// We never trust the webhook body's status. Instead we fetch the envelope from
// DocuSign with the owner's own OAuth token — a forged webhook can at most make
// us re-read the real status, never fabricate a "signed".
async verifyAndGetStatus(creds, externalId) {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/json" },
})
if (!res.ok) return null
const j = (await res.json()) as { status?: string }
return j.status ? mapStatus(j.status) : null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}/documents/combined`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/pdf" },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+92 -31
View File
@@ -1,30 +1,83 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import crypto from "crypto"
import type { ESignAdapter, ESignTokens, SendParams } from "./types"
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
// Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
// pastes their own API key (no platform app credentials needed). Docs:
// https://developers.hellosign.com/api/reference/
const BASE = "https://api.hellosign.com/v3"
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
function auth() {
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
function authFor(apiKey: string) {
return "Basic " + Buffer.from(`${apiKey}:`).toString("base64")
}
/** Verify the `event_hash` (hex HMAC-SHA256 of event_time+event_type, key = API key). */
function verifyEventHash(apiKey: string, ev?: { event_type?: string; event_time?: string; event_hash?: string }): boolean {
if (!apiKey || !ev?.event_type || !ev?.event_time || !ev?.event_hash) return false
const expected = crypto.createHmac("sha256", apiKey).update(ev.event_time + ev.event_type).digest("hex")
const a = Buffer.from(ev.event_hash)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
type DbxEvent = {
event?: { event_type?: string; event_time?: string; event_hash?: string }
signature_request?: { signature_request_id?: string }
}
function parseBody(body: string): DbxEvent | null {
try {
// Dropbox Sign posts multipart form-data with a `json` field (or raw JSON).
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
return JSON.parse(m ? m[1] : body)
} catch {
return null
}
}
export const dropboxSign: ESignAdapter = {
id: "dropbox_sign",
label: "Dropbox Sign",
configured: () => Boolean(API_KEY),
kind: "apikey",
available: () => true, // landlord brings their own key; no operator setup required
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
getAuthUrl(): string {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async exchangeCode(): Promise<ESignTokens> {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async refresh(): Promise<ESignTokens> {
throw new Error("Dropbox Sign API keys don't expire")
},
async connectApiKey(apiKey): Promise<ESignTokens> {
const key = apiKey.trim()
if (!key) throw new Error("Enter your Dropbox Sign API key")
const res = await fetch(`${BASE}/account`, { headers: { Authorization: authFor(key) } })
if (res.status === 401 || res.status === 403) throw new Error("That API key was rejected by Dropbox Sign")
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: could not validate the API key`)
const j = (await res.json()) as { account?: { email_address?: string } }
return {
accessToken: key,
refreshToken: null,
expiresAt: null,
accountId: null,
baseUri: null,
accountName: j.account?.email_address ?? "Dropbox Sign account",
}
},
async send(creds, p: SendParams) {
const fd = new FormData()
fd.append("subject", subject)
fd.append("message", message)
fd.append("subject", p.subject)
fd.append("message", p.message)
fd.append("test_mode", TEST_MODE)
fd.append("signers[0][email_address]", signerEmail)
fd.append("signers[0][name]", signerName)
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
fd.append("signers[0][email_address]", p.signerEmail)
fd.append("signers[0][name]", p.signerName)
fd.append("file[0]", new Blob([new Uint8Array(p.document)], { type: "application/pdf" }), p.documentName)
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: authFor(creds.accessToken) }, body: fd })
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
const id = j.signature_request?.signature_request_id
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
return { externalId: id }
},
parseWebhook(body): WebhookResult | null {
// Dropbox Sign posts multipart form-data with a `json` field.
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
try {
// Extract the JSON payload whether sent raw or as a form field.
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
event = JSON.parse(m ? m[1] : body)
} catch {
return null
peekExternalId(body): string | null {
return parseBody(body)?.signature_request?.signature_request_id ?? null
},
async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
const ev = parseBody(body)
if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
switch (ev.event?.event_type) {
case "signature_request_all_signed":
return "signed"
case "signature_request_declined":
return "declined"
case "signature_request_canceled":
return "voided"
default:
return null
}
const type = event.event?.event_type
const externalId = event.signature_request?.signature_request_id
if (!externalId || !type) return null
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
if (type === "signature_request_declined") return { externalId, status: "declined" }
if (type === "signature_request_canceled") return { externalId, status: "voided" }
return null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
headers: { Authorization: authFor(creds.accessToken) },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+73 -30
View File
@@ -1,42 +1,48 @@
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage"
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
import { readFile, saveBuffer } from "@/lib/storage"
import { getAdapter } from "./registry"
import { resolveEsignCreds, markEsignError } from "./credentials"
import type { ESignProvider } from "./types"
export type { ESignProvider } from "./types"
export { getAdapter, listEsignAdapters } from "./registry"
export {
resolveEsignCreds,
listEsignConnections,
getEsignConnection,
saveEsignConnection,
disconnectEsign,
} from "./credentials"
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
const FILES_PREFIX = "/api/files/"
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
export function listAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
}
export function anyEsignConfigured(): boolean {
return listAdapters().some((a) => a.configured)
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
function webhookUrl(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/webhook`
}
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/"
if (documentUrl.startsWith(prefix)) {
const key = documentUrl.slice(prefix.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
if (!documentUrl.startsWith(FILES_PREFIX)) {
throw new Error("Lease document must be an uploaded file")
}
const res = await fetch(documentUrl)
if (!res.ok) throw new Error("Could not fetch the lease document")
return { bytes: Buffer.from(await res.arrayBuffer()), name: documentUrl.split("/").pop()?.split("?")[0] ?? "lease.pdf" }
const key = documentUrl.slice(FILES_PREFIX.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
}
/**
* Send a lease for signature through the owner's OWN connected account.
* Requires the provider to be connected (per-user OAuth / API key).
*/
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
const adapter = getAdapter(provider)
if (!adapter) throw new Error("Unknown provider")
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
const creds = await resolveEsignCreds(ownerId, provider)
if (!creds) throw new Error(`Connect your ${adapter.label} account in Settings → Integrations first`)
const lease = await db.query.leases.findFirst({
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
@@ -51,13 +57,14 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
const { bytes, name } = await getDocumentBytes(lease.document_url)
try {
const { externalId } = await adapter.send({
const { externalId } = await adapter.send(creds, {
document: bytes,
documentName: name,
signerEmail: email,
signerName,
subject: "Please sign your lease agreement",
message: "Your landlord has sent your lease agreement for electronic signature.",
webhookUrl: webhookUrl(provider),
})
const [row] = await db
.insert(signature_requests)
@@ -66,6 +73,7 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
return row
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await markEsignError(ownerId, provider, msg)
await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
@@ -80,18 +88,53 @@ export async function listRequestsForLease(ownerId: string, leaseId: string) {
})
}
/** Update a request's status from an inbound provider webhook. */
/**
* Process an inbound provider webhook. The body is UNTRUSTED: we use it only to
* find which signature request (and therefore which owner + credentials) it
* concerns, then authenticate the event via the adapter (DocuSign pull-verify /
* Dropbox HMAC) before updating status and archiving the signed document.
*/
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
const adapter = getAdapter(provider)
if (!adapter) return
const result = adapter.parseWebhook(body, headers)
if (!result) return
const externalId = adapter.peekExternalId(body)
if (!externalId) return
const reqRow = await db.query.signature_requests.findFirst({
where: eq(signature_requests.external_id, externalId),
columns: { id: true, user_id: true, status: true },
})
if (!reqRow) return
const creds = await resolveEsignCreds(reqRow.user_id, provider as ESignProvider)
if (!creds) return
const status = await adapter.verifyAndGetStatus(creds, externalId, body, headers)
if (!status) return
await db
.update(signature_requests)
.set({
status: result.status,
completed_at: result.status === "signed" ? new Date().toISOString() : null,
status,
completed_at: status === "signed" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
})
.where(eq(signature_requests.external_id, result.externalId))
.where(eq(signature_requests.id, reqRow.id))
// Archive the executed document so the landlord can download the signed copy.
if (status === "signed") {
try {
const bytes = await adapter.getSignedDocument(creds, externalId)
if (bytes && bytes.length) {
const { key } = await saveBuffer(bytes, { userId: reqRow.user_id, scope: "esign", ext: "pdf" })
await db
.update(signature_requests)
.set({ signed_document_url: `${FILES_PREFIX}${key}` })
.where(eq(signature_requests.id, reqRow.id))
}
} catch {
// Best-effort — status is already recorded.
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
// Adapter registry — dependency-free (no db) so it can be imported anywhere,
// including the credential resolver, without creating import cycles.
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
/** Providers the platform can offer, with their connect style + availability. */
export function listEsignAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({
id,
label: ADAPTERS[id].label,
kind: ADAPTERS[id].kind,
available: ADAPTERS[id].available(),
}))
}
+39
View File
@@ -0,0 +1,39 @@
import crypto from "crypto"
// Signed OAuth `state` for the e-sign connect flow — carries the initiating
// owner + provider + a random nonce (bound to a cookie by the connect route),
// plus an issued-at so a leaked state can't be replayed. Mirrors the hardened
// accounting OAuth state; fails closed if BETTER_AUTH_SECRET is missing.
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
export const ESIGN_NONCE_COOKIE = "esign_oauth_nonce"
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type EsignOAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: EsignOAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): EsignOAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as EsignOAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
}
+67 -11
View File
@@ -1,5 +1,27 @@
export type ESignProvider = "docusign" | "dropbox_sign"
export type ESignStatus = "signed" | "declined" | "voided"
/** Result of connecting an account (OAuth exchange or API-key validation). */
export interface ESignTokens {
accessToken: string
refreshToken: string | null
/** ISO expiry of the access token, or null (API keys don't expire). */
expiresAt: string | null
accountId: string | null
baseUri: string | null
accountName: string | null
}
/** Decrypted, ready-to-use credentials for a single connected account. */
export interface ESignCredentials {
provider: ESignProvider
accessToken: string
refreshToken?: string | null
accountId?: string | null
baseUri?: string | null
}
export interface SendParams {
document: Buffer
documentName: string
@@ -7,20 +29,54 @@ export interface SendParams {
signerName: string
subject: string
message: string
}
export interface WebhookResult {
externalId: string
status: "signed" | "declined" | "voided"
/** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
webhookUrl: string
}
export interface ESignAdapter {
id: ESignProvider
label: string
/** True when this provider's credentials are configured in env. */
configured(): boolean
/** Send a document for signature; returns the provider's request/envelope id. */
send(p: SendParams): Promise<{ externalId: string }>
/** Parse an inbound webhook body into a status update (or null to ignore). */
parseWebhook(body: string, headers: Headers): WebhookResult | null
/** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
kind: "oauth" | "apikey"
/**
* True when the platform can offer this provider. OAuth providers need the
* operator's app credentials (client id/secret); API-key providers are always
* available because the landlord brings their own key.
*/
available(): boolean
// ── OAuth providers (DocuSign) ────────────────────────────────────────────
getAuthUrl(state: string): string
exchangeCode(code: string): Promise<ESignTokens>
refresh(refreshToken: string): Promise<ESignTokens>
// ── API-key providers (Dropbox Sign) ──────────────────────────────────────
/** Validate a pasted API key and return a token set to store. */
connectApiKey(apiKey: string): Promise<ESignTokens>
// ── Common ────────────────────────────────────────────────────────────────
/** Send a document for signature; returns the provider's envelope/request id. */
send(creds: ESignCredentials, params: SendParams): Promise<{ externalId: string }>
/** Extract the external id from an inbound (still UNVERIFIED) webhook body, for owner lookup. */
peekExternalId(body: string): string | null
/**
* Authenticate an inbound webhook and return the authoritative status.
* DocuSign pull-verifies by fetching the envelope with the owner's token;
* Dropbox Sign HMAC-verifies the body with the account API key. Returns null
* if the event isn't authentic or isn't a terminal status we track.
*/
verifyAndGetStatus(
creds: ESignCredentials,
externalId: string,
body: string,
headers: Headers
): Promise<ESignStatus | null>
/** Download the completed/executed document, or null if unavailable. */
getSignedDocument(creds: ESignCredentials, externalId: string): Promise<Buffer | null>
}
/** The OAuth callback URL for a provider (must match what's registered in the provider app). */
export function esignRedirectUri(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/callback`
}
-123
View File
@@ -1,123 +0,0 @@
import { paypalFetch } from "./client"
// We encode the app user id + target plan into PayPal's `custom_id` so webhooks
// and the return handler can resolve who/what a subscription or order is for,
// without trusting query params. Format: "<userId>:<plan>".
export function encodeCustomId(userId: string, plan: string): string {
return `${userId}:${plan}`
}
export function decodeCustomId(customId: string | null | undefined): { userId: string; plan: string } | null {
if (!customId) return null
const idx = customId.lastIndexOf(":")
if (idx <= 0) return null
return { userId: customId.slice(0, idx), plan: customId.slice(idx + 1) }
}
function approveUrl(links: Array<{ rel: string; href: string }> | undefined): string | undefined {
return links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href
}
const BRAND = "Property Management Network"
/** Create a recurring subscription; returns its id + the PayPal approval URL. */
export async function createSubscription(params: {
planId: string
userId: string
plan: string
email?: string | null
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
body: JSON.stringify({
plan_id: params.planId,
custom_id: encodeCustomId(params.userId, params.plan),
subscriber: params.email ? { email_address: params.email } : undefined,
application_context: {
brand_name: BRAND,
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createSubscription failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Create a one-time order (used for the Lifetime plan). */
export async function createOrder(params: {
amount: number
userId: string
plan: string
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v2/checkout/orders", {
method: "POST",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: params.amount.toFixed(2) },
custom_id: encodeCustomId(params.userId, params.plan),
description: `${BRAND} — Lifetime`,
},
],
application_context: {
brand_name: BRAND,
user_action: "PAY_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createOrder failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Capture an approved order. Returns the captured order (status COMPLETED). */
export async function captureOrder(orderId: string): Promise<{
status: string
custom_id?: string
} | null> {
const res = await paypalFetch(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
body: "{}",
})
if (!res.ok) return null
const json = (await res.json()) as {
status: string
purchase_units?: Array<{ custom_id?: string; payments?: { captures?: Array<{ custom_id?: string }> } }>
}
const unit = json.purchase_units?.[0]
const custom_id = unit?.custom_id ?? unit?.payments?.captures?.[0]?.custom_id
return { status: json.status, custom_id }
}
export type PaypalSubscription = {
id: string
status: string
custom_id?: string
billing_info?: { next_billing_time?: string }
}
export async function getSubscription(id: string): Promise<PaypalSubscription | null> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}`, { method: "GET" })
if (!res.ok) return null
return (await res.json()) as PaypalSubscription
}
export async function cancelSubscription(id: string, reason = "Cancelled by subscriber"): Promise<boolean> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
body: JSON.stringify({ reason }),
})
// 204 = cancelled; 422 = already inactive (treat as success so the UI settles).
return res.ok || res.status === 204 || res.status === 422
}
-57
View File
@@ -1,57 +0,0 @@
// 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 ?? {}),
},
})
}
-53
View File
@@ -1,53 +0,0 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import type { Plan } from "@/types"
// Applies PayPal subscription/order outcomes to a profile. Shared by the return
// handler (synchronous, on approval redirect) and the webhook (async, for
// renewals/cancellations). Both are idempotent.
const RECURRING: ReadonlyArray<Plan> = ["pro", "landlord"]
export async function fulfillSubscription(
userId: string,
plan: string,
subscriptionId: string,
nextBillingTime?: string | null,
status = "active",
): Promise<void> {
if (!RECURRING.includes(plan as Plan)) return
await db
.update(profiles)
.set({
plan: plan as Plan,
subscription_status: status,
paypal_subscription_id: subscriptionId,
billing_provider: "paypal",
plan_expires_at: nextBillingTime ?? null,
})
.where(eq(profiles.id, userId))
}
export async function fulfillLifetime(userId: string): Promise<void> {
await db
.update(profiles)
.set({ plan: "lifetime", subscription_status: "active", billing_provider: "paypal" })
.where(eq(profiles.id, userId))
}
/** Downgrade/mark a profile by its PayPal subscription id (cancel/expire/suspend). */
export async function markPaypalSubscriptionInactive(
subscriptionId: string,
status: string,
downgrade: boolean,
): Promise<void> {
await db
.update(profiles)
.set(
downgrade
? { subscription_status: status, plan: "starter", paypal_subscription_id: null, plan_expires_at: null }
: { subscription_status: status },
)
.where(eq(profiles.paypal_subscription_id, subscriptionId))
}
-28
View File
@@ -1,28 +0,0 @@
import type { Plan } from "@/types"
// PayPal billing-plan IDs, one per (plan, interval). Create them once with
// `node scripts/paypal-setup-plans.mjs` and paste the printed IDs into the
// environment. A plan/interval with no configured ID simply isn't offered.
const PAYPAL_PLAN_IDS: Record<string, string | undefined> = {
"pro:month": process.env.PAYPAL_PRO_MONTHLY_PLAN_ID,
"pro:year": process.env.PAYPAL_PRO_YEARLY_PLAN_ID,
"landlord:month": process.env.PAYPAL_LANDLORD_MONTHLY_PLAN_ID,
"landlord:year": process.env.PAYPAL_LANDLORD_YEARLY_PLAN_ID,
}
/** Recurring plans PayPal can bill (lifetime is a one-time order, not a plan). */
export const PAYPAL_RECURRING_PLANS = ["pro", "landlord"] as const
export function getPaypalPlanId(plan: Plan, interval: "month" | "year"): string | undefined {
return PAYPAL_PLAN_IDS[`${plan}:${interval}`] || undefined
}
/** True when at least one PayPal-billable plan is configured. */
export function anyPaypalPlanConfigured(): boolean {
return Object.values(PAYPAL_PLAN_IDS).some(Boolean)
}
/** Annual PayPal billing is offered only when both yearly plan IDs exist. */
export function paypalAnnualEnabled(): boolean {
return Boolean(PAYPAL_PLAN_IDS["pro:year"] && PAYPAL_PLAN_IDS["landlord:year"])
}
-36
View File
@@ -1,36 +0,0 @@
import { paypalFetch } from "./client"
// Verify an inbound PayPal webhook using PayPal's verify-webhook-signature API.
// Requires PAYPAL_WEBHOOK_ID (from the webhook you create in the PayPal app).
// Returns false (reject) when the id is missing or verification doesn't succeed.
export async function verifyPaypalWebhook(headers: Headers, rawBody: string): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID
if (!webhookId) return false
let event: unknown
try {
event = JSON.parse(rawBody)
} catch {
return false
}
try {
const res = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: headers.get("paypal-auth-algo"),
cert_url: headers.get("paypal-cert-url"),
transmission_id: headers.get("paypal-transmission-id"),
transmission_sig: headers.get("paypal-transmission-sig"),
transmission_time: headers.get("paypal-transmission-time"),
webhook_id: webhookId,
webhook_event: event,
}),
})
if (!res.ok) return false
const json = (await res.json()) as { verification_status?: string }
return json.verification_status === "SUCCESS"
} catch {
return false
}
}
+81 -1
View File
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
/**
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
* client-supplied key/path whose first segment differs belongs to another tenant
* (or is malformed) and must be rejected.
*/
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
if (!key || !ownerId) return false
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
return first === sanitizeSegment(ownerId)
}
/**
* Lightweight magic-byte check: reject a file whose real content doesn't match
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
* without a reliable file signature (csv/txt) are allowed through. `head` should
* be the first ~16 bytes of the file.
*/
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
const at = (offset: number, sig: number[]) =>
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
switch (ext) {
case "pdf":
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
case "png":
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
case "jpg":
case "jpeg":
return at(0, [0xff, 0xd8, 0xff])
case "gif":
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
case "webp":
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
case "docx":
case "xlsx":
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
case "doc":
case "xls":
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
case "csv":
case "txt":
return true // no reliable signature
default:
return true
}
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
@@ -194,6 +241,36 @@ export async function saveFile(
return { key, size: file.size, type }
}
/**
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
* so the key is always in the owner's namespace) and return the storage key.
* Used for server-side artifacts like signed e-sign PDFs.
*/
export async function saveBuffer(
buffer: Buffer,
opts: { userId: string; scope: string; ext: string }
): Promise<{ key: string }> {
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: contentTypeForKey(key),
ACL: "private",
})
)
} else {
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -227,7 +304,10 @@ export async function presignGetUrl(
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
export async function deleteFile(key: string, ownerId: string): Promise<void> {
// Defense in depth: never delete an object outside the caller's own namespace,
// even if a stored storage_path was tampered with to point at another tenant.
if (!keyBelongsToOwner(key, ownerId)) return
try {
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))