diff --git a/.do/app.yaml b/.do/app.yaml index 907a92b..ac7af51 100644 --- a/.do/app.yaml +++ b/.do/app.yaml @@ -1,30 +1,28 @@ # ───────────────────────────────────────────────────────────────────────────── # DigitalOcean App Platform spec — Property Management Network # -# Deploy: doctl apps create --spec .do/app.yaml +# Deploy: doctl apps create --spec .do/app.yaml (or the DO MCP apps-create) # Update: doctl apps update --spec .do/app.yaml # -# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git -# lives on self-hosted Gitea, which App Platform cannot pull, so we build the -# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full -# build/push/deploy walkthrough. +# SOURCE: App Platform builds the Dockerfile directly from GitHub +# (github.com/silkoserfo/property-management-network). Pushes to `main` +# auto-redeploy (deploy_on_push). No DOCR image build/push needed. # # SECRETS: values marked `type: SECRET` are placeholders — set the real values in -# the App Platform dashboard (App → Settings → Environment Variables) or via -# `doctl`. Never commit real secrets to this file. +# the App Platform dashboard (App → Settings → Environment Variables) or via the +# create spec. Never commit real secrets to this file. # ───────────────────────────────────────────────────────────────────────────── name: property-management-network region: nyc services: - name: web - # Pre-built image pushed to DOCR (repository must exist in your registry). - image: - registry_type: DOCR - repository: property-management-network - tag: latest - deploy_on_push: - enabled: true + # Built by App Platform from GitHub using the repo Dockerfile. + github: + repo: silkoserfo/property-management-network + branch: main + deploy_on_push: true + dockerfile_path: Dockerfile instance_count: 1 instance_size_slug: apps-s-1vcpu-1gb http_port: 3000 @@ -37,32 +35,35 @@ services: failure_threshold: 3 envs: # ── App URLs ────────────────────────────────────────────────────────── - # ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client - # bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile / - # DIGITALOCEAN.md), so build the image with the same URL you serve on. + # NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they + # must be RUN_AND_BUILD_TIME with the literal domain we serve on. - key: NEXT_PUBLIC_APP_URL - scope: RUN_TIME - value: ${APP_URL} + scope: RUN_AND_BUILD_TIME + value: https://propertymanagement.network - key: BETTER_AUTH_URL scope: RUN_TIME - value: ${APP_URL} + value: https://propertymanagement.network - key: NEXT_PUBLIC_APP_NAME - scope: RUN_TIME + scope: RUN_AND_BUILD_TIME value: Property Management Network - # ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ── + # ── Admin & auth policy ─────────────────────────────────────────────── + - key: ADMIN_EMAILS + scope: RUN_TIME + value: leon@phluit.com + - key: REQUIRE_EMAIL_VERIFICATION + scope: RUN_TIME + value: "true" + + # ── Database (managed Postgres — PRIVATE host, direct port 25060) ── - key: DATABASE_URL scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD - # Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses - # a CA that isn't in the system trust store, so paste the cluster's CA cert - # into DATABASE_CA: DO control panel → Database → Connection Details → - # "Download CA certificate", then paste its PEM contents as the DATABASE_CA - # secret in the App Platform dashboard. Without a valid CA the app will - # refuse to connect (fail loud) rather than run unverified. - # Emergency fallback ONLY (not for production): DATABASE_SSL=no-verify is - # encrypted but does NOT verify the server certificate. + # Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so + # paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA. + # With `require` + a valid CA the app connects verified; without a valid CA + # it fails loud rather than run unverified. - key: DATABASE_SSL scope: RUN_TIME value: require @@ -70,8 +71,7 @@ services: scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD - # Schema is migrated out-of-band (as doadmin), NOT on boot — the app user - # intentionally lacks DDL rights. Keep this false; run migrations manually. + # Schema is migrated out-of-band (as doadmin), NOT on boot. - key: RUN_MIGRATIONS_ON_START scope: RUN_TIME value: "false" @@ -81,14 +81,15 @@ services: scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD + # Google OAuth (optional — leave blank to disable the Google button). - key: GOOGLE_CLIENT_ID scope: RUN_TIME type: SECRET - value: REPLACE_IN_DASHBOARD + value: "" - key: GOOGLE_CLIENT_SECRET scope: RUN_TIME type: SECRET - value: REPLACE_IN_DASHBOARD + value: "" # ── Stripe ──────────────────────────────────────────────────────────── - key: STRIPE_SECRET_KEY @@ -99,22 +100,21 @@ services: scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD - # No STRIPE_*_PRICE_ID vars — prices are resolved by lookup key and - # auto-created on first checkout (lib/stripe/prices.ts). Going live only - # needs the two live secrets above + the live publishable key below. - # ── OpenAI ──────────────────────────────────────────────────────────── - - key: OPENAI_API_KEY + # ── AI provider (Anthropic default; OpenAI optional) ────────────────── + - key: ANTHROPIC_API_KEY scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD + - key: ANTHROPIC_MODEL + scope: RUN_TIME + value: claude-haiku-4-5 + - key: OPENAI_API_KEY + scope: RUN_TIME + type: SECRET + value: "" # ── Email (SMTP — SMTP2GO) ──────────────────────────────────────────── - # The app sends mail via SMTP only (nodemailer). Email is silently skipped - # unless SMTP_HOST + SMTP_USER + SMTP_PASS are all set — password resets, - # email verification, rent/overdue/lease reminders, team invites, and - # payment links all depend on this. EMAIL_FROM is a bare address; the app - # wraps it as "Property Management Network <…>". - key: SMTP_HOST scope: RUN_TIME value: mail.smtp2go.com @@ -133,10 +133,9 @@ services: scope: RUN_TIME value: postmaster@propertymanagement.network - # ── Cloudflare Turnstile (site key is public; baked into the client bundle - # at image build time — keep it in sync when you build) ── + # ── Cloudflare Turnstile (site key public; baked at build time) ── - key: NEXT_PUBLIC_TURNSTILE_SITE_KEY - scope: RUN_TIME + scope: RUN_AND_BUILD_TIME value: 0x4AAAAAADuDQverznfv1a60 - key: TURNSTILE_SECRET_KEY scope: RUN_TIME @@ -165,8 +164,53 @@ services: scope: RUN_TIME value: https://nyc3.cdn.digitaloceanspaces.com + # ── Accounting sync (optional — per-landlord QuickBooks / Xero OAuth) ── + - key: QBO_CLIENT_ID + scope: RUN_TIME + type: SECRET + value: "" + - key: QBO_CLIENT_SECRET + scope: RUN_TIME + type: SECRET + value: "" + - key: QBO_ENVIRONMENT + scope: RUN_TIME + value: production + - key: XERO_CLIENT_ID + scope: RUN_TIME + type: SECRET + value: "" + - key: XERO_CLIENT_SECRET + scope: RUN_TIME + type: SECRET + value: "" + - key: XERO_SALES_ACCOUNT_CODE + scope: RUN_TIME + value: "200" + - key: XERO_EXPENSE_ACCOUNT_CODE + scope: RUN_TIME + value: "400" + + # ── Error monitoring (Sentry — DSN is public; browser DSN baked at build) ── + - key: SENTRY_DSN + scope: RUN_TIME + value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648 + - key: NEXT_PUBLIC_SENTRY_DSN + scope: RUN_AND_BUILD_TIME + value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648 + - key: SENTRY_ENVIRONMENT + scope: RUN_TIME + value: production + # ── Cron (Bearer token the DO Function sends to /api/cron/*) ── - key: CRON_SECRET scope: RUN_TIME type: SECRET value: REPLACE_IN_DASHBOARD + +# ── Custom domains (DNS hosted on Cloudflare — set CNAMEs there, DNS-only) ── +domains: + - domain: propertymanagement.network + type: PRIMARY + - domain: www.propertymanagement.network + type: ALIAS diff --git a/.env.example b/.env.example index 70863e0..d15e863 100644 --- a/.env.example +++ b/.env.example @@ -50,24 +50,16 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key # auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a # pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs -# === PAYPAL (optional — alternative subscription checkout) === -# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to -# hide the PayPal buttons. Create a REST app at https://developer.paypal.com; -# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to -# /api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the -# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below. -PAYPAL_CLIENT_ID= -PAYPAL_SECRET= -PAYPAL_ENVIRONMENT=sandbox -PAYPAL_WEBHOOK_ID= -PAYPAL_PRO_MONTHLY_PLAN_ID= -PAYPAL_PRO_YEARLY_PLAN_ID= -PAYPAL_LANDLORD_MONTHLY_PLAN_ID= -PAYPAL_LANDLORD_YEARLY_PLAN_ID= - -# === AI (OpenAI) === -# Get from: https://platform.openai.com/api-keys +# === AI PROVIDER (OpenAI and/or Anthropic) === +# The active provider is chosen by an admin in Settings → System. Configure the +# key(s) for whichever provider(s) you want available; the app falls back to the +# configured one if the selected provider's key is missing. +# OpenAI — https://platform.openai.com/api-keys OPENAI_API_KEY=sk-your-api-key +# OPENAI_MODEL=gpt-4o-mini +# Anthropic (Claude) — https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY= +# ANTHROPIC_MODEL=claude-haiku-4-5 # cheapest; use claude-sonnet-5 / claude-opus-4-8 for more capability # === EMAIL (SMTP — e.g. SMTP2GO) === # Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS. @@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox XERO_CLIENT_ID= XERO_CLIENT_SECRET= -# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === -# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing. -DROPBOX_SIGN_API_KEY= -DROPBOX_SIGN_TEST_MODE=true -# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign -# Connect → /api/esign/docusign/webhook ; Dropbox Sign callback → -# /api/esign/dropbox_sign/webhook -DOCUSIGN_ACCESS_TOKEN= -DOCUSIGN_ACCOUNT_ID= -DOCUSIGN_BASE_URI=https://demo.docusign.net +# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) === +# DocuSign: register ONE DocuSign app (integration key) here; each landlord then +# connects their own DocuSign account via OAuth from Settings → Integrations. +# Redirect URI to register in the DocuSign app: /api/esign/docusign/callback +# DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod). +DOCUSIGN_CLIENT_ID= +DOCUSIGN_CLIENT_SECRET= +DOCUSIGN_OAUTH_BASE=account-d.docusign.com +# Dropbox Sign: no server credentials — landlords paste their own API key in the +# app and set their account callback URL to /api/esign/dropbox_sign/webhook. +# DROPBOX_SIGN_TEST_MODE applies test mode to all outbound requests (optional). +DROPBOX_SIGN_TEST_MODE=false # === APP === NEXT_PUBLIC_APP_URL=http://localhost:3000 @@ -113,6 +107,14 @@ CRON_SECRET=your-random-secret-string NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 +# === ERROR MONITORING (Sentry — optional) === +# Paste the DSN from your Sentry project (Settings → Client Keys / DSN). It's +# public (ships in the browser bundle). Sentry stays inert until this is set. +NEXT_PUBLIC_SENTRY_DSN= +# Build-time only: uploads source maps for readable stack traces. Create at +# Sentry → Settings → Auth Tokens. Keep secret; leave blank to skip upload. +SENTRY_AUTH_TOKEN= + # === MAPS / GEOCODING (OpenStreetMap — free, no key) === # Property addresses are geocoded on save via OpenStreetMap Nominatim and shown # on a Leaflet map (both keyless & free). Nominatim's policy requires an diff --git a/.env.production.example b/.env.production.example index 4f6b019..f33a8d4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -68,21 +68,6 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx # first checkout, so going live is ONLY the three values above (live keys + live # webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs -# === PAYPAL (optional — alternative subscription checkout) === -# Landlords can pay for their plan with PayPal alongside Stripe. Leave blank to -# hide the PayPal buttons. Create a REST app at https://developer.paypal.com and -# set PAYPAL_ENVIRONMENT=live for production. Create a webhook there pointing to -# /api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the -# plan IDs with `node scripts/paypal-setup-plans.mjs`. -PAYPAL_CLIENT_ID= -PAYPAL_SECRET= -PAYPAL_ENVIRONMENT=live -PAYPAL_WEBHOOK_ID= -PAYPAL_PRO_MONTHLY_PLAN_ID= -PAYPAL_PRO_YEARLY_PLAN_ID= -PAYPAL_LANDLORD_MONTHLY_PLAN_ID= -PAYPAL_LANDLORD_YEARLY_PLAN_ID= - # === AI (OpenAI) === OPENAI_API_KEY=sk-xxx @@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production XERO_CLIENT_ID= XERO_CLIENT_SECRET= -# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === -# Leave blank to hide/disable a provider on the lease page. Configure the -# provider callbacks to point at this app: -# Dropbox Sign callback → /api/esign/dropbox_sign/webhook -# DocuSign Connect → /api/esign/docusign/webhook -# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs. -DROPBOX_SIGN_API_KEY= +# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) === +# DocuSign: register ONE DocuSign app; landlords connect their own account via +# OAuth from Settings → Integrations. Register this redirect URI in the app: +# /api/esign/docusign/callback +# Use account.docusign.com in production (account-d.docusign.com for demo). +DOCUSIGN_CLIENT_ID= +DOCUSIGN_CLIENT_SECRET= +DOCUSIGN_OAUTH_BASE=account.docusign.com +# Dropbox Sign: no server credentials — landlords paste their own API key and set +# their account callback URL to /api/esign/dropbox_sign/webhook. DROPBOX_SIGN_TEST_MODE=false -DOCUSIGN_ACCESS_TOKEN= -DOCUSIGN_ACCOUNT_ID= -DOCUSIGN_BASE_URI=https://www.docusign.net # === APP (NEXT_PUBLIC_* — also set as Build Variables) === NEXT_PUBLIC_APP_URL=https://propertymanagement.network @@ -128,6 +113,14 @@ GOOGLE_SITE_VERIFICATION= NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 +# === ERROR MONITORING (Sentry) === +# DSN from your Sentry project (public — inlined in the browser bundle, so set +# it as a Build Variable too). Error monitoring is disabled until this is set. +NEXT_PUBLIC_SENTRY_DSN= +# Build-time secret: uploads source maps so prod stack traces are un-minified. +# Sentry → Settings → Auth Tokens. Set as a Build Variable; leave blank to skip. +SENTRY_AUTH_TOKEN= + # === MAPS / GEOCODING (OpenStreetMap — free, no key) === # Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM # tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent — diff --git a/.gitignore b/.gitignore index d466acc..41be56f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# MCP config — contains a DigitalOcean API token; keep local, never commit. +.mcp.json + # env files (can opt-in for committing if needed) .env* !.env.example diff --git a/DIGITALOCEAN.md b/DIGITALOCEAN.md index 6bffabd..57f509a 100644 --- a/DIGITALOCEAN.md +++ b/DIGITALOCEAN.md @@ -94,11 +94,19 @@ docker build \ --build-arg NEXT_PUBLIC_APP_URL=https:// \ --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \ --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \ + --build-arg NEXT_PUBLIC_SENTRY_DSN= \ + --build-arg SENTRY_AUTH_TOKEN= \ -t $REG/property-management-network:latest . docker push $REG/property-management-network:latest ``` +> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg` +> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The +> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert +> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is +> optional and only uploads source maps for readable stack traces. + > First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the > `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`. @@ -114,7 +122,10 @@ Then set every `type: SECRET` value (App → Settings → Environment Variables) `.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`, -`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP +`SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden): +`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET` +(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring — +plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP (SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without `SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime. diff --git a/Dockerfile b/Dockerfile index 3020201..35d7288 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,17 @@ ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_APP_NAME="Property Management Network" ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY +# Client-side Sentry DSN — inlined into the browser bundle. Without it, only +# server/edge errors are reported (SENTRY_DSN at runtime); the browser stays inert. +ARG NEXT_PUBLIC_SENTRY_DSN +# Optional: a Sentry auth token uploads source maps for readable stack traces. +# The build still succeeds without it. +ARG SENTRY_AUTH_TOKEN ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY +ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN +ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build diff --git a/README.md b/README.md index b6d531d..67faf89 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o **Core operations** - 🏢 **Properties & units** — manage your whole portfolio with live occupancy tracking and a map view (addresses are auto-geocoded). - 👥 **Tenants** — profiles, lease history, payment records, and a private **tenant portal** (token-based, no login required). -- 💵 **Rent tracking** — log payments, send **Stripe or PayPal** payment links, and auto-mark balances overdue with automatic late fees. +- 💵 **Rent tracking** — log payments, send **Stripe** payment links, and auto-mark balances overdue with automatic late fees. - 🔧 **Maintenance** — full status workflow (Open → In Progress → Resolved), with tenant-submitted requests from the portal. - 📄 **Leases** — expiry countdowns, automated 60/30/7-day email alerts, and **e-signature** (DocuSign / Dropbox Sign). - 🧾 **Expenses** — categorized logging with recurring-expense support. @@ -55,7 +55,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o |---|---| | 🌐 **Public REST API** | Versioned `/api/v1` endpoints (properties, tenants, payments, maintenance, webhooks) authenticated with Bearer **API keys**. See `/api-docs`. | | 🪝 **Outbound webhooks / Zapier** | Subscribe to events (`tenant.created`, `payment.paid`, `maintenance.updated`, …). Deliveries are **HMAC-signed**, retried with backoff, and Zapier-compatible via the REST-hook subscribe/unsubscribe pattern. | -| 💳 **Payments** | Stripe (subscriptions + rent payment links) and PayPal. | +| 💳 **Payments** | Stripe (subscriptions + rent payment links). | | 📚 **Accounting sync** | One-way push of income & expenses to **QuickBooks Online** or **Xero** (OAuth). | | ✍️ **E-signature** | Send leases for signature via **DocuSign** or **Dropbox Sign**. | | 🔑 **Auth** | Email/password and Google OAuth (Better Auth). | @@ -73,7 +73,7 @@ Every integration is env-gated: unconfigured providers show a clean “not confi | 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) | | ♾️ **Lifetime** | $199 once | Everything in Landlord, forever | -Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up. +Billing runs through **Stripe**. Products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up. --- @@ -86,7 +86,7 @@ Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolv | Database | PostgreSQL via **Drizzle ORM** | | Auth | Better Auth (email/password + Google OAuth) | | Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) | -| Payments | Stripe + PayPal | +| Payments | Stripe | | AI | OpenAI (`gpt-4o-mini`) | | Email | SMTP (SMTP2GO) | | Maps | Leaflet + OpenStreetMap / Nominatim geocoding | @@ -113,7 +113,7 @@ Copy the template and fill in your own values: cp .env.example .env.local ``` -`.env.local` holds your database URL, auth secret, and credentials for Stripe/PayPal, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets. +`.env.local` holds your database URL, auth secret, and credentials for Stripe, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets. ### 3. 🗄️ Run migrations @@ -129,7 +129,7 @@ npm run db:push # push schema directly (quick local prototyping) - **Stripe** — set the API keys, then add a webhook at `https://yourdomain.com/api/stripe/webhook` for `checkout.session.completed`, the `customer.subscription.*` events, `invoice.payment_failed`, and `payment_intent.succeeded`. - **Email** — verify a sending domain with your SMTP provider (e.g. SMTP2GO) and set the `SMTP_*` + `EMAIL_FROM` vars. -- **Google / PayPal / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present. +- **Google / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present. ### 5. ▶️ Run locally @@ -158,7 +158,7 @@ app/ ├── api/ │ ├── v1/ # 🌐 Public REST API (Bearer API keys) │ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs -│ ├── stripe/ paypal/ # 💳 Billing + payment links + provider webhooks +│ ├── stripe/ # 💳 Billing + payment links + provider webhooks │ ├── integrations/ # 📚 QuickBooks / Xero OAuth │ ├── esign/ # ✍️ DocuSign / Dropbox Sign │ └── … # Properties, tenants, rent, maintenance, documents, AI @@ -169,7 +169,7 @@ lib/ ├── auth.ts account.ts # Better Auth + team/account scoping ├── storage.ts # Object storage (Spaces) with local-disk dev fallback ├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery -├── stripe/ paypal/ # Billing clients & plans +├── stripe/ # Billing clients & plans ├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign ├── ai/ # OpenAI client + prompts ├── email/ # SMTP (SMTP2GO) client + HTML templates diff --git a/app/(admin)/admin/system/page.tsx b/app/(admin)/admin/system/page.tsx index d107050..b185eea 100644 --- a/app/(admin)/admin/system/page.tsx +++ b/app/(admin)/admin/system/page.tsx @@ -1,6 +1,8 @@ import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries" import { getMaintenanceMode } from "@/lib/settings" +import { aiProviderStatus } from "@/lib/ai/provider" import { MaintenanceToggle } from "@/components/admin/maintenance-toggle" +import { AiProviderToggle } from "@/components/admin/ai-provider-toggle" import { formatDate } from "@/lib/utils" import { Settings, Database, Table2 } from "lucide-react" @@ -13,10 +15,11 @@ function humanize(name: string): string { } export default async function AdminSystemPage() { - const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([ + const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([ getSystemCounts(), Promise.resolve(getEnvHealth()), getMaintenanceMode(), + aiProviderStatus(), ]) return ( @@ -37,6 +40,18 @@ export default async function AdminSystemPage() { /> + {/* AI provider selection */} +
+ +
+
{/* ── Environment configuration ───────────────────────────────── */}
diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index 72d2173..95a6749 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1,8 +1,14 @@ +import type { Metadata } from "next" import Link from "next/link" import { Logo } from "@/components/shared/logo" import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { resetPassword } from "@/app/actions/auth" +export const metadata: Metadata = { + title: "Reset password", + robots: { index: false, follow: true }, +} + export default async function ForgotPasswordPage({ searchParams, }: { diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 4ad00ec..4b93b4d 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,8 +1,14 @@ +import type { Metadata } from "next" import Link from "next/link" import { Logo } from "@/components/shared/logo" import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { signIn, signInWithGoogle } from "@/app/actions/auth" +export const metadata: Metadata = { + title: "Sign in", + robots: { index: false, follow: true }, +} + export default async function LoginPage({ searchParams, }: { diff --git a/app/(dashboard)/leases/[leaseId]/page.tsx b/app/(dashboard)/leases/[leaseId]/page.tsx index 58c2790..8f97c75 100644 --- a/app/(dashboard)/leases/[leaseId]/page.tsx +++ b/app/(dashboard)/leases/[leaseId]/page.tsx @@ -4,13 +4,16 @@ import { db } from "@/lib/db" import { leases as leasesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" -import { listAdapters, listRequestsForLease } from "@/lib/esign" +import { listEsignConnections, listRequestsForLease } from "@/lib/esign" import Link from "next/link" -import { FileText, ExternalLink } from "lucide-react" +import { FileText } from "lucide-react" import { formatCurrency, formatDate, daysUntil } from "@/lib/utils" import { cn } from "@/lib/utils" import { LeaseActions } from "@/components/forms/lease-actions" import { EsignLease } from "@/components/forms/esign-lease" +import { LeaseDocument } from "@/components/forms/lease-document" + +const ESIGN_LABEL: Record = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" } export const metadata = { title: "Lease" } @@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le if (!lease) notFound() const esignRequests = await listRequestsForLease(ownerId, leaseId) - const esignProviders = listAdapters() - const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email + const esignConnections = await listEsignConnections(ownerId) + const connectedProviders = esignConnections + .filter((c) => c.status !== "revoked") + .map((c) => ({ id: c.provider, label: ESIGN_LABEL[c.provider] ?? c.provider })) + const canSendEsign = + ctx.canWrite && !!lease.document_url && !!lease.tenant?.email && connectedProviders.length > 0 const esignDisabledReason = !ctx.canWrite ? "You have read-only access." : !lease.document_url @@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
- {lease.document_url && ( - -
- - Lease document -
- -
- )} + + searchParams: Promise<{ success?: string; canceled?: string }> }) { const user = await getSessionUser() if (!user) redirect("/login") @@ -72,16 +70,12 @@ export default async function BillingPage({ plan_expires_at: true, stripe_customer_id: true, stripe_subscription_id: true, - paypal_subscription_id: true, - billing_provider: true, }, }) const params = await searchParams const currentPlan = (profile?.plan ?? "starter") as Plan const hasStripeAccount = !!profile?.stripe_customer_id - const isPaypal = profile?.billing_provider === "paypal" || !!profile?.paypal_subscription_id - const paypalEnabled = paypalConfigured() const limits = PLAN_LIMITS[currentPlan] const canBillAnnually = annualEnabled() @@ -113,12 +107,6 @@ export default async function BillingPage({ Checkout canceled — no charge was made. )} - {params.error === "paypal" && ( -
- We couldn't complete your PayPal payment. No charge was made — please try again. -
- )} - {/* Current plan */}
@@ -129,12 +117,9 @@ export default async function BillingPage({

Status: {profile.subscription_status}

)}
- {currentPlan !== "starter" && currentPlan !== "lifetime" && - (isPaypal ? ( - - ) : hasStripeAccount ? ( - - ) : null)} + {hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && ( + + )}
@@ -197,7 +182,6 @@ export default async function BillingPage({ label={plan.cta} highlight={plan.highlight} annualAvailable={canBillAnnually && plan.key !== "lifetime"} - paypalEnabled={paypalEnabled} /> )} diff --git a/app/(dashboard)/settings/integrations/page.tsx b/app/(dashboard)/settings/integrations/page.tsx index 8cbc225..ec8591b 100644 --- a/app/(dashboard)/settings/integrations/page.tsx +++ b/app/(dashboard)/settings/integrations/page.tsx @@ -2,7 +2,9 @@ import { redirect } from "next/navigation" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" import { listProviders, listConnections } from "@/lib/accounting" +import { listEsignAdapters, listEsignConnections } from "@/lib/esign" import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations" +import { EsignIntegrations } from "@/components/dashboard/esign-integrations" export const metadata = { title: "Integrations" } export const dynamic = "force-dynamic" @@ -20,20 +22,45 @@ export default async function IntegrationsPage({ const providers = listProviders() const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : [] + const esignAdapters = listEsignAdapters() + const esignConnections = ctx.isOwner ? await listEsignConnections(ctx.ownerId) : [] + const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "") + // DocuSign vs Dropbox Sign flash messages are keyed by provider id, so a single + // connected/error param drives whichever card the user just acted on. + const esignFlash = { connected: sp.connected, error: sp.error } + return ( -
-
-

Integrations

-

- Connect your accounting software to automatically push rent income and expenses into your books. -

+
+
+
+

E-signature

+

+ Connect your own DocuSign or Dropbox Sign account to send leases for signature. +

+
+ +
+ +
+
+

Accounting

+

+ Connect your accounting software to automatically push rent income and expenses into your books. +

+
+
-
) } diff --git a/app/actions/admin.ts b/app/actions/admin.ts index d76e732..7e97850 100644 --- a/app/actions/admin.ts +++ b/app/actions/admin.ts @@ -8,6 +8,7 @@ import { z } from "zod" import { getAdminSession } from "@/lib/session" import { logAdminAction } from "@/lib/admin/audit" import { setMaintenanceMode } from "@/lib/settings" +import { setAiProvider, type AiProvider } from "@/lib/ai/provider" import { auth } from "@/lib/auth" import { db } from "@/lib/db" import { profiles, user as userTable } from "@/lib/db/schema" @@ -158,3 +159,22 @@ export async function setSiteMaintenance(enabled: boolean, message?: string) { revalidatePath("/", "layout") return { ok: true } } + +// ── AI provider ─────────────────────────────────────────────────────────────── +// Chooses which LLM provider powers all AI features (OpenAI or Anthropic/Claude), +// persisted in app_settings. Applies immediately to every AI route. +export async function setAiProviderAction(provider: string) { + const a = await guard() + if (provider !== "openai" && provider !== "anthropic") throw new Error("Invalid AI provider") + + await setAiProvider(provider as AiProvider) + + await logAdminAction({ + adminId: a.user.id, + action: "ai_provider", + metadata: { provider }, + }) + + revalidatePath("/admin/system") + return { ok: true } +} diff --git a/app/actions/auth.ts b/app/actions/auth.ts index a6700cb..0e64a44 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -36,7 +36,12 @@ export async function signUp(formData: FormData) { headers: h, }) } catch (e) { - const msg = e instanceof APIError ? e.message : "Sign up failed" + const raw = e instanceof APIError ? e.message : "Sign up failed" + // Don't reveal that an email is already registered (user enumeration) — the + // "already exists" path must not be distinguishable from other failures. + const msg = /exist|registered|already|taken/i.test(raw) + ? "We couldn't complete your sign-up. Please try a different email or sign in." + : raw redirect(`/signup?error=${encodeURIComponent(msg)}`) } diff --git a/app/actions/esign.ts b/app/actions/esign.ts index 3fc213d..3e73279 100644 --- a/app/actions/esign.ts +++ b/app/actions/esign.ts @@ -1,9 +1,27 @@ "use server" import { revalidatePath } from "next/cache" +import { and, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { leases } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" -import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign" +import { keyBelongsToOwner } from "@/lib/storage" +import { + sendLeaseForSignature, + getAdapter, + saveEsignConnection, + disconnectEsign, + type ESignProvider, +} from "@/lib/esign" + +async function ownerGuard() { + const user = await getSessionUser() + if (!user) throw new Error("Unauthorized") + const ctx = await getAccountContext(user.id) + if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations") + return ctx +} export async function sendLeaseForSignatureAction(leaseId: string, provider: string) { const user = await getSessionUser() @@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str revalidatePath(`/leases/${leaseId}`) return { ok: true } } + +/** Connect Dropbox Sign by validating and storing the landlord's API key. */ +export async function connectDropboxSign(apiKey: string) { + const ctx = await ownerGuard() + const adapter = getAdapter("dropbox_sign") + if (!adapter) throw new Error("Unknown provider") + const tokens = await adapter.connectApiKey(typeof apiKey === "string" ? apiKey : "") + await saveEsignConnection(ctx.ownerId, "dropbox_sign", tokens) + revalidatePath("/settings/integrations") + return { ok: true, accountName: tokens.accountName } +} + +export async function disconnectEsignAction(provider: string) { + const ctx = await ownerGuard() + if (!getAdapter(provider)) throw new Error("Unknown provider") + await disconnectEsign(ctx.ownerId, provider as ESignProvider) + revalidatePath("/settings/integrations") + return { ok: true } +} + +/** + * Attach an already-uploaded document (via /api/upload) to a lease. Validates + * the file belongs to the caller's namespace to prevent cross-tenant refs. + */ +export async function setLeaseDocument(leaseId: string, fileUrl: string) { + const user = await getSessionUser() + if (!user) throw new Error("Unauthorized") + const ctx = await getAccountContext(user.id) + if (!ctx.canWrite) throw new Error("You don't have permission to do that") + + const prefix = "/api/files/" + if (typeof fileUrl !== "string" || !fileUrl.startsWith(prefix)) throw new Error("Invalid document reference") + if (!keyBelongsToOwner(fileUrl.slice(prefix.length), ctx.ownerId)) throw new Error("Invalid document reference") + + const [row] = await db + .update(leases) + .set({ document_url: fileUrl }) + .where(and(eq(leases.id, leaseId), eq(leases.user_id, ctx.ownerId))) + .returning({ id: leases.id }) + if (!row) throw new Error("Lease not found") + + revalidatePath(`/leases/${leaseId}`) + return { ok: true, url: fileUrl } +} diff --git a/app/api/ai/ask/route.ts b/app/api/ai/ask/route.ts index 47c281f..7142633 100644 --- a/app/api/ai/ask/route.ts +++ b/app/api/ai/ask/route.ts @@ -13,7 +13,8 @@ import { } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getEffectiveOwnerId } from "@/lib/account" -import { openai } from "@/lib/ai/client" +import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client" +import { aiComplete } from "@/lib/ai/provider" import { enforceAiQuota } from "@/lib/ai/usage" import { dataBlock } from "@/lib/ai/prompts" @@ -21,6 +22,9 @@ export async function POST(request: Request) { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Before the quota check so an unconfigured server never burns a call. + if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 }) + const quota = await enforceAiQuota(user.id, "ai_ask") if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) @@ -154,16 +158,22 @@ ${dataBlock("EXPIRING LEASES", JSON.stringify(expiringLeases, null, 2))} Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect. ` - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - max_tokens: 1024, - messages: [ - { role: "system", content: context }, - { role: "user", content: question }, - ], - }) - - const answer = completion.choices[0].message.content ?? "" + let answer: string + try { + answer = await aiComplete({ + messages: [ + { role: "system", content: context }, + { role: "user", content: question }, + ], + maxTokens: 1024, + }) + } catch (err) { + console.error("[ai/ask] AI request failed:", err) + return NextResponse.json( + { error: "The AI service is temporarily unavailable. Please try again in a moment." }, + { status: 502 } + ) + } return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } }) } diff --git a/app/api/ai/maintenance-summary/route.ts b/app/api/ai/maintenance-summary/route.ts index 8d204fc..1391d6b 100644 --- a/app/api/ai/maintenance-summary/route.ts +++ b/app/api/ai/maintenance-summary/route.ts @@ -4,7 +4,8 @@ import { db } from "@/lib/db" import { properties, maintenance_requests } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getEffectiveOwnerId } from "@/lib/account" -import { openai } from "@/lib/ai/client" +import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client" +import { aiComplete } from "@/lib/ai/provider" import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts" import { enforceAiQuota } from "@/lib/ai/usage" @@ -12,6 +13,9 @@ export async function POST(request: Request) { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Before the quota check so an unconfigured server never burns a call. + if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 }) + const quota = await enforceAiQuota(user.id, "ai_maintenance_summary") if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) @@ -39,9 +43,7 @@ export async function POST(request: Request) { columns: { name: true }, }) - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - max_tokens: 1024, + const text = await aiComplete({ messages: [ { role: "system", content: MAINTENANCE_SUMMARY_PROMPT }, { @@ -49,13 +51,13 @@ export async function POST(request: Request) { content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`, }, ], + maxTokens: 1024, + json: true, }) - const text = completion.choices[0].message.content ?? "" - let summary try { - summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) + summary = JSON.parse(text) } catch { return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) } diff --git a/app/api/ai/predictions/route.ts b/app/api/ai/predictions/route.ts index c6f63ba..332aaa5 100644 --- a/app/api/ai/predictions/route.ts +++ b/app/api/ai/predictions/route.ts @@ -13,7 +13,8 @@ import { } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" -import { openai } from "@/lib/ai/client" +import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client" +import { aiComplete } from "@/lib/ai/provider" import { logActivity } from "@/lib/activity" import { enforceAiQuota } from "@/lib/ai/usage" import { dataBlock } from "@/lib/ai/prompts" @@ -38,6 +39,9 @@ export async function POST() { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Before the quota check so an unconfigured server never burns a call. + if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 }) + const quota = await enforceAiQuota(user.id, "ai_predictions") if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) @@ -174,16 +178,15 @@ Generate a JSON object with key "predictions" containing an array of 5-7 predict Only return valid JSON, no other text.` - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - max_tokens: 2000, + const content = await aiComplete({ messages: [{ role: "user", content: prompt }], - response_format: { type: "json_object" }, + maxTokens: 2000, + json: true, }) let predictions: any[] = [] try { - const parsed = JSON.parse(completion.choices[0].message.content ?? "{}") + const parsed = JSON.parse(content || "{}") predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? []) } catch { return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) diff --git a/app/api/ai/recommendations/route.ts b/app/api/ai/recommendations/route.ts index 8fa6fab..ddb8e50 100644 --- a/app/api/ai/recommendations/route.ts +++ b/app/api/ai/recommendations/route.ts @@ -13,7 +13,8 @@ import { } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" -import { openai } from "@/lib/ai/client" +import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client" +import { aiComplete } from "@/lib/ai/provider" import { logActivity } from "@/lib/activity" import { enforceAiQuota } from "@/lib/ai/usage" import { dataBlock } from "@/lib/ai/prompts" @@ -37,6 +38,9 @@ export async function POST() { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Before the quota check so an unconfigured server never burns a call. + if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 }) + const quota = await enforceAiQuota(user.id, "ai_recommendations") if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) @@ -167,13 +171,12 @@ Only return valid JSON, no other text.` let recommendations: any[] = [] try { - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - max_tokens: 1500, + const content = await aiComplete({ messages: [{ role: "user", content: prompt }], - response_format: { type: "json_object" }, + maxTokens: 1500, + json: true, }) - const parsed = JSON.parse(completion.choices[0].message.content ?? "{}") + const parsed = JSON.parse(content || "{}") recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? []) } catch (err: any) { return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 }) diff --git a/app/api/ai/rent-receipt/route.ts b/app/api/ai/rent-receipt/route.ts index 77df46b..4054a0d 100644 --- a/app/api/ai/rent-receipt/route.ts +++ b/app/api/ai/rent-receipt/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" import { z } from "zod" import { getSessionUser } from "@/lib/session" -import { openai } from "@/lib/ai/client" +import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client" +import { aiComplete } from "@/lib/ai/provider" import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts" import { enforceAiQuota } from "@/lib/ai/usage" @@ -25,6 +26,9 @@ export async function POST(request: Request) { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Before the quota check so an unconfigured server never burns a call. + if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 }) + const quota = await enforceAiQuota(user.id, "ai_rent_receipt") if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) @@ -36,9 +40,7 @@ export async function POST(request: Request) { // Pass only the whitelisted, validated fields to the model. const { payment_id, ...receiptFields } = parsed.data - const completion = await openai.chat.completions.create({ - model: "gpt-4o-mini", - max_tokens: 1024, + const text = await aiComplete({ messages: [ { role: "system", content: RENT_RECEIPT_PROMPT }, { @@ -46,13 +48,13 @@ export async function POST(request: Request) { content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)), }, ], + maxTokens: 1024, + json: true, }) - const text = completion.choices[0].message.content ?? "" - let receipt try { - receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) + receipt = JSON.parse(text) } catch { return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) } diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts index 590cc3c..eb67e90 100644 --- a/app/api/documents/[id]/route.ts +++ b/app/api/documents/[id]/route.ts @@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId))) if (doc.storage_path) { - await deleteFile(doc.storage_path) + await deleteFile(doc.storage_path, ownerId) } return NextResponse.json({ success: true }) diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index d3b12bb..9811730 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { documents, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" -import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage" +import { + saveFile, + isAllowedUploadExt, + StorageNotConfiguredError, + keyBelongsToOwner, + contentMatchesExtension, + extOf, +} from "@/lib/storage" import { checkStorageLimit } from "@/lib/plan-limits" import { ownsProperty, ownsTenant } from "@/lib/db/ownership" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" @@ -57,6 +64,10 @@ export async function POST(request: Request) { if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }) if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 }) if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 }) + const head = Buffer.from(await file.slice(0, 16).arrayBuffer()) + if (!contentMatchesExtension(head, extOf(file.name))) { + return NextResponse.json({ error: "File content does not match its type" }, { status: 400 }) + } const storageError = await checkStorageLimit(ownerId, file.size) if (storageError) return NextResponse.json({ error: storageError }, { status: 403 }) @@ -113,6 +124,20 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Tenant not found" }, { status: 404 }) } + // The file reference is client-supplied. Require it to be an /api/files URL + // inside the caller's OWN namespace, and derive storage_path from it — never + // trust a separate client storage_path (which could point at another tenant's + // object and later be deleted). Also blocks javascript:/external file_url values. + const FILES_PREFIX = "/api/files/" + const fileUrl = typeof body.file_url === "string" ? body.file_url : "" + if (!fileUrl.startsWith(FILES_PREFIX)) { + return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 }) + } + const storagePath = fileUrl.slice(FILES_PREFIX.length) + if (!keyBelongsToOwner(storagePath, ownerId)) { + return NextResponse.json({ error: "Invalid file reference" }, { status: 403 }) + } + // Whitelist insertable columns — never trust client-supplied user_id/id/created_at. const [data] = await db .insert(documents) @@ -122,8 +147,8 @@ export async function POST(request: Request) { tenant_id: tenantId, name: body.name as string, category: (body.category as typeof documents.$inferInsert.category) ?? "other", - file_url: body.file_url as string, - storage_path: body.storage_path as string | undefined, + file_url: fileUrl, + storage_path: storagePath, file_type: body.file_type as string | undefined, file_size: body.file_size as number | undefined, }) diff --git a/app/api/esign/[provider]/callback/route.ts b/app/api/esign/[provider]/callback/route.ts new file mode 100644 index 0000000..229319d --- /dev/null +++ b/app/api/esign/[provider]/callback/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import { cookies } from "next/headers" +import { getSessionUser } from "@/lib/session" +import { getAccountContext } from "@/lib/account" +import { getAdapter, saveEsignConnection, type ESignProvider } from "@/lib/esign" +import { verifyState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state" + +// OAuth callback — exchanges the code for tokens and stores the connection. +export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { + const { provider } = await params + const adapter = getAdapter(provider) + const settings = new URL("/settings/integrations", request.url) + + const cookieStore = await cookies() + const nonceCookie = cookieStore.get(ESIGN_NONCE_COOKIE)?.value + const done = (p: Record) => { + for (const [k, v] of Object.entries(p)) settings.searchParams.set(k, v) + const res = NextResponse.redirect(settings) + res.cookies.set(ESIGN_NONCE_COOKIE, "", { path: "/", maxAge: 0 }) + return res + } + + const url = new URL(request.url) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const oauthError = url.searchParams.get("error") + + if (oauthError || !adapter || adapter.kind !== "oauth") return done({ error: "connect_failed" }) + + const st = state ? verifyState(state) : null + // CSRF: state nonce must match the cookie, and the session must be the same owner. + if (!code || !st || st.provider !== provider || !nonceCookie || nonceCookie !== st.nonce) { + return done({ error: "invalid_state" }) + } + const user = await getSessionUser() + if (!user) return done({ error: "invalid_state" }) + const ctx = await getAccountContext(user.id) + if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" }) + + try { + const tokens = await adapter.exchangeCode(code) + if (!tokens.accountId || !tokens.baseUri) throw new Error("No account returned from provider") + await saveEsignConnection(st.ownerId, provider as ESignProvider, tokens) + return done({ connected: provider }) + } catch { + return done({ error: "connect_failed" }) + } +} diff --git a/app/api/esign/[provider]/connect/route.ts b/app/api/esign/[provider]/connect/route.ts new file mode 100644 index 0000000..0c357c1 --- /dev/null +++ b/app/api/esign/[provider]/connect/route.ts @@ -0,0 +1,49 @@ +import crypto from "crypto" +import { NextResponse } from "next/server" +import { getSessionUser } from "@/lib/session" +import { getAccountContext } from "@/lib/account" +import { getAdapter } from "@/lib/esign" +import { signState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state" + +// Starts the OAuth connect flow for an e-signature provider (owner-only). +// API-key providers (Dropbox Sign) don't use this — they connect via a form. +export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { + const { provider } = await params + const adapter = getAdapter(provider) + const settings = new URL("/settings/integrations", request.url) + + if (!adapter) { + settings.searchParams.set("error", "unknown_provider") + return NextResponse.redirect(settings) + } + + const user = await getSessionUser() + if (!user) return NextResponse.redirect(new URL("/login", request.url)) + + const ctx = await getAccountContext(user.id) + if (!ctx.isOwner) { + settings.searchParams.set("error", "owner_only") + return NextResponse.redirect(settings) + } + if (adapter.kind !== "oauth") { + settings.searchParams.set("error", "use_api_key") + return NextResponse.redirect(settings) + } + if (!adapter.available()) { + settings.searchParams.set("error", "not_configured") + return NextResponse.redirect(settings) + } + + // Bind the round-trip to this browser: nonce in the signed state AND a cookie. + const nonce = crypto.randomUUID() + const state = signState({ ownerId: ctx.ownerId, provider, nonce }) + const res = NextResponse.redirect(adapter.getAuthUrl(state)) + res.cookies.set(ESIGN_NONCE_COOKIE, nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }) + return res +} diff --git a/app/api/follow-ups/run/route.ts b/app/api/follow-ups/run/route.ts index b4f2de7..89add3c 100644 --- a/app/api/follow-ups/run/route.ts +++ b/app/api/follow-ups/run/route.ts @@ -1,15 +1,17 @@ import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/session" -import { getEffectiveOwnerId } from "@/lib/account" +import { getAccountContext } from "@/lib/account" import { runFollowUpsForUser } from "@/lib/follow-ups" export async function POST() { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const ownerId = await getEffectiveOwnerId(user.id) + // Sends real outbound follow-ups — a mutating action, so viewers are blocked. + const ctx = await getAccountContext(user.id) + if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 }) - const result = await runFollowUpsForUser(ownerId) + const result = await runFollowUpsForUser(ctx.ownerId) // Preserve the original response shape ({ sent, results }). The detailed // per-follow-up rows now live only in follow_up_log; the client re-fetches diff --git a/app/api/integrations/[provider]/callback/route.ts b/app/api/integrations/[provider]/callback/route.ts index 313f285..6346712 100644 --- a/app/api/integrations/[provider]/callback/route.ts +++ b/app/api/integrations/[provider]/callback/route.ts @@ -1,37 +1,51 @@ import { NextResponse } from "next/server" +import { cookies } from "next/headers" +import { getSessionUser } from "@/lib/session" +import { getAccountContext } from "@/lib/account" import { getProvider, saveConnection, type Provider } from "@/lib/accounting" -import { verifyState } from "@/lib/accounting/state" +import { verifyState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state" // OAuth callback — exchanges the code for tokens and stores the connection. export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider: pid } = await params const prov = getProvider(pid) - const url = new URL(request.url) const settings = new URL("/settings/integrations", request.url) + // Always clear the one-shot nonce cookie on the way out. + const cookieStore = await cookies() + const nonceCookie = cookieStore.get(OAUTH_NONCE_COOKIE)?.value + const done = (params: Record) => { + for (const [k, v] of Object.entries(params)) settings.searchParams.set(k, v) + const res = NextResponse.redirect(settings) + res.cookies.set(OAUTH_NONCE_COOKIE, "", { path: "/", maxAge: 0 }) + return res + } + + const url = new URL(request.url) const code = url.searchParams.get("code") const state = url.searchParams.get("state") const realmId = url.searchParams.get("realmId") // QuickBooks includes this const oauthError = url.searchParams.get("error") - if (oauthError || !prov) { - settings.searchParams.set("error", "connect_failed") - return NextResponse.redirect(settings) - } + if (oauthError || !prov) return done({ error: "connect_failed" }) const st = state ? verifyState(state) : null - if (!code || !st || st.provider !== pid) { - settings.searchParams.set("error", "invalid_state") - return NextResponse.redirect(settings) + // CSRF: the state's nonce must match the cookie set at connect time, and the + // current session must be the same owner that initiated the connect. + if (!code || !st || st.provider !== pid || !nonceCookie || nonceCookie !== st.nonce) { + return done({ error: "invalid_state" }) } + const user = await getSessionUser() + if (!user) return done({ error: "invalid_state" }) + const ctx = await getAccountContext(user.id) + if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" }) try { const tokens = await prov.exchangeCode(code, realmId) if (!tokens.realmId) throw new Error("No organisation returned from provider") await saveConnection(st.ownerId, pid as Provider, tokens) - settings.searchParams.set("connected", pid) + return done({ connected: pid }) } catch { - settings.searchParams.set("error", "connect_failed") + return done({ error: "connect_failed" }) } - return NextResponse.redirect(settings) } diff --git a/app/api/integrations/[provider]/connect/route.ts b/app/api/integrations/[provider]/connect/route.ts index 743882c..fffa168 100644 --- a/app/api/integrations/[provider]/connect/route.ts +++ b/app/api/integrations/[provider]/connect/route.ts @@ -1,8 +1,9 @@ +import crypto from "crypto" import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" import { getProvider } from "@/lib/accounting" -import { signState } from "@/lib/accounting/state" +import { signState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state" // Starts the OAuth connect flow for an accounting provider (owner-only). export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { @@ -28,6 +29,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov return NextResponse.redirect(settings) } - const state = signState({ ownerId: ctx.ownerId, provider: pid }) - return NextResponse.redirect(prov.getAuthUrl(state)) + // Bind the OAuth round-trip to this browser: a random nonce goes into the + // signed state AND an httpOnly cookie; the callback requires them to match. + const nonce = crypto.randomUUID() + const state = signState({ ownerId: ctx.ownerId, provider: pid, nonce }) + const res = NextResponse.redirect(prov.getAuthUrl(state)) + res.cookies.set(OAUTH_NONCE_COOKIE, nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }) + return res } diff --git a/app/api/paypal/cancel/route.ts b/app/api/paypal/cancel/route.ts deleted file mode 100644 index 92b91f2..0000000 --- a/app/api/paypal/cancel/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from "next/server" -import { eq } from "drizzle-orm" -import { db } from "@/lib/db" -import { profiles } from "@/lib/db/schema" -import { getSessionUser } from "@/lib/session" -import { cancelSubscription } from "@/lib/paypal/checkout" - -// Cancel the signed-in user's PayPal subscription. The account keeps access -// until the paid period ends; the BILLING.SUBSCRIPTION.CANCELLED webhook does -// the final downgrade to starter. -export async function POST() { - const user = await getSessionUser() - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - - const profile = await db.query.profiles.findFirst({ - where: eq(profiles.id, user.id), - columns: { paypal_subscription_id: true }, - }) - if (!profile?.paypal_subscription_id) { - return NextResponse.json({ error: "No PayPal subscription to cancel" }, { status: 400 }) - } - - const ok = await cancelSubscription(profile.paypal_subscription_id) - if (!ok) return NextResponse.json({ error: "PayPal cancellation failed" }, { status: 502 }) - - await db - .update(profiles) - .set({ subscription_status: "canceled" }) - .where(eq(profiles.id, user.id)) - - return NextResponse.json({ ok: true }) -} diff --git a/app/api/paypal/checkout/route.ts b/app/api/paypal/checkout/route.ts deleted file mode 100644 index 2c6117c..0000000 --- a/app/api/paypal/checkout/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextResponse } from "next/server" -import { eq } from "drizzle-orm" -import { db } from "@/lib/db" -import { profiles } from "@/lib/db/schema" -import { getSessionUser } from "@/lib/session" -import { paypalConfigured } from "@/lib/paypal/client" -import { getPaypalPlanId } from "@/lib/paypal/plans" -import { createSubscription, createOrder } from "@/lib/paypal/checkout" -import { PLAN_AMOUNTS } from "@/lib/stripe/plans" - -const RECURRING = new Set(["pro", "landlord"]) - -// Start a PayPal checkout for a plan upgrade and return the approval URL. -// Recurring plans → Subscriptions API; lifetime → one-time Orders API. -export async function POST(request: Request) { - if (!paypalConfigured()) { - return NextResponse.json({ error: "PayPal is not configured" }, { status: 400 }) - } - const user = await getSessionUser() - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - - const { plan, interval } = (await request.json().catch(() => ({}))) as { - plan?: string - interval?: "month" | "year" - } - if (!plan || (plan !== "lifetime" && !RECURRING.has(plan))) { - return NextResponse.json({ error: "Invalid plan" }, { status: 400 }) - } - - const appUrl = process.env.NEXT_PUBLIC_APP_URL! - const cancelUrl = `${appUrl}/settings/billing?canceled=true` - - try { - if (plan === "lifetime") { - const { approveUrl } = await createOrder({ - amount: PLAN_AMOUNTS.lifetime, - userId: user.id, - plan: "lifetime", - returnUrl: `${appUrl}/api/paypal/return?type=order`, - cancelUrl, - }) - if (!approveUrl) throw new Error("PayPal did not return an approval URL") - return NextResponse.json({ url: approveUrl }) - } - - const billingInterval = interval === "year" ? "year" : "month" - const planId = getPaypalPlanId(plan as "pro" | "landlord", billingInterval) - if (!planId) { - return NextResponse.json({ error: "That plan isn't available on PayPal yet." }, { status: 400 }) - } - - const profile = await db.query.profiles.findFirst({ - where: eq(profiles.id, user.id), - columns: { email: true }, - }) - - const { approveUrl } = await createSubscription({ - planId, - userId: user.id, - plan, - email: profile?.email ?? user.email, - returnUrl: `${appUrl}/api/paypal/return?type=subscription`, - cancelUrl, - }) - if (!approveUrl) throw new Error("PayPal did not return an approval URL") - return NextResponse.json({ url: approveUrl }) - } catch (e) { - return NextResponse.json( - { error: (e as Error).message || "PayPal checkout failed" }, - { status: 502 } - ) - } -} diff --git a/app/api/paypal/return/route.ts b/app/api/paypal/return/route.ts deleted file mode 100644 index 689f45d..0000000 --- a/app/api/paypal/return/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server" -import { getSessionUser } from "@/lib/session" -import { captureOrder, getSubscription, decodeCustomId } from "@/lib/paypal/checkout" -import { fulfillSubscription, fulfillLifetime } from "@/lib/paypal/fulfill" - -// PayPal redirects the approver back here. We finalize synchronously (capture -// the order / confirm the subscription) so the plan is live the moment they -// land on the billing page — the webhook is a backstop, not the only path. -export async function GET(request: Request) { - const url = new URL(request.url) - const type = url.searchParams.get("type") - const appUrl = process.env.NEXT_PUBLIC_APP_URL! - const ok = NextResponse.redirect(`${appUrl}/settings/billing?success=true`) - const fail = NextResponse.redirect(`${appUrl}/settings/billing?error=paypal`) - - const user = await getSessionUser() - if (!user) return NextResponse.redirect(`${appUrl}/login`) - - try { - if (type === "order") { - const orderId = url.searchParams.get("token") - if (!orderId) return fail - const captured = await captureOrder(orderId) - if (!captured || captured.status !== "COMPLETED") return fail - const decoded = decodeCustomId(captured.custom_id) - if (!decoded || decoded.userId !== user.id) return fail - await fulfillLifetime(user.id) - return ok - } - - // Subscription approval. - const subId = url.searchParams.get("subscription_id") - if (!subId) return fail - const sub = await getSubscription(subId) - if (!sub) return fail - const decoded = decodeCustomId(sub.custom_id) - // Only accept a subscription whose custom_id matches the signed-in user. - if (!decoded || decoded.userId !== user.id) return fail - - const active = sub.status === "ACTIVE" || sub.status === "APPROVED" - await fulfillSubscription( - user.id, - decoded.plan, - sub.id, - sub.billing_info?.next_billing_time, - active ? "active" : sub.status.toLowerCase() - ) - return ok - } catch { - return fail - } -} diff --git a/app/api/paypal/webhook/route.ts b/app/api/paypal/webhook/route.ts deleted file mode 100644 index 2a1b4cd..0000000 --- a/app/api/paypal/webhook/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { NextResponse } from "next/server" -import { verifyPaypalWebhook } from "@/lib/paypal/webhook" -import { decodeCustomId, getSubscription } from "@/lib/paypal/checkout" -import { fulfillSubscription, fulfillLifetime, markPaypalSubscriptionInactive } from "@/lib/paypal/fulfill" - -// Inbound PayPal webhook. Signature is verified via PayPal's API using -// PAYPAL_WEBHOOK_ID; unverified events are rejected. -export async function POST(request: Request) { - const body = await request.text() - - const valid = await verifyPaypalWebhook(request.headers, body) - if (!valid) return NextResponse.json({ error: "invalid signature" }, { status: 400 }) - - let event: { event_type?: string; resource?: Record } - try { - event = JSON.parse(body) - } catch { - return NextResponse.json({ ok: true }) - } - - const type = event.event_type ?? "" - const resource = (event.resource ?? {}) as Record - - try { - switch (type) { - case "BILLING.SUBSCRIPTION.ACTIVATED": - case "BILLING.SUBSCRIPTION.UPDATED": { - const decoded = decodeCustomId(resource.custom_id) - if (decoded && resource.id) { - await fulfillSubscription( - decoded.userId, - decoded.plan, - resource.id, - resource.billing_info?.next_billing_time, - "active" - ) - } - break - } - - case "PAYMENT.SALE.COMPLETED": { - // A recurring payment cleared — refresh status + next billing date. - const subId = resource.billing_agreement_id as string | undefined - if (subId) { - const sub = await getSubscription(subId) - const decoded = decodeCustomId(sub?.custom_id) - if (sub && decoded) { - await fulfillSubscription( - decoded.userId, - decoded.plan, - subId, - sub.billing_info?.next_billing_time, - "active" - ) - } - } - break - } - - case "BILLING.SUBSCRIPTION.CANCELLED": - case "BILLING.SUBSCRIPTION.EXPIRED": { - if (resource.id) { - await markPaypalSubscriptionInactive( - resource.id, - type.endsWith("CANCELLED") ? "canceled" : "expired", - true - ) - } - break - } - - case "BILLING.SUBSCRIPTION.SUSPENDED": { - if (resource.id) await markPaypalSubscriptionInactive(resource.id, "suspended", false) - break - } - - case "PAYMENT.CAPTURE.COMPLETED": { - // Lifetime order capture (backup to the return handler). - const decoded = decodeCustomId(resource.custom_id) - if (decoded && decoded.plan === "lifetime") await fulfillLifetime(decoded.userId) - break - } - } - } catch { - // Never loop forever on a handler bug — PayPal retries non-2xx. - } - - return NextResponse.json({ received: true }) -} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index f4f75f6..5f7f50f 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -8,8 +8,17 @@ export async function GET() { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Exclude bearer/secret + billing-id columns from the client payload. The + // calendar feed token and Stripe/PayPal ids are used server-side only. const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id), + columns: { + calendar_token: false, + stripe_customer_id: false, + stripe_subscription_id: false, + paypal_subscription_id: false, + billing_provider: false, + }, }) return NextResponse.json({ profile: profile ?? null }) diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index ae74d1a..8e5be3a 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/session" import { getAccountContext } from "@/lib/account" -import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage" +import { + saveFile, + isAllowedUploadExt, + StorageNotConfiguredError, + contentMatchesExtension, + extOf, +} from "@/lib/storage" import { checkStorageLimit } from "@/lib/plan-limits" const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"] @@ -33,6 +39,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "File type not allowed" }, { status: 400 }) } + // Reject files whose real content doesn't match the claimed extension. + const head = Buffer.from(await file.slice(0, 16).arrayBuffer()) + if (!contentMatchesExtension(head, extOf(file.name))) { + return NextResponse.json({ error: "File content does not match its type" }, { status: 400 }) + } + // Enforce per-plan storage quota (accounts for everything already stored in // the owner's portfolio namespace). const storageError = await checkStorageLimit(ownerId, file.size) diff --git a/app/global-error.tsx b/app/global-error.tsx index 9e1ea83..d206570 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -1,5 +1,6 @@ "use client" +import * as Sentry from "@sentry/nextjs" import { useEffect } from "react" export default function GlobalError({ @@ -10,6 +11,7 @@ export default function GlobalError({ reset: () => void }) { useEffect(() => { + Sentry.captureException(error) console.error(error) }, [error]) diff --git a/app/manifest.ts b/app/manifest.ts index cb4d200..4bf20d8 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest { theme_color: "#09090b", icons: [ { - src: "/logo-mark.png", + src: "/icon-192.png", type: "image/png", - sizes: "100x100", + sizes: "192x192", purpose: "any", }, + { + src: "/icon-512.png", + type: "image/png", + sizes: "512x512", + purpose: "any", + }, + { + src: "/icon-maskable-512.png", + type: "image/png", + sizes: "512x512", + purpose: "maskable", + }, ], } } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..e9306a6 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,44 @@ +import type { MetadataRoute } from "next" + +export default function robots(): MetadataRoute.Robots { + const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" + + return { + rules: { + userAgent: "*", + allow: "/", + // Private/app areas. Mirrors PROTECTED_PATHS in proxy.ts, plus the API + // surface and the token-gated tenant portal (both private but enforced + // outside the cookie proxy). `/tenant-portal/` keeps the trailing slash so + // it doesn't also block the indexable `/tenant-portal-info` marketing page. + disallow: [ + "/dashboard", + "/admin", + "/api/", + "/settings", + "/onboarding", + "/team", + "/tenant-portal/", + "/calendar", + "/inspections", + "/vendors", + "/reports", + "/activity", + "/ai", + "/ai-dashboard", + "/predictions", + "/recommendations", + "/impact", + "/follow-ups", + "/properties", + "/tenants", + "/rent", + "/maintenance", + "/leases", + "/expenses", + ], + }, + sitemap: `${base}/sitemap.xml`, + host: base, + } +} diff --git a/app/robots.txt/route.ts b/app/robots.txt/route.ts deleted file mode 100644 index cc767ca..0000000 --- a/app/robots.txt/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -export async function GET() { - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" - const body = `User-agent: * -Allow: / -Disallow: /dashboard -Disallow: /properties -Disallow: /tenants -Disallow: /rent -Disallow: /maintenance -Disallow: /leases -Disallow: /expenses -Disallow: /settings -Disallow: /tenant-portal/ -Disallow: /api/ - -Sitemap: ${appUrl}/sitemap.xml` - - return new Response(body, { - headers: { "Content-Type": "text/plain" }, - }) -} diff --git a/app/sitemap.ts b/app/sitemap.ts index c150955..8cdd331 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,17 +1,38 @@ import type { MetadataRoute } from "next" +import { LEGAL_PAGES } from "@/lib/legal" + +const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" + +type ChangeFrequency = MetadataRoute.Sitemap[number]["changeFrequency"] + +// Single source of truth for the public, indexable URL surface. Every entry +// must resolve to a real 200 page that is NOT noindex'd. Private/app routes are +// blocked in app/robots.ts, and the /login and /forgot-password auth pages are +// noindex, so all three are intentionally omitted here. /signup is kept as a +// conversion landing page. +const PAGES: { path: string; changeFrequency: ChangeFrequency; priority: number }[] = [ + { path: "/", changeFrequency: "weekly", priority: 1 }, + { path: "/tenant-portal-info", changeFrequency: "monthly", priority: 0.8 }, + { path: "/api-docs", changeFrequency: "monthly", priority: 0.8 }, + { path: "/signup", changeFrequency: "monthly", priority: 0.7 }, + // Legal pages are derived from the shared LEGAL_PAGES constant (the same list + // the footer renders) so the sitemap can never drift from the real routes. + ...LEGAL_PAGES.map((page) => ({ + path: page.href, + changeFrequency: "yearly" as const, + priority: 0.3, + })), +] export default function sitemap(): MetadataRoute.Sitemap { - const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" - const lastModified = new Date("2026-07-01") + // Build-time timestamp, refreshed on every deploy. We don't track per-page + // modification dates, so a single honest "last built" date is used throughout. + const lastModified = new Date() - return [ - { url: base, lastModified, changeFrequency: "weekly", priority: 1 }, - { url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 }, - { url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 }, - { url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 }, - { url: `${base}/privacy`, lastModified, changeFrequency: "yearly", priority: 0.3 }, - { url: `${base}/terms`, lastModified, changeFrequency: "yearly", priority: 0.3 }, - { url: `${base}/cookie-policy`, lastModified, changeFrequency: "yearly", priority: 0.3 }, - { url: `${base}/gdpr`, lastModified, changeFrequency: "yearly", priority: 0.3 }, - ] + return PAGES.map(({ path, changeFrequency, priority }) => ({ + url: path === "/" ? base : `${base}${path}`, + lastModified, + changeFrequency, + priority, + })) } diff --git a/components/admin/ai-provider-toggle.tsx b/components/admin/ai-provider-toggle.tsx new file mode 100644 index 0000000..4f954de --- /dev/null +++ b/components/admin/ai-provider-toggle.tsx @@ -0,0 +1,119 @@ +"use client" + +import { useState, useTransition } from "react" +import { toast } from "sonner" +import { Sparkles, Check, AlertTriangle } from "lucide-react" +import { setAiProviderAction } from "@/app/actions/admin" + +type Provider = "openai" | "anthropic" + +const LABELS: Record = { openai: "OpenAI", anthropic: "Anthropic (Claude)" } + +export function AiProviderToggle({ + selected, + effective, + openaiConfigured, + anthropicConfigured, + openaiModel, + anthropicModel, +}: { + selected: Provider + effective: Provider + openaiConfigured: boolean + anthropicConfigured: boolean + openaiModel: string + anthropicModel: string +}) { + const [current, setCurrent] = useState(selected) + const [pending, startTransition] = useTransition() + + const configured: Record = { openai: openaiConfigured, anthropic: anthropicConfigured } + const models: Record = { openai: openaiModel, anthropic: anthropicModel } + + function choose(next: Provider) { + if (next === current || pending) return + const prev = current + setCurrent(next) + startTransition(async () => { + try { + await setAiProviderAction(next) + toast.success(`AI provider set to ${LABELS[next]}`) + } catch { + setCurrent(prev) // revert optimistic change + toast.error("Couldn't switch the AI provider. Try again.") + } + }) + } + + // When the selected provider has no key on the server, AI falls back to the + // other configured provider (see lib/ai/provider). Surface that clearly. + const fallbackActive = effective !== current + const noneConfigured = !openaiConfigured && !anthropicConfigured + + const options: Provider[] = ["openai", "anthropic"] + + return ( +
+
+ +

AI provider

+
+ +
+

+ Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries, + receipts). Applies to everyone immediately. +

+ +
+ {options.map((p) => { + const active = current === p + return ( + + ) + })} +
+ + {noneConfigured ? ( +

+ + No AI provider key is set on the server — AI features return a 503 until{" "} + OPENAI_API_KEY or ANTHROPIC_API_KEY is configured. +

+ ) : fallbackActive ? ( +

+ + {LABELS[current]} has no API key on this server, so AI is temporarily running on{" "} + {LABELS[effective]}. Add the key to use {LABELS[current]}. +

+ ) : null} +
+
+ ) +} diff --git a/components/dashboard/esign-integrations.tsx b/components/dashboard/esign-integrations.tsx new file mode 100644 index 0000000..e650225 --- /dev/null +++ b/components/dashboard/esign-integrations.tsx @@ -0,0 +1,268 @@ +"use client" + +import { useEffect, useState, useTransition } from "react" +import { toast } from "sonner" +import { + PenLine, + Link2, + CheckCircle2, + AlertTriangle, + KeyRound, + ChevronDown, + ExternalLink, + Loader2, +} from "lucide-react" +import { connectDropboxSign, disconnectEsignAction } from "@/app/actions/esign" + +type Adapter = { id: string; label: string; kind: "oauth" | "apikey"; available: boolean } +type Conn = { provider: string; accountName: string | null; status: string; lastError: string | null } + +const ERR_MSG: Record = { + connect_failed: "Connection failed — please try again.", + invalid_state: "The connection link expired or was invalid. Please retry.", + owner_only: "Only the account owner can manage integrations.", + not_configured: "E-signature isn't enabled on this server yet.", + unknown_provider: "Unknown provider.", + use_api_key: "That provider connects with an API key, not a redirect.", +} + +const LABEL: Record = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" } + +function StatusPill({ status }: { status: string }) { + const error = status === "error" + return ( + + {error ? : } + {error ? "Error" : "Connected"} + + ) +} + +export function EsignIntegrations({ + adapters, + connections, + isOwner, + flash, + webhookUrl, +}: { + adapters: Adapter[] + connections: Conn[] + isOwner: boolean + flash: { connected?: string; error?: string } + webhookUrl: string +}) { + const connByProvider: Record = Object.fromEntries(connections.map((c) => [c.provider, c])) + const [pending, start] = useTransition() + const [busy, setBusy] = useState(null) + const [open, setOpen] = useState(null) // which provider's instructions are expanded + const [apiKey, setApiKey] = useState("") + const [showKeyForm, setShowKeyForm] = useState(false) + + useEffect(() => { + if (flash.connected) toast.success(`Connected to ${LABEL[flash.connected] ?? flash.connected}`) + if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong") + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function connectDbx() { + const key = apiKey.trim() + if (!key) return + setBusy("dropbox_sign") + start(async () => { + try { + const r = await connectDropboxSign(key) + toast.success(`Connected ${r.accountName ?? "Dropbox Sign"}`) + setApiKey("") + setShowKeyForm(false) + } catch (e) { + toast.error((e as Error).message || "Couldn't connect") + } finally { + setBusy(null) + } + }) + } + + function remove(id: string) { + if (!confirm(`Disconnect ${LABEL[id] ?? id}? You won't be able to send leases through it until you reconnect.`)) return + setBusy(id) + start(async () => { + try { + await disconnectEsignAction(id) + toast.success("Disconnected") + } catch { + toast.error("Couldn't disconnect") + } finally { + setBusy(null) + } + }) + } + + if (!isOwner) { + return ( +
+ Only the account owner can connect e-signature providers. +
+ ) + } + + return ( +
+ {/* Intro / how it works */} +
+
+ +

Send leases for e-signature

+
+

+ Connect your own DocuSign or Dropbox Sign account so signed leases carry + your brand and audit trail — and the signing costs stay on your provider plan, not ours. Once connected, a + “Send for signature” button appears on every lease that has a document + and a tenant email. +

+
    +
  1. 1. Connect your provider below (one-time).
  2. +
  3. 2. Open a lease → upload the lease PDF.
  4. +
  5. 3. Click “Send via DocuSign / Dropbox Sign.” The tenant signs; the status updates here automatically and the signed copy is saved back to the lease.
  6. +
+
+ + {adapters.map((a) => { + const conn = connByProvider[a.id] + const isBusy = pending && busy === a.id + const instructionsOpen = open === a.id + return ( +
+
+
+
+ +
+
+

{a.label}

+ {conn ? ( +

{conn.accountName ?? "Connected"}

+ ) : ( +

+ {a.kind === "oauth" ? "Connect with your DocuSign login" : "Connect with your API key"} +

+ )} +
+
+ {conn ? ( + + ) : !a.available ? ( + + Not available + + ) : null} +
+ + {conn?.status === "error" && conn.lastError && ( +

{conn.lastError}

+ )} + + {/* Actions */} +
+ {conn ? ( + + ) : !a.available ? ( +

+ Ask your administrator to enable {a.label} (server credentials aren't configured). +

+ ) : a.kind === "oauth" ? ( + + Connect {a.label} + + ) : showKeyForm ? ( +
+ setApiKey(e.target.value)} + placeholder="Paste your Dropbox Sign API key" + className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-white placeholder-white/30 outline-none focus:border-indigo-500/50" + /> + +
+ ) : ( + + )} + + {a.available && ( + + )} +
+ + {/* Instructions */} + {instructionsOpen && ( +
+ {a.id === "docusign" ? ( +
    +
  1. + 1. You need an active{" "} + + DocuSign eSignature plan + . +
  2. +
  3. 2. Click Connect DocuSign above.
  4. +
  5. 3. Log in to your DocuSign account and click Allow to grant access.
  6. +
  7. 4. You'll return here connected — no webhook setup needed. Signed-status updates and the completed PDF flow back automatically.
  8. +
+ ) : ( +
    +
  1. + 1. In Dropbox Sign, open{" "} + + Settings → API + {" "} + and copy your API key. +
  2. +
  3. 2. Paste it above and click Connect.
  4. +
  5. + 3. In the same API settings, set your account callback URL to: + {webhookUrl} + This lets us receive signed-status updates. +
  6. +
+ )} +
+ )} +
+ ) + })} + +

+ Your credentials are encrypted at rest and never leave the server. We only send the leases you explicitly submit. +

+
+ ) +} diff --git a/components/forms/checkout-button.tsx b/components/forms/checkout-button.tsx index a86a389..abaa9ed 100644 --- a/components/forms/checkout-button.tsx +++ b/components/forms/checkout-button.tsx @@ -9,7 +9,6 @@ export function CheckoutButton({ highlight, interval = "month", annualAvailable = false, - paypalEnabled = false, }: { plan: string label: string @@ -18,11 +17,8 @@ export function CheckoutButton({ // When true, show a monthly/annual choice. Only pass this for subscription // plans and only when annual billing is actually configured server-side. annualAvailable?: boolean - // When true, also offer "Pay with PayPal" using the same interval choice. - paypalEnabled?: boolean }) { const [loading, setLoading] = useState(false) - const [paypalLoading, setPaypalLoading] = useState(false) const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval) const effectiveInterval = annualAvailable ? chosenInterval : interval @@ -39,21 +35,6 @@ export function CheckoutButton({ else setLoading(false) } - async function handlePaypal() { - setPaypalLoading(true) - const res = await fetch("/api/paypal/checkout", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ plan, interval: effectiveInterval }), - }) - const data = await res.json() - if (data.url) window.location.href = data.url - else { - setPaypalLoading(false) - if (data.error) alert(data.error) - } - } - return (
{annualAvailable && ( @@ -82,7 +63,7 @@ export function CheckoutButton({ )} - - {paypalEnabled && ( - - )}
) } diff --git a/components/forms/esign-lease.tsx b/components/forms/esign-lease.tsx index 3e2c3e6..ae782e8 100644 --- a/components/forms/esign-lease.tsx +++ b/components/forms/esign-lease.tsx @@ -1,8 +1,9 @@ "use client" import { useTransition } from "react" +import Link from "next/link" import { toast } from "sonner" -import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react" +import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react" import { formatDate } from "@/lib/utils" import { sendLeaseForSignatureAction } from "@/app/actions/esign" @@ -12,11 +13,12 @@ type Req = { status: string signer_email: string document_name: string | null + signed_document_url: string | null sent_at: string | null completed_at: string | null last_error: string | null } -type Prov = { id: string; label: string; configured: boolean } +type Prov = { id: string; label: string } const STATUS: Record = { sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock }, @@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record = { docusign: "DocuSign", dropbox_s export function EsignLease({ leaseId, - providers, + connected, requests, canSend, disabledReason, }: { leaseId: string - providers: Prov[] + connected: Prov[] requests: Req[] canSend: boolean disabledReason: string }) { - const configured = providers.filter((p) => p.configured) const [pending, start] = useTransition() function send(provider: string) { @@ -74,6 +75,16 @@ export function EsignLease({ {r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""} {r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}

+ {r.signed_document_url && ( + + Signed document + + )}
{s.label} @@ -84,11 +95,16 @@ export function EsignLease({ )} - {configured.length === 0 ? ( -

Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.

+ {connected.length === 0 ? ( +

+ + Connect DocuSign or Dropbox Sign + {" "} + in Settings → Integrations to send leases for signature. +

) : canSend ? (
- {configured.map((p) => ( + {connected.map((p) => ( - ) -} diff --git a/components/marketing/structured-data.tsx b/components/marketing/structured-data.tsx index 50c229e..2f03f7f 100644 --- a/components/marketing/structured-data.tsx +++ b/components/marketing/structured-data.tsx @@ -1,5 +1,19 @@ +import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans" +import type { Plan } from "@/types" + const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" +// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth). +// Annual billing is auto-provisioned at checkout and has no fixed amount here, so +// we advertise only the monthly / one-time base prices that actually exist. +const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"] +const planOffers = planOrder.map((plan) => ({ + "@type": "Offer", + name: getPlanLabel(plan), + price: String(PLAN_AMOUNTS[plan]), + priceCurrency: "USD", +})) + // Mirrors the visible FAQ content in components/marketing/faq.tsx. // Keep these in sync with that source so the JSON-LD matches what users see. const faqs = [ @@ -43,6 +57,11 @@ const organization: Record = { name: "Property Management Network", url: base, logo: `${base}/logo-mark.png`, + contactPoint: { + "@type": "ContactPoint", + contactType: "customer support", + email: "support@propertymanagement.network", + }, sameAs: [ "https://twitter.com/propertymgmtnet", "https://github.com/propertymanagement-network", @@ -64,11 +83,7 @@ const softwareApplication: Record = { operatingSystem: "Web", description: "Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.", - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - }, + offers: planOffers, } const faqPage: Record = { diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 0000000..0391345 --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,21 @@ +// Sentry initialization for the browser. Next.js loads this client-side +// instrumentation file automatically (Next 15.3+). +import * as Sentry from "@sentry/nextjs" + +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN + +Sentry.init({ + dsn, + // Inert until a DSN is configured. + enabled: !!dsn, + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, + // Session Replay: record 10% of all sessions, and 100% of sessions with an + // error. Text is masked and media blocked so tenant/landlord PII isn't captured. + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true })], +}) + +// Instrument client-side route transitions for tracing. +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/instrumentation.ts b/instrumentation.ts index 859c531..2679f02 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,8 +1,19 @@ // Next.js instrumentation hook — runs once when the server process starts. -// Surfaces "silently disabled" integrations at boot so a misconfigured deploy is -// obvious in the logs instead of failing quietly at runtime. -export function register() { - // Only run in the Node.js server runtime (not edge/middleware) and only in prod. +// (1) Initializes Sentry for the active runtime, and (2) surfaces "silently +// disabled" integrations at boot so a misconfigured deploy is obvious in the +// logs instead of failing quietly at runtime. +import * as Sentry from "@sentry/nextjs" + +export async function register() { + // Initialize Sentry for whichever server runtime is booting. + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config") + } + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config") + } + + // --- Startup env warnings (Node.js server runtime, production only) --- if (process.env.NEXT_RUNTIME !== "nodejs") return if (process.env.NODE_ENV !== "production") return @@ -33,4 +44,12 @@ export function register() { if (!process.env.OPENAI_API_KEY) { warn("OPENAI_API_KEY is NOT set — AI features will error when called.") } + + if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) { + warn("SENTRY DSN is NOT set — error monitoring is disabled (no crash reports).") + } } + +// Capture errors thrown in nested React Server Components, route handlers, and +// server actions and report them to Sentry. +export const onRequestError = Sentry.captureRequestError diff --git a/lib/accounting/state.ts b/lib/accounting/state.ts index 238e519..628a123 100644 --- a/lib/accounting/state.ts +++ b/lib/accounting/state.ts @@ -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 } diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts index 644a5ee..ec672f5 100644 --- a/lib/admin/audit.ts +++ b/lib/admin/audit.ts @@ -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 diff --git a/lib/ai/anthropic.ts b/lib/ai/anthropic.ts new file mode 100644 index 0000000..d2d2491 --- /dev/null +++ b/lib/ai/anthropic.ts @@ -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 +} diff --git a/lib/ai/client.ts b/lib/ai/client.ts index b666e02..bc7a27a 100644 --- a/lib/ai/client.ts +++ b/lib/ai/client.ts @@ -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. diff --git a/lib/ai/provider.ts b/lib/ai/provider.ts new file mode 100644 index 0000000..c3db096 --- /dev/null +++ b/lib/ai/provider.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/lib/db/admin-queries.ts b/lib/db/admin-queries.ts index e372057..c96a0ff 100644 --- a/lib/db/admin-queries.ts +++ b/lib/db/admin-queries.ts @@ -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", diff --git a/lib/db/migrations/0010_esign_connections.sql b/lib/db/migrations/0010_esign_connections.sql new file mode 100644 index 0000000..7e47810 --- /dev/null +++ b/lib/db/migrations/0010_esign_connections.sql @@ -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; \ No newline at end of file diff --git a/lib/db/migrations/meta/0010_snapshot.json b/lib/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..dd3cdb2 --- /dev/null +++ b/lib/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,3548 @@ +{ + "id": "5b8a5779-88ab-4af3-abc9-f42d536346e2", + "prevId": "fff0a9bb-59c2-4555-8b31-08a6774204ef", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_members": { + "name": "account_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_members_owner_id_profiles_id_fk": { + "name": "account_members_owner_id_profiles_id_fk", + "tableFrom": "account_members", + "tableTo": "profiles", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_members_member_id_profiles_id_fk": { + "name": "account_members_member_id_profiles_id_fk", + "tableFrom": "account_members", + "tableTo": "profiles", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_members_invite_token_unique": { + "name": "account_members_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounting_connections": { + "name": "accounting_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_name": { + "name": "org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounting_connections_user_id_profiles_id_fk": { + "name": "accounting_connections_user_id_profiles_id_fk", + "tableFrom": "accounting_connections", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "activity_log_user_id_profiles_id_fk": { + "name": "activity_log_user_id_profiles_id_fk", + "tableFrom": "activity_log", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_audit_log_admin_id_user_id_fk": { + "name": "admin_audit_log_admin_id_user_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "user", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_predictions": { + "name": "ai_predictions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prediction": { + "name": "prediction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeframe": { + "name": "timeframe", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_level": { + "name": "risk_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_predictions_user_id_profiles_id_fk": { + "name": "ai_predictions_user_id_profiles_id_fk", + "tableFrom": "ai_predictions", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_recommendations": { + "name": "ai_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "action_label": { + "name": "action_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_data": { + "name": "action_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_recommendations_user_id_profiles_id_fk": { + "name": "ai_recommendations_user_id_profiles_id_fk", + "tableFrom": "ai_recommendations", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_profiles_id_fk": { + "name": "api_keys_user_id_profiles_id_fk", + "tableFrom": "api_keys", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "documents_user_id_profiles_id_fk": { + "name": "documents_user_id_profiles_id_fk", + "tableFrom": "documents", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_property_id_properties_id_fk": { + "name": "documents_property_id_properties_id_fk", + "tableFrom": "documents", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_tenant_id_tenants_id_fk": { + "name": "documents_tenant_id_tenants_id_fk", + "tableFrom": "documents", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esign_connections": { + "name": "esign_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_uri": { + "name": "base_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "esign_connections_user_id_profiles_id_fk": { + "name": "esign_connections_user_id_profiles_id_fk", + "tableFrom": "esign_connections", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.expenses": { + "name": "expenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "expense_date": { + "name": "expense_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "expenses_user_id_profiles_id_fk": { + "name": "expenses_user_id_profiles_id_fk", + "tableFrom": "expenses", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "expenses_property_id_properties_id_fk": { + "name": "expenses_property_id_properties_id_fk", + "tableFrom": "expenses", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "expenses_unit_id_units_id_fk": { + "name": "expenses_unit_id_units_id_fk", + "tableFrom": "expenses", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follow_up_log": { + "name": "follow_up_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "follow_up_log_user_id_profiles_id_fk": { + "name": "follow_up_log_user_id_profiles_id_fk", + "tableFrom": "follow_up_log", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follow_up_log_rule_id_follow_up_rules_id_fk": { + "name": "follow_up_log_rule_id_follow_up_rules_id_fk", + "tableFrom": "follow_up_log", + "tableTo": "follow_up_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follow_up_rules": { + "name": "follow_up_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_days": { + "name": "trigger_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "message_template": { + "name": "message_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "follow_up_rules_user_id_profiles_id_fk": { + "name": "follow_up_rules_user_id_profiles_id_fk", + "tableFrom": "follow_up_rules", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inspections": { + "name": "inspections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "items": { + "name": "items", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inspections_user_id_profiles_id_fk": { + "name": "inspections_user_id_profiles_id_fk", + "tableFrom": "inspections", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspections_property_id_properties_id_fk": { + "name": "inspections_property_id_properties_id_fk", + "tableFrom": "inspections", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspections_unit_id_units_id_fk": { + "name": "inspections_unit_id_units_id_fk", + "tableFrom": "inspections", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leases": { + "name": "leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_start": { + "name": "lease_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "lease_end": { + "name": "lease_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "rent_amount": { + "name": "rent_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "security_deposit": { + "name": "security_deposit", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "lease_type": { + "name": "lease_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "document_url": { + "name": "document_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "auto_renew": { + "name": "auto_renew", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_60_sent": { + "name": "reminder_60_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_30_sent": { + "name": "reminder_30_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_7_sent": { + "name": "reminder_7_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "leases_user_id_profiles_id_fk": { + "name": "leases_user_id_profiles_id_fk", + "tableFrom": "leases", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leases_tenant_id_tenants_id_fk": { + "name": "leases_tenant_id_tenants_id_fk", + "tableFrom": "leases", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leases_property_id_properties_id_fk": { + "name": "leases_property_id_properties_id_fk", + "tableFrom": "leases", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leases_unit_id_units_id_fk": { + "name": "leases_unit_id_units_id_fk", + "tableFrom": "leases", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maintenance_requests": { + "name": "maintenance_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "actual_cost": { + "name": "actual_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_notes": { + "name": "resolution_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "maintenance_requests_user_id_profiles_id_fk": { + "name": "maintenance_requests_user_id_profiles_id_fk", + "tableFrom": "maintenance_requests", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_requests_tenant_id_tenants_id_fk": { + "name": "maintenance_requests_tenant_id_tenants_id_fk", + "tableFrom": "maintenance_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "maintenance_requests_property_id_properties_id_fk": { + "name": "maintenance_requests_property_id_properties_id_fk", + "tableFrom": "maintenance_requests", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_requests_unit_id_units_id_fk": { + "name": "maintenance_requests_unit_id_units_id_fk", + "tableFrom": "maintenance_requests", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_profiles_id_fk": { + "name": "notifications_user_id_profiles_id_fk", + "tableFrom": "notifications", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starter'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paypal_subscription_id": { + "name": "paypal_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_provider": { + "name": "billing_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "brand_name": { + "name": "brand_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_logo_url": { + "name": "brand_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hide_powered_by": { + "name": "hide_powered_by", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "calendar_token": { + "name": "calendar_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()::text" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_id_user_id_fk": { + "name": "profiles_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_stripe_customer_id_unique": { + "name": "profiles_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "profiles_calendar_token_unique": { + "name": "profiles_calendar_token_unique", + "nullsNotDistinct": false, + "columns": [ + "calendar_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.properties": { + "name": "properties", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'US'" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'residential'" + }, + "total_units": { + "name": "total_units", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "properties_user_id_profiles_id_fk": { + "name": "properties_user_id_profiles_id_fk", + "tableFrom": "properties", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_payments": { + "name": "rent_payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "paid_date": { + "name": "paid_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_link_id": { + "name": "stripe_payment_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminder_sent_at": { + "name": "reminder_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "late_fee_applied": { + "name": "late_fee_applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rent_payments_user_id_profiles_id_fk": { + "name": "rent_payments_user_id_profiles_id_fk", + "tableFrom": "rent_payments", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rent_payments_tenant_id_tenants_id_fk": { + "name": "rent_payments_tenant_id_tenants_id_fk", + "tableFrom": "rent_payments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rent_payments_property_id_properties_id_fk": { + "name": "rent_payments_property_id_properties_id_fk", + "tableFrom": "rent_payments", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rent_payments_unit_id_units_id_fk": { + "name": "rent_payments_unit_id_units_id_fk", + "tableFrom": "rent_payments", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signature_requests": { + "name": "signature_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "signer_email": { + "name": "signer_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer_name": { + "name": "signer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_name": { + "name": "document_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signed_document_url": { + "name": "signed_document_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signature_requests_user_id_profiles_id_fk": { + "name": "signature_requests_user_id_profiles_id_fk", + "tableFrom": "signature_requests", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signature_requests_lease_id_leases_id_fk": { + "name": "signature_requests_lease_id_leases_id_fk", + "tableFrom": "signature_requests", + "tableTo": "leases", + "columnsFrom": [ + "lease_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emergency_contact_name": { + "name": "emergency_contact_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emergency_contact_phone": { + "name": "emergency_contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "move_in_date": { + "name": "move_in_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "move_out_date": { + "name": "move_out_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "portal_token": { + "name": "portal_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_user_id_profiles_id_fk": { + "name": "tenants_user_id_profiles_id_fk", + "tableFrom": "tenants", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenants_property_id_properties_id_fk": { + "name": "tenants_property_id_properties_id_fk", + "tableFrom": "tenants", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenants_unit_id_units_id_fk": { + "name": "tenants_unit_id_units_id_fk", + "tableFrom": "tenants", + "tableTo": "units", + "columnsFrom": [ + "unit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_portal_token_unique": { + "name": "tenants_portal_token_unique", + "nullsNotDistinct": false, + "columns": [ + "portal_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.units": { + "name": "units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit_number": { + "name": "unit_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "bathrooms": { + "name": "bathrooms", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "1" + }, + "sq_ft": { + "name": "sq_ft", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rent_amount": { + "name": "rent_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vacant'" + }, + "current_tenant_id": { + "name": "current_tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "units_property_id_properties_id_fk": { + "name": "units_property_id_properties_id_fk", + "tableFrom": "units", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "units_user_id_profiles_id_fk": { + "name": "units_user_id_profiles_id_fk", + "tableFrom": "units", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_events": { + "name": "usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usage_events_user_id_profiles_id_fk": { + "name": "usage_events_user_id_profiles_id_fk", + "tableFrom": "usage_events", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vendors": { + "name": "vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trade": { + "name": "trade", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "vendors_user_id_profiles_id_fk": { + "name": "vendors_user_id_profiles_id_fk", + "tableFrom": "vendors", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vendors_property_id_properties_id_fk": { + "name": "vendors_property_id_properties_id_fk", + "tableFrom": "vendors", + "tableTo": "properties", + "columnsFrom": [ + "property_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_user_id_profiles_id_fk": { + "name": "webhook_deliveries_user_id_profiles_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk": { + "name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'dashboard'" + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_endpoints_user_id_profiles_id_fk": { + "name": "webhook_endpoints_user_id_profiles_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index c1441ed..8521a1b 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1782994066547, "tag": "0009_amusing_blackheart", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1783017593260, + "tag": "0010_esign_connections", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index efc8ade..d7b8671 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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) // ============================================================ diff --git a/lib/esign/credentials.ts b/lib/esign/credentials.ts new file mode 100644 index 0000000..a942d63 --- /dev/null +++ b/lib/esign/credentials.ts @@ -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 { + 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))) +} diff --git a/lib/esign/docusign.ts b/lib/esign/docusign.ts index f6db888..5c68666 100644 --- a/lib/esign/docusign.ts +++ b/lib/esign/docusign.ts @@ -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): 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/ */ +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 { + 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 { + 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 { + 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 { + 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()) + }, } diff --git a/lib/esign/dropbox-sign.ts b/lib/esign/dropbox-sign.ts index 9d64935..d70ee18 100644 --- a/lib/esign/dropbox-sign.ts +++ b/lib/esign/dropbox-sign.ts @@ -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 { + throw new Error("Dropbox Sign connects with an API key, not OAuth") + }, + async refresh(): Promise { + throw new Error("Dropbox Sign API keys don't expire") + }, + + async connectApiKey(apiKey): Promise { + 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 { + 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()) }, } diff --git a/lib/esign/index.ts b/lib/esign/index.ts index 5917d9c..55a9c57 100644 --- a/lib/esign/index.ts +++ b/lib/esign/index.ts @@ -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 = { 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. + } + } } diff --git a/lib/esign/registry.ts b/lib/esign/registry.ts new file mode 100644 index 0000000..99b0d23 --- /dev/null +++ b/lib/esign/registry.ts @@ -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 = { 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(), + })) +} diff --git a/lib/esign/state.ts b/lib/esign/state.ts new file mode 100644 index 0000000..7026797 --- /dev/null +++ b/lib/esign/state.ts @@ -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 + } +} diff --git a/lib/esign/types.ts b/lib/esign/types.ts index cd439b0..07282d9 100644 --- a/lib/esign/types.ts +++ b/lib/esign/types.ts @@ -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 + refresh(refreshToken: string): Promise + + // ── API-key providers (Dropbox Sign) ────────────────────────────────────── + /** Validate a pasted API key and return a token set to store. */ + connectApiKey(apiKey: string): Promise + + // ── 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 + /** Download the completed/executed document, or null if unavailable. */ + getSignedDocument(creds: ESignCredentials, externalId: string): Promise +} + +/** 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` } diff --git a/lib/paypal/checkout.ts b/lib/paypal/checkout.ts deleted file mode 100644 index e6c8692..0000000 --- a/lib/paypal/checkout.ts +++ /dev/null @@ -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: ":". -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 { - 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 { - 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 -} diff --git a/lib/paypal/client.ts b/lib/paypal/client.ts deleted file mode 100644 index 84b3d7e..0000000 --- a/lib/paypal/client.ts +++ /dev/null @@ -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 { - 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 { - const token = await getAccessToken() - return fetch(`${BASE_URL}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - ...(init.headers ?? {}), - }, - }) -} diff --git a/lib/paypal/fulfill.ts b/lib/paypal/fulfill.ts deleted file mode 100644 index 46d278e..0000000 --- a/lib/paypal/fulfill.ts +++ /dev/null @@ -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 = ["pro", "landlord"] - -export async function fulfillSubscription( - userId: string, - plan: string, - subscriptionId: string, - nextBillingTime?: string | null, - status = "active", -): Promise { - 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 { - 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 { - 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)) -} diff --git a/lib/paypal/plans.ts b/lib/paypal/plans.ts deleted file mode 100644 index e37ab9d..0000000 --- a/lib/paypal/plans.ts +++ /dev/null @@ -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 = { - "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"]) -} diff --git a/lib/paypal/webhook.ts b/lib/paypal/webhook.ts deleted file mode 100644 index 4a7cd7e..0000000 --- a/lib/paypal/webhook.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/lib/storage.ts b/lib/storage.ts index 8fe5ec7..b3819a6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -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 (`/…`). + * Keys are generated server-side as `//`, 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 { 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}/.` (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 { 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 { +export async function deleteFile(key: string, ownerId: string): Promise { + // 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) })) diff --git a/next.config.ts b/next.config.ts index 6bf5e39..aa966e7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withSentryConfig } from "@sentry/nextjs"; // NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next // middleware) so `script-src` can carry a per-request nonce instead of @@ -31,4 +32,16 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + org: "phluit", + project: "property-management-network", + // Only print source-map upload logs in CI. + silent: !process.env.CI, + // Upload a wider set of client source maps for readable stack traces. + widenClientFileUpload: true, + // Tree-shake Sentry's debug logger to shrink the client bundle. + disableLogger: true, + // Source-map upload runs at build time only when SENTRY_AUTH_TOKEN is set; + // without it the build still succeeds (stack traces just aren't un-minified). +}); + diff --git a/package-lock.json b/package-lock.json index e03907f..25b707d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "property-management-network", "version": "0.1.0", + "license": "UNLICENSED", "dependencies": { + "@anthropic-ai/sdk": "^0.110.0", "@aws-sdk/client-s3": "^3.1077.0", "@aws-sdk/s3-request-presigner": "^3.1078.0", "@fullcalendar/daygrid": "^6.1.21", @@ -16,6 +18,7 @@ "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@radix-ui/react-switch": "^1.2.6", + "@sentry/nextjs": "^10.63.0", "@types/papaparse": "^5.5.2", "better-auth": "^1.6.20", "clsx": "^2.1.1", @@ -68,6 +71,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", + "integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", + "integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.1.tgz", + "integrity": "sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@aws-sdk/checksums": { "version": "3.1000.10", "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.10.tgz", @@ -378,7 +441,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -393,7 +455,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -403,7 +464,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -434,7 +494,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -451,7 +510,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -468,7 +526,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -478,7 +535,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -492,7 +548,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -510,7 +565,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -520,7 +574,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -530,7 +583,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -540,7 +592,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -554,7 +605,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -579,7 +629,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -594,7 +643,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -613,7 +661,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2333,7 +2380,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2344,7 +2390,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2355,24 +2400,31 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2606,6 +2658,86 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", @@ -2857,6 +2989,390 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", + "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2864,6 +3380,433 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", + "integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.63.0.tgz", + "integrity": "sha512-0mi56YOkwgyjdLOcN5cB1//EcYzEOt3NZ2GLygE92B3zAAwVM1WgbmibZCXToKFClH7z1uH3VWVfBffmkwIMYw==", + "dependencies": { + "@sentry/browser-utils": "10.63.0", + "@sentry/core": "10.63.0", + "@sentry/feedback": "10.63.0", + "@sentry/replay": "10.63.0", + "@sentry/replay-canvas": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.63.0.tgz", + "integrity": "sha512-DhUGNN+CH8fzAs6qAsueKPU70qShyTX3NxLhIP+l5DbGXDSXpYXBT6s8ubZus0/LhxpLvI0iSyNIDvZRD/gZaA==", + "dependencies": { + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz", + "integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.3.0", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "hasInstallScript": true, + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", + "integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.63.0.tgz", + "integrity": "sha512-OtUbsrnbEHffOF2S2+M5zXa3HIM0U2b4CDVLKMY1dgS0J3ivRF8XvkjvyIcEG/y8JXnwXbnprLyjhG+AqMdUZQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.63.0.tgz", + "integrity": "sha512-If/+72xFg9ylz4twUo3U9gUpZ+Ys+T/3Y09WH7r2gGhWEOF9bp+ta94+Pg7Lb0M2nVD7waz4OxIvB49GEvtLDA==", + "dependencies": { + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/nextjs": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.63.0.tgz", + "integrity": "sha512-SN3tBm+wpDmr4GONaxuIRjQglAiPBKF7JEsQlli1HNfar1JG+fRsxWy0/aKcn9Kp/XX1kOpFgW9tcfuE4UKm7Q==", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@rollup/plugin-commonjs": "28.0.1", + "@sentry/browser-utils": "10.63.0", + "@sentry/bundler-plugin-core": "^5.3.0", + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.63.0", + "@sentry/node": "10.63.0", + "@sentry/opentelemetry": "10.63.0", + "@sentry/react": "10.63.0", + "@sentry/vercel-edge": "10.63.0", + "@sentry/webpack-plugin": "^5.3.0", + "rollup": "^4.60.3", + "stacktrace-parser": "^0.1.11" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" + } + }, + "node_modules/@sentry/node": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.63.0.tgz", + "integrity": "sha512-E+JfDTdUDGQPRsAfCTR2YgmQgxYdoxk4ks6niHN+ByW8alEZL+nXlcN9vI57qj1LsS4v2jjfLxJf1/cMMt84YA==", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.63.0", + "@sentry/node-core": "10.63.0", + "@sentry/opentelemetry": "10.63.0", + "@sentry/server-utils": "10.63.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.63.0.tgz", + "integrity": "sha512-TaNtkGDRNxH3SjOea2PDtaebkNjMbAH8ZFsEcwlqmadpS7nqSR7z6slZy/iu7y1nLiUdbmcM5JmXwxksy52WRQ==", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.63.0", + "@sentry/opentelemetry": "10.63.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.63.0.tgz", + "integrity": "sha512-8yqi8+Ej/anmMn82blXA0BNMeAMs4av6nx0DzhxDrFya28ZaYOn19PChd3erMidfU0HnLLFNqWiFlYxBKq+/KA==", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.63.0.tgz", + "integrity": "sha512-+/Y0dd4EMqyqYBJ1D3bAYYuG+ccIx5+IFcbTZ9p+XWnW6nNIVjy5zttVftYo6xOmtTQbzRuPT/vO4dqDHKKmfw==", + "dependencies": { + "@sentry/browser": "10.63.0", + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.63.0.tgz", + "integrity": "sha512-u4fDaLbd4QmJbU0qGzV5g2B2hjw5utdeZzpTrmq565AS5o6mfaZdCz30zF9R2Unkn0g9SJr90piTN2RMwvDrkw==", + "dependencies": { + "@sentry/browser-utils": "10.63.0", + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.63.0.tgz", + "integrity": "sha512-1Dg6yo+KDNZcE9M6V2EP4DGgTDJMcUgg5ui69w/E96ZZPWErS/bibK2bGj20H3qwpJXlnEwXB5YAJ2fZ620T1A==", + "dependencies": { + "@sentry/core": "10.63.0", + "@sentry/replay": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.63.0.tgz", + "integrity": "sha512-7NN//DG9Yak8t2+6WiEcNmN269iHRVdtZtZIwucEd0OXyZ3FEBBDaBF+bT9V6H/kPtUvVMkHQ72Bn2Xs5JYGxg==", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/tracing-hooks": "^0.10.0", + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.63.0", + "magic-string": "~0.30.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vercel-edge": { + "version": "10.63.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.63.0.tgz", + "integrity": "sha512-6vay7/Skgjt1SiDchobaN7mOeSDRwhQUikVvuT7Q/0nX5Xg5TRlSMbqwcllqKxwDXdJiW3MVdqqLixY1c8WjJA==", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@sentry/core": "10.63.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/webpack-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.3.0.tgz", + "integrity": "sha512-i3OQUrS0FZlXLgq57RIKDp+vHHzuvYKPCKewAPXULWKMsBXFGhP6veGRQ+6To/pmZkkXjEX5ofVNDy9C3jEPKQ==", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "webpack": ">=5.0.0" + } + }, "node_modules/@smithy/core": { "version": "3.29.0", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz", @@ -2939,6 +3882,11 @@ "node": ">=18.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3306,11 +4254,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" }, "node_modules/@types/geojson": { "version": "7946.0.16", @@ -3322,7 +4268,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -3989,11 +4934,168 @@ "win32" ] }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "peer": true + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4002,6 +5104,26 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -4012,6 +5134,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -4029,6 +5162,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "peer": true + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4229,6 +5401,14 @@ "dev": true, "license": "MIT" }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4460,7 +5640,6 @@ "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -4493,8 +5672,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/call-bind": { "version": "1.0.8", @@ -4613,6 +5791,20 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4648,6 +5840,17 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "peer": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4659,7 +5862,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, "license": "MIT" }, "node_modules/core-js": { @@ -4902,7 +6104,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5172,7 +6373,6 @@ "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -5183,14 +6383,12 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, - "license": "MIT", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -5314,6 +6512,11 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5429,7 +6632,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5812,7 +7014,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -5825,7 +7026,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -5838,12 +7038,16 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5860,11 +7064,19 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5928,6 +7140,27 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "peer": true + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5974,7 +7207,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -6055,7 +7287,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6121,7 +7352,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6197,6 +7427,22 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6210,6 +7456,39 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -6257,7 +7536,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -6277,7 +7555,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6385,6 +7662,18 @@ "node": ">=8.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6422,6 +7711,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", + "integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6732,6 +8035,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6888,7 +8199,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -6909,6 +8219,35 @@ "node": ">= 0.4" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6931,7 +8270,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6951,7 +8289,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -6967,6 +8304,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6985,7 +8334,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -7345,11 +8693,23 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -7385,7 +8745,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -7404,7 +8763,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7420,6 +8778,12 @@ "node": ">= 0.4" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "peer": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7430,6 +8794,14 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7444,6 +8816,15 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7467,6 +8848,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -7486,7 +8940,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -7544,6 +8997,12 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "peer": true + }, "node_modules/next": { "version": "16.2.2", "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", @@ -7644,11 +9103,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, "license": "MIT" }, "node_modules/nodemailer": { @@ -7843,7 +9320,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -7859,7 +9335,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -7894,7 +9369,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7917,6 +9391,29 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -8157,6 +9654,14 @@ "node": ">= 0.8.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8169,6 +9674,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8356,6 +9866,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -8427,6 +9958,49 @@ "node": ">= 0.8.15" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/rou3": { "version": "0.7.12", "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", @@ -8517,11 +10091,68 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "peer": true + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8752,7 +10383,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -8770,7 +10400,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -8802,6 +10431,26 @@ "node": ">=0.1.14" } }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9046,11 +10695,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", - "dev": true, - "license": "MIT", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "engines": { "node": ">=6" }, @@ -9059,6 +10706,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -9136,6 +10801,16 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9683,6 +11358,14 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "engines": { + "node": ">=8" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -9863,7 +11546,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9941,11 +11623,112 @@ "d3-timer": "^3.0.1" } }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/webpack": { + "version": "5.108.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", + "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10092,14 +11875,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 03bba16..57701f7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@anthropic-ai/sdk": "^0.110.0", "@aws-sdk/client-s3": "^3.1077.0", "@aws-sdk/s3-request-presigner": "^3.1078.0", "@fullcalendar/daygrid": "^6.1.21", @@ -22,6 +23,7 @@ "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@radix-ui/react-switch": "^1.2.6", + "@sentry/nextjs": "^10.63.0", "@types/papaparse": "^5.5.2", "better-auth": "^1.6.20", "clsx": "^2.1.1", diff --git a/proxy.ts b/proxy.ts index 760a958..325004c 100644 --- a/proxy.ts +++ b/proxy.ts @@ -28,6 +28,19 @@ const PROTECTED_PATHS = [ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"] +// Origin of the Sentry ingest endpoint, derived from the public DSN so the +// CSP stays in sync with whatever project/region the DSN points at. Returns +// null when Sentry is not configured. +function sentryIngestOrigin(): string | null { + const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN + if (!dsn) return null + try { + return new URL(dsn).origin + } catch { + return null + } +} + // Build the per-request Content-Security-Policy. `script-src` carries a // per-request nonce instead of 'unsafe-inline'. `style-src` keeps // 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline @@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"] // scripts automatically. function buildCsp(nonce: string): string { const isDev = process.env.NODE_ENV !== "production" + const sentry = sentryIngestOrigin() // In development, Next.js/React and Turbopack HMR require eval() for hot // reloading and debugging features, and open a dev websocket. These are NOT @@ -43,9 +57,14 @@ function buildCsp(nonce: string): string { const scriptSrc = isDev ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com` : `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com` - const connectSrc = isDev - ? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com" - : "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com" + const connectSrc = [ + "connect-src 'self'", + isDev ? "ws: wss:" : "", + "https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com", + sentry ?? "", + ] + .filter(Boolean) + .join(" ") return [ "default-src 'self'", @@ -54,13 +73,39 @@ function buildCsp(nonce: string): string { scriptSrc, "font-src 'self' data:", connectSrc, + // Sentry Session Replay spins up its compression worker from a blob: URL; + // without worker-src the browser falls back to script-src and blocks it. + "worker-src 'self' blob:", "frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", + "object-src 'none'", ].join("; ") } +// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off +// an auth page we confirm the session is actually alive — otherwise a stale +// cookie loops forever: /dashboard → /login (server sees no session) → +// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS. +// "unknown" (auth service unreachable / rate-limited) renders the auth page +// without touching cookies, which is safe in both directions. +async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> { + try { + const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin + const res = await fetch(new URL("/api/auth/get-session", base), { + headers: { cookie: request.headers.get("cookie") ?? "" }, + cache: "no-store", + }) + if (!res.ok) return "unknown" + // Better Auth returns JSON `null` when the session is missing or revoked. + const session = await res.json() + return session ? "valid" : "invalid" + } catch { + return "unknown" + } +} + export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname @@ -76,10 +121,18 @@ export async function proxy(request: NextRequest) { } const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p)) + let dropStaleSessionCookie = false if (isAuthPage && sessionCookie) { - const url = request.nextUrl.clone() - url.pathname = "/dashboard" - return NextResponse.redirect(url) + const state = await sessionState(request) + if (state === "valid") { + const url = request.nextUrl.clone() + url.pathname = "/dashboard" + return NextResponse.redirect(url) + } + // Dead cookie (session revoked or expired): render the auth page and drop + // the cookie below so protected paths stop treating this visitor as + // signed in. On "unknown", render the page but keep the cookie. + dropStaleSessionCookie = state === "invalid" } // Per-request CSP nonce. UUID contains only hex + dashes, so it never @@ -96,6 +149,21 @@ export async function proxy(request: NextRequest) { const response = NextResponse.next({ request: { headers: requestHeaders } }) // Also set the CSP on the outgoing response so the browser enforces it. response.headers.set("Content-Security-Policy", csp) + + if (dropStaleSessionCookie) { + // Covers both the plain and __Secure-prefixed Better Auth cookie names. + for (const cookie of request.cookies.getAll()) { + if (!cookie.name.includes("better-auth.session_token")) continue + response.cookies.set(cookie.name, "", { + maxAge: 0, + path: "/", + httpOnly: true, + sameSite: "lax", + secure: cookie.name.startsWith("__Secure-"), + }) + } + } + return response } diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..95083be Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..a31bbb1 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..9815f6e Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/paypal-setup-plans.mjs b/scripts/paypal-setup-plans.mjs deleted file mode 100644 index fbc6f00..0000000 --- a/scripts/paypal-setup-plans.mjs +++ /dev/null @@ -1,102 +0,0 @@ -// One-time PayPal setup: creates the product + the recurring billing plans and -// prints the plan IDs to paste into your environment. -// -// 1. Set PAYPAL_CLIENT_ID / PAYPAL_SECRET (and PAYPAL_ENVIRONMENT) in .env.local -// 2. node scripts/paypal-setup-plans.mjs -// 3. Copy the printed PAYPAL_*_PLAN_ID lines into .env.local / production env -// -// Amounts mirror the app's pricing (Pro $29/mo, Landlord $59/mo); yearly is -// billed at 10× monthly (~2 months free). Adjust in the PayPal dashboard if you -// want different annual pricing. Safe to re-run (it creates fresh plans). -import { config } from "dotenv" - -config({ path: ".env.local", quiet: true }) - -const ENV = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox" -const BASE = ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com" -const id = process.env.PAYPAL_CLIENT_ID -const secret = process.env.PAYPAL_SECRET - -if (!id || !secret) { - console.error("[paypal-setup] Set PAYPAL_CLIENT_ID and PAYPAL_SECRET in .env.local first.") - process.exit(1) -} - -async function getToken() { - const r = await fetch(`${BASE}/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 (!r.ok) throw new Error(`auth ${r.status}: ${await r.text()}`) - return (await r.json()).access_token -} - -const token = await getToken() -const post = (path, body) => - fetch(`${BASE}${path}`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - Prefer: "return=representation", - }, - body: JSON.stringify(body), - }) - -console.log(`[paypal-setup] Environment: ${ENV}`) - -const prodRes = await post("/v1/catalogs/products", { - name: "Property Management Network", - description: "Property Management Network subscription", - type: "SERVICE", - category: "SOFTWARE", -}) -if (!prodRes.ok) { - console.error("[paypal-setup] product creation failed:", await prodRes.text()) - process.exit(1) -} -const product = await prodRes.json() -console.log(`[paypal-setup] Product: ${product.id}`) - -const AMOUNTS = { pro: 29, landlord: 59 } -const envLines = [] - -for (const plan of ["pro", "landlord"]) { - for (const interval of ["month", "year"]) { - const amount = interval === "month" ? AMOUNTS[plan] : AMOUNTS[plan] * 10 - const res = await post("/v1/billing/plans", { - product_id: product.id, - name: `${plan[0].toUpperCase()}${plan.slice(1)} ${interval === "month" ? "Monthly" : "Yearly"}`, - status: "ACTIVE", - billing_cycles: [ - { - frequency: { interval_unit: interval === "month" ? "MONTH" : "YEAR", interval_count: 1 }, - tenure_type: "REGULAR", - sequence: 1, - total_cycles: 0, - pricing_scheme: { fixed_price: { value: amount.toFixed(2), currency_code: "USD" } }, - }, - ], - payment_preferences: { - auto_bill_outstanding: true, - setup_fee_failure_action: "CONTINUE", - payment_failure_threshold: 2, - }, - }) - if (!res.ok) { - console.error(`[paypal-setup] plan ${plan}/${interval} failed:`, await res.text()) - continue - } - const p = await res.json() - const key = `PAYPAL_${plan.toUpperCase()}_${interval === "month" ? "MONTHLY" : "YEARLY"}_PLAN_ID` - console.log(` ${plan}/${interval} $${amount} → ${p.id}`) - envLines.push(`${key}=${p.id}`) - } -} - -console.log("\n[paypal-setup] Add these to your environment:\n") -console.log(envLines.join("\n")) diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts new file mode 100644 index 0000000..3e969c4 --- /dev/null +++ b/sentry.edge.config.ts @@ -0,0 +1,13 @@ +// Sentry initialization for the Edge runtime (middleware / proxy.ts). +// Loaded by instrumentation.ts when NEXT_RUNTIME === "edge". +import * as Sentry from "@sentry/nextjs" + +const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN + +Sentry.init({ + dsn, + enabled: !!dsn, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, + sendDefaultPii: false, +}) diff --git a/sentry.server.config.ts b/sentry.server.config.ts new file mode 100644 index 0000000..d4f02fe --- /dev/null +++ b/sentry.server.config.ts @@ -0,0 +1,16 @@ +// Sentry initialization for the Node.js server runtime. +// Loaded by instrumentation.ts when NEXT_RUNTIME === "nodejs". +import * as Sentry from "@sentry/nextjs" + +const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN + +Sentry.init({ + dsn, + // Inert until a DSN is configured — safe to ship before Sentry is set up. + enabled: !!dsn, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + // Performance tracing — sample less in production to control volume/cost. + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, + // Don't attach PII (IP, cookies, request bodies) by default. + sendDefaultPii: false, +})