From 7d8b930bc35fd3a0b2b253aa3ccca081eb15483d Mon Sep 17 00:00:00 2001 From: moaljumaa Date: Sat, 1 Aug 2026 14:01:17 +0200 Subject: [PATCH] release: 2026-08-01 --- .dockerignore | 21 + .env.example | 20 + .gitignore | 73 + Dockerfile | 68 + LICENSE | 50 + README.md | 205 + VERSION | 1 + components/layout/Layout.tsx | 23 + components/layout/Sidebar.tsx | 316 + components/onboarding/TourGate.tsx | 33 + components/ui/FilterBar.tsx | 482 + components/ui/ModelPicker.tsx | 132 + docker-compose.yml | 39 + eslint.config.mjs | 18 + instrumentation.ts | 11 + lib/apollo.ts | 107 + lib/auth.ts | 49 + lib/crypto.ts | 43 + lib/csv-import.ts | 169 + lib/db.ts | 883 ++ lib/email/imap-simple.d.ts | 1 + lib/email/inbox.ts | 367 + lib/email/sender.ts | 103 + lib/import-jobs.ts | 289 + lib/linkedin/connect.ts | 89 + lib/linkedin/enrich.ts | 161 + lib/linkedin/li-stats.ts | 60 + lib/linkedin/message.ts | 138 + lib/linkedin/pending-invitations.ts | 61 + lib/linkedin/profile-scrape.ts | 298 + lib/linkedin/resolve-account.ts | 50 + lib/linkedin/runner.ts | 1422 +++ lib/linkedin/scraper.ts | 453 + lib/linkedin/session.ts | 519 + lib/linkedin/sync-accepted.ts | 256 + lib/linkedin/visit.ts | 48 + lib/premium.ts | 68 + lib/rate-limit.ts | 53 + lib/tour.ts | 302 + lib/update-check.ts | 88 + next.config.ts | 37 + package-lock.json | 10659 +++++++++++++++++ package.json | 56 + pages/_app.tsx | 46 + pages/_document.tsx | 17 + pages/api/accounts/[id]/authenticate.ts | 50 + pages/api/accounts/[id]/index.ts | 46 + pages/api/accounts/[id]/li-stats.ts | 36 + pages/api/accounts/[id]/login.ts | 46 + pages/api/accounts/[id]/sync-accepted.ts | 72 + pages/api/accounts/index.ts | 43 + pages/api/auth/[...nextauth].ts | 48 + pages/api/auth/change-password.ts | 39 + pages/api/auth/signup.ts | 48 + pages/api/companies/[id].ts | 45 + pages/api/companies/index.ts | 57 + pages/api/dashboard/agent-stats.ts | 38 + pages/api/dashboard/stats.ts | 170 + pages/api/email-accounts/[id]/index.ts | 78 + pages/api/email-accounts/[id]/send-test.ts | 42 + pages/api/email-accounts/[id]/test.ts | 68 + pages/api/email-accounts/index.ts | 74 + pages/api/email-health.ts | 96 + pages/api/hello.ts | 13 + pages/api/imports/[id]/cancel.ts | 21 + pages/api/imports/index.ts | 32 + pages/api/inbox/[replyId]/cancel-followup.ts | 46 + pages/api/inbox/index.ts | 119 + pages/api/inbox/reply.ts | 40 + pages/api/inbox/thread.ts | 143 + pages/api/integrations/index.ts | 47 + pages/api/lists/[id]/add-members.ts | 49 + pages/api/lists/[id]/analyze.ts | 35 + pages/api/lists/[id]/apollo-enrich.ts | 198 + pages/api/lists/[id]/conflicts.ts | 35 + pages/api/lists/[id]/csv-template.ts | 14 + pages/api/lists/[id]/enrich.ts | 51 + pages/api/lists/[id]/import-csv.ts | 24 + pages/api/lists/[id]/import-status.ts | 48 + pages/api/lists/[id]/import.ts | 48 + pages/api/lists/[id]/index.ts | 38 + pages/api/lists/[id]/move-targets.ts | 45 + pages/api/lists/[id]/remove-members.ts | 92 + pages/api/lists/[id]/sync-status.ts | 66 + pages/api/lists/[id]/targets.ts | 26 + pages/api/lists/index.ts | 33 + pages/api/premium-status.ts | 9 + pages/api/runs/[id]/enroll.ts | 124 + pages/api/runs/[id]/index.ts | 85 + pages/api/runs/[id]/remove.ts | 22 + pages/api/runs/[id]/retry.ts | 29 + pages/api/runs/[id]/start.ts | 22 + pages/api/runs/[id]/unenroll.ts | 30 + pages/api/runs/index.ts | 160 + pages/api/settings/import-cap.ts | 23 + pages/api/system/update.ts | 6 + pages/api/targets/[id].ts | 65 + pages/api/targets/[id]/emails.ts | 193 + pages/api/targets/[id]/profile-scrape.ts | 66 + pages/api/targets/[id]/runs.ts | 68 + pages/api/targets/index.ts | 232 + pages/api/templates/[id].ts | 29 + pages/api/templates/index.ts | 23 + pages/api/tour.ts | 29 + pages/api/workflows/[id]/analytics.ts | 190 + pages/api/workflows/[id]/duplicate.ts | 61 + pages/api/workflows/[id]/enrollments.ts | 63 + pages/api/workflows/[id]/index.ts | 54 + pages/api/workflows/[id]/prospects.ts | 204 + pages/api/workflows/[id]/stats.ts | 93 + pages/api/workflows/[id]/steps.ts | 67 + pages/api/workflows/[id]/steps/[stepId].ts | 31 + pages/api/workflows/index.ts | 30 + pages/companies/[id].tsx | 290 + pages/companies/index.tsx | 247 + pages/contacts/[id].tsx | 1336 +++ pages/contacts/index.tsx | 585 + pages/email-health.tsx | 269 + pages/inbox.tsx | 679 ++ pages/index.tsx | 704 ++ pages/lists/[id].tsx | 1071 ++ pages/lists/index.tsx | 326 + pages/login.tsx | 187 + pages/settings.tsx | 1781 +++ pages/workflows/[id].tsx | 3427 ++++++ pages/workflows/index.tsx | 503 + postcss.config.mjs | 7 + proxy.ts | 41 + public/deploy-with-opsily.svg | 21 + public/logo_linki.ico | Bin 0 -> 4286 bytes public/logo_linki.png | Bin 0 -> 11777 bytes public/logo_linki.svg | 16 + scripts/force-next-step.js | 153 + scripts/mirror-ee.js | 94 + scripts/strip-mirrored-pages.js | 52 + styles/globals.css | 126 + tsconfig.json | 29 + 137 files changed, 34695 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 VERSION create mode 100644 components/layout/Layout.tsx create mode 100644 components/layout/Sidebar.tsx create mode 100644 components/onboarding/TourGate.tsx create mode 100644 components/ui/FilterBar.tsx create mode 100644 components/ui/ModelPicker.tsx create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 instrumentation.ts create mode 100644 lib/apollo.ts create mode 100644 lib/auth.ts create mode 100644 lib/crypto.ts create mode 100644 lib/csv-import.ts create mode 100644 lib/db.ts create mode 100644 lib/email/imap-simple.d.ts create mode 100644 lib/email/inbox.ts create mode 100644 lib/email/sender.ts create mode 100644 lib/import-jobs.ts create mode 100644 lib/linkedin/connect.ts create mode 100644 lib/linkedin/enrich.ts create mode 100644 lib/linkedin/li-stats.ts create mode 100644 lib/linkedin/message.ts create mode 100644 lib/linkedin/pending-invitations.ts create mode 100644 lib/linkedin/profile-scrape.ts create mode 100644 lib/linkedin/resolve-account.ts create mode 100644 lib/linkedin/runner.ts create mode 100644 lib/linkedin/scraper.ts create mode 100644 lib/linkedin/session.ts create mode 100644 lib/linkedin/sync-accepted.ts create mode 100644 lib/linkedin/visit.ts create mode 100644 lib/premium.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/tour.ts create mode 100644 lib/update-check.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pages/_app.tsx create mode 100644 pages/_document.tsx create mode 100644 pages/api/accounts/[id]/authenticate.ts create mode 100644 pages/api/accounts/[id]/index.ts create mode 100644 pages/api/accounts/[id]/li-stats.ts create mode 100644 pages/api/accounts/[id]/login.ts create mode 100644 pages/api/accounts/[id]/sync-accepted.ts create mode 100644 pages/api/accounts/index.ts create mode 100644 pages/api/auth/[...nextauth].ts create mode 100644 pages/api/auth/change-password.ts create mode 100644 pages/api/auth/signup.ts create mode 100644 pages/api/companies/[id].ts create mode 100644 pages/api/companies/index.ts create mode 100644 pages/api/dashboard/agent-stats.ts create mode 100644 pages/api/dashboard/stats.ts create mode 100644 pages/api/email-accounts/[id]/index.ts create mode 100644 pages/api/email-accounts/[id]/send-test.ts create mode 100644 pages/api/email-accounts/[id]/test.ts create mode 100644 pages/api/email-accounts/index.ts create mode 100644 pages/api/email-health.ts create mode 100644 pages/api/hello.ts create mode 100644 pages/api/imports/[id]/cancel.ts create mode 100644 pages/api/imports/index.ts create mode 100644 pages/api/inbox/[replyId]/cancel-followup.ts create mode 100644 pages/api/inbox/index.ts create mode 100644 pages/api/inbox/reply.ts create mode 100644 pages/api/inbox/thread.ts create mode 100644 pages/api/integrations/index.ts create mode 100644 pages/api/lists/[id]/add-members.ts create mode 100644 pages/api/lists/[id]/analyze.ts create mode 100644 pages/api/lists/[id]/apollo-enrich.ts create mode 100644 pages/api/lists/[id]/conflicts.ts create mode 100644 pages/api/lists/[id]/csv-template.ts create mode 100644 pages/api/lists/[id]/enrich.ts create mode 100644 pages/api/lists/[id]/import-csv.ts create mode 100644 pages/api/lists/[id]/import-status.ts create mode 100644 pages/api/lists/[id]/import.ts create mode 100644 pages/api/lists/[id]/index.ts create mode 100644 pages/api/lists/[id]/move-targets.ts create mode 100644 pages/api/lists/[id]/remove-members.ts create mode 100644 pages/api/lists/[id]/sync-status.ts create mode 100644 pages/api/lists/[id]/targets.ts create mode 100644 pages/api/lists/index.ts create mode 100644 pages/api/premium-status.ts create mode 100644 pages/api/runs/[id]/enroll.ts create mode 100644 pages/api/runs/[id]/index.ts create mode 100644 pages/api/runs/[id]/remove.ts create mode 100644 pages/api/runs/[id]/retry.ts create mode 100644 pages/api/runs/[id]/start.ts create mode 100644 pages/api/runs/[id]/unenroll.ts create mode 100644 pages/api/runs/index.ts create mode 100644 pages/api/settings/import-cap.ts create mode 100644 pages/api/system/update.ts create mode 100644 pages/api/targets/[id].ts create mode 100644 pages/api/targets/[id]/emails.ts create mode 100644 pages/api/targets/[id]/profile-scrape.ts create mode 100644 pages/api/targets/[id]/runs.ts create mode 100644 pages/api/targets/index.ts create mode 100644 pages/api/templates/[id].ts create mode 100644 pages/api/templates/index.ts create mode 100644 pages/api/tour.ts create mode 100644 pages/api/workflows/[id]/analytics.ts create mode 100644 pages/api/workflows/[id]/duplicate.ts create mode 100644 pages/api/workflows/[id]/enrollments.ts create mode 100644 pages/api/workflows/[id]/index.ts create mode 100644 pages/api/workflows/[id]/prospects.ts create mode 100644 pages/api/workflows/[id]/stats.ts create mode 100644 pages/api/workflows/[id]/steps.ts create mode 100644 pages/api/workflows/[id]/steps/[stepId].ts create mode 100644 pages/api/workflows/index.ts create mode 100644 pages/companies/[id].tsx create mode 100644 pages/companies/index.tsx create mode 100644 pages/contacts/[id].tsx create mode 100644 pages/contacts/index.tsx create mode 100644 pages/email-health.tsx create mode 100644 pages/inbox.tsx create mode 100644 pages/index.tsx create mode 100644 pages/lists/[id].tsx create mode 100644 pages/lists/index.tsx create mode 100644 pages/login.tsx create mode 100644 pages/settings.tsx create mode 100644 pages/workflows/[id].tsx create mode 100644 pages/workflows/index.tsx create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 public/deploy-with-opsily.svg create mode 100644 public/logo_linki.ico create mode 100644 public/logo_linki.png create mode 100644 public/logo_linki.svg create mode 100644 scripts/force-next-step.js create mode 100644 scripts/mirror-ee.js create mode 100644 scripts/strip-mirrored-pages.js create mode 100644 styles/globals.css create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5772458 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +node_modules +.next +.git +*.db +*.db-shm +*.db-wal +data/ +# Exclude local/dev scripts, but KEEP the build-critical ones: the Docker build runs +# `npm run build` → prebuild → scripts/mirror-ee.js (open-core mirror; no-ops without ee/). +# strip-mirrored-pages.js is kept alongside it for consistency. See docs/OPEN_CORE.md. +scripts/* +!scripts/mirror-ee.js +!scripts/strip-mirrored-pages.js +mcp/ +playwright-browsers/ +.env* +npm-debug.log* +.deploy/ +.publish/ +docs/ +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..835f86b --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy this file to .env.local and fill in your values + +# NextAuth — generate secret with: openssl rand -base64 32 +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET= + +# Password to log in to the Linki UI +AUTH_PASSWORD= + +# Internal secret for server-to-server API calls (e.g. the MCP server calling Linki's own +# /api/* routes over loopback). Generate with: openssl rand -base64 32 +# Never sent to a browser or exposed externally — only Node processes on this host see it. +INTERNAL_API_SECRET= + +# SQLite database path (optional — defaults to ./linki.db in cwd) +# When running via Docker, this is set automatically to /data/linki.db +LINKI_DB_PATH= + +# Set to "false" to show the browser window during automation (useful for debugging) +HEADLESS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8a7fa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/mcp/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files — ignore real secrets, but allow the example template +.env +.env.local +.env.development +.env.production +credentials.json + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# SQLite database — contains personal data (LinkedIn cookies, lead lists) +*.db +*.db-shm +*.db-wal + +# Runtime log file +log.txt + +# Temporary / personal dev scripts — not useful to others +scripts/tmp/ + +# Playwright browser cache (downloaded automatically on first run) +/playwright-browsers + +# Docker volume mount — contains the SQLite DB when running via docker-compose +/data + +# open-core: premium pages are mirrored from ee/pages/ into pages/ by +# scripts/mirror-ee.js at predev/prebuild. The script writes a generated pages/.gitignore +# (read natively by git) that ignores the mirrored copies + manifest, so they are never +# tracked. Source of truth is ee/pages/**. NOTE: ee/ itself IS tracked in the private repo +# and is stripped only at publish time (.publish/), never via gitignore. See docs/OPEN_CORE.md. + +# credentials +# One-off handoff doc (Opsily GHCR access instructions) — keep local, not tracked +docs/OPSILY_GHCR_ACCESS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9533c53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# Base image PINNED to a digest. The browser fingerprint LinkedIn sees is derived +# from the Chromium build below, which depends on this base. A floating tag would +# let a rebuild silently pull a newer Debian + newer Chromium, changing the +# fingerprint and triggering a LinkedIn forced-logout (root cause of the Jun 2026 +# incident). Bump this digest only deliberately — it requires re-authenticating. +FROM node:22-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 + +# Chromium PINNED to an exact version for a stable browser fingerprint. +# Do NOT unpin. Any Chromium change must be a conscious decision — it changes the +# fingerprint and will log the LinkedIn session out (Jun 2026 incident, §15). +# +# We fetch Chromium from snapshot.debian.org (a frozen, immutable Debian archive) +# instead of the live mirror. The live mirror only keeps the newest 1-2 Chromium +# builds, so it ROTATED 149 out (and the current live build, 150.0.7871.46, is a +# broken build that SIGTRAPs on launch — do not use it). The snapshot archive keeps +# every version forever, so this pin builds reproducibly regardless of the live +# mirror. Snapshot is served over http:// on purpose (its TLS cert isn't in the +# base image's CA bundle; apt would silently fall back to the live mirror otherwise). +# All three chromium packages are pinned together — chromium depends on +# chromium-common/chromium-sandbox at the exact same version or apt errors. +RUN echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20260620T000000Z/ bookworm main" > /etc/apt/sources.list \ + && echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260620T000000Z/ bookworm-security main" >> /etc/apt/sources.list \ + && echo "Acquire::Check-Valid-Until false;" > /etc/apt/apt.conf.d/99snapshot \ + && apt-get -o Acquire::Retries=3 update && apt-get install -y --no-install-recommends \ + chromium=149.0.7827.155-1~deb12u1 \ + chromium-common=149.0.7827.155-1~deb12u1 \ + chromium-sandbox=149.0.7827.155-1~deb12u1 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +# Tell Playwright to use the system Chromium instead of downloading its own +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +ARG APP_VERSION=dev +ENV APP_VERSION=$APP_VERSION + +RUN npm run build + +# Data directory — mount a volume here to persist the SQLite DB +RUN mkdir -p /data && chown node:node /data +ENV LINKI_DB_PATH=/data/linki.db + +USER node + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83303a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,50 @@ +Linki Sustainable Use License + +Copyright (c) 2025 Mohammad Al Jumaa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to use, +copy, modify, merge, publish, and distribute the Software, subject to the +following conditions: + +--- + +## Permitted Uses + +You may use the Software for any purpose, including commercial purposes, provided +that you are not offering it as a managed hosting service to third parties. + +Specifically, you may: + +- Run the Software on your own infrastructure (local machine or VPS) for personal + or internal business use. +- Modify the Software and contribute changes back to the project. +- Use the Software to conduct outreach and automation for your own business. +- Offer consulting, support, or integration services that involve the Software, + as long as the Software itself is not the product being sold. + +## Prohibited Uses + +You may NOT: + +- Offer the Software as a hosted or managed service to third parties — meaning + you cannot provide access to a running instance of the Software to users who + did not set it up themselves, in exchange for payment or free of charge. +- Sublicense the Software for managed hosting purposes. + +**Exception:** Opsily (opsily.com) is the sole authorized managed hosting +provider for Linki. This exception is non-transferable. + +--- + +## Conditions + +Redistributions of source code must retain this license file in full. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..69e2e05 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +

+ Linki +

+ +

Linki

+

Open-source AI SDR for B2B outreach. LinkedIn sequences, cold email, and lead enrichment — self-hosted, no per-seat pricing.

+ +

+ + Deploy with Opsily + +   + + Join our Discord + +

+ +--- + +

+ ▶ Full demo  |  + https://youtu.be/S6n4RHULq3E +

+

+ + Click to watch the full demo on YouTube + +

+ +--- + +## What is Linki + +Linki is an open-source AI SDR built for B2B founders and sales teams who want full control over their outreach. You build multichannel campaigns — LinkedIn sequences, cold email, or both — enrich your leads, and run everything on your own server. Your data never leaves your machine. + +No SaaS middleman. No per-seat pricing. No black box. + +--- + +## Features + +### 📬 Multichannel Campaigns + +- **LinkedIn + email in one campaign**: run LinkedIn actions (visit, connect, message) and email actions in parallel within a single campaign sequence +- **Flexible step builder**: chain visit → connect → delay → message → cold email in any order, with configurable delays between steps +- **Per-lead state tracking**: see exactly where every lead is across both channels, with a live pipeline view broken down by step and status +- **A/B template pools**: assign multiple message templates to a step and rotate them automatically + +### 🔐 Server-Side LinkedIn Login + +- **Headless server-side authentication**: log in to LinkedIn directly on your server — no cookie-pasting — so the session is born under the exact browser fingerprint the automation runs with +- **Handles LinkedIn's real challenges**: email/SMS codes **and** mobile-app device approval, plus LinkedIn's dynamic React login fields +- **Longer, more stable sessions**: a pinned browser fingerprint keeps sessions alive far longer, enabling more complex and more frequent LinkedIn activity without forced logouts +- **Captures the full session**: including the httpOnly Sales Navigator seat cookie that cookie-paste can't reach — so Sales Nav import and enrichment just work + +### 🔍 Data & Enrichment + +- **Sales Navigator import**: paste a list URL and Linki pulls in all leads with name, title, company, location, seniority, and LinkedIn URL +- **CSV import**: bring in leads from anywhere else — a downloadable template covers LinkedIn URL, Sales Nav URL, email, and every contact field; each row just needs a LinkedIn URL and/or an email, so LinkedIn-only, email-only, and mixed lists all work +- **Batched & scheduled imports**: large lists split across days automatically under a global daily cap, with human-like pacing so imports never look like a bot burst +- **Apollo.io enrichment**: connect your Apollo API key and enrich any list with verified email addresses, company data, and seniority in one click +- **Sales Nav profile enrichment**: pull richer profile data (headline, positions) for better targeting, gathered at runner time to stay under the radar +- **Company model**: enriched company records (description, headcount, industry, location) linked from contacts; never duplicated across leads +- **Contact detail pages**: full profile view with outreach history, enrichment status, and all campaign activity per contact + +### 📥 Unified Inbox + +- **Aggregated reply feed**: all email replies from active campaigns surface in one inbox regardless of which email account received them +- **Email + LinkedIn reply detection**: the runner passively monitors both email and LinkedIn conversations and flags contacts who replied +- **Reply filtering**: only shows contacts who actually replied; noise-free by design +- **Inline reply composer**: read the full email thread and reply without leaving Linki + +### ⚡ Reliability & Safety + +- **Pinned browser fingerprint**: Chromium and its base image are version-pinned so a rebuild never changes the fingerprint LinkedIn sees — the single biggest cause of forced logouts, eliminated +- **63% improvement in connection reliability**: rewritten LinkedIn automation with smarter DOM targeting, clipboard-based message delivery, and graceful handling of LinkedIn's UI variants +- **Human-like import behavior**: lead list imports use randomized delays and pacing patterns to avoid triggering LinkedIn's bot detection +- **Email account ramp-up**: gradually increase sending volume on new email accounts to build sender reputation safely +- **Multiple accounts**: connect as many SMTP/IMAP email accounts and LinkedIn accounts as you need, each with its own daily limits +- **Daily limits & auto-reschedule**: set max connections and messages per day; when a limit is hit the runner reschedules work for the next day automatically instead of stopping the campaign + +### 📊 Analytics + +- **Campaign pipeline view**: funnel breakdown by step with prospect counts per stage; click any step to drill into the exact contacts at that point +- **Stats bar**: live counts for total prospects, in progress, completed, failed/skipped, connections sent, accepted, and messages sent +- **Acceptance rate**: tracks connection request → acceptance ratio per campaign +- **Dashboard overview**: cross-campaign summary of active runs, total contacts, recent activity + +--- + +## What's new + +- **CSV import** — bring your own leads from any source (a prior export, a website scrape, another tool). One template covers everything: LinkedIn URL, Sales Navigator URL, email, and every contact field — each row just needs a LinkedIn URL and/or an email +- **Generic inboxes supported** — role-based addresses (`info@`, `contact@`, `sales@…`) import as regular contacts, ready for email-only campaigns +- **Phone number field** — track a contact's phone alongside email and LinkedIn, editable inline on the contact page or via CSV/API +- **Bulk contact deletion** — permanently delete contacts (and their run history) from the Contacts page, with a confirmation step +- **Server-side headless LinkedIn login** — logs in on your server (email/SMS code **or** mobile-app approval), captures the full session incl. the Sales Navigator seat cookie, and unlocks longer, more frequent, more complex LinkedIn sessions +- **Pinned browser fingerprint** — Chromium + base image are version-pinned so rebuilds never trigger a forced logout +- **Batched & scheduled imports** — big Sales Nav lists split across days under a global daily cap with human-like pacing +- **Better reply sync** — reply detection for **both** email and LinkedIn, with accurate accepted-connection sync via LinkedIn's own APIs +- **Lead enrichment built in** — Apollo.io + Sales Nav profile enrichment, one-click on any list + +--- + +## Hosting options + +### One-click on Opsily (recommended) + +[Opsily](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki) is the easiest way to run Linki. Create a server, deploy Linki from the app store, and you get a live URL in under a minute: no terminal required. + +[![Deploy with Opsily](public/deploy-with-opsily.svg)](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki) + +### Self-host with Docker + +**1. Create your environment file** + +```bash +cp .env.example .env.local +``` + +Fill in `.env.local`: + +```env +# Public URL of the app (e.g. https://linki.yourdomain.com or http://localhost:3456) +NEXTAUTH_URL=http://localhost:3456 + +# Random secret: generate with: openssl rand -base64 32 +NEXTAUTH_SECRET=your_random_secret_here + +# Password to log in to the Linki UI +AUTH_PASSWORD=your_password_here +``` + +**2. Start the container** + +```bash +docker compose up -d +``` + +Or pull the image directly: + +```bash +docker run -d -p 3456:3000 \ + -e NEXTAUTH_URL=http://localhost:3456 \ + -e NEXTAUTH_SECRET=your_random_secret_here \ + -e AUTH_PASSWORD=your_password_here \ + -v $(pwd)/data:/data \ + moaljumaa/linki:latest +``` + +Linki is now running at `http://localhost:3456`. The SQLite database is persisted in `./data/linki.db` on your host machine. + +### Self-host manually (Node.js) + +Requires Node.js 22+. + +```bash +npm install +npm run build +npm start +``` + +--- + +## Setup + +### 1. Add a LinkedIn account + +Go to **Settings → LinkedIn** and add your account. Set conservative daily limits to start (recommended: 20 connections/day, 30 messages/day). + +### 2. Authenticate LinkedIn + +Click **Authenticate** and use **Server login** — Linki logs in on the server and walks you through LinkedIn's verification (email/SMS code or a tap in the LinkedIn mobile app). This captures a full, long-lived session, including the Sales Navigator seat needed for imports. + +### 3. Add email accounts (optional) + +Go to **Settings → Email** and add your SMTP/IMAP accounts. You can add as many as you need. Enable ramp-up on new accounts to build sender reputation gradually. + +### 4. Connect Apollo (optional) + +Go to **Settings → Integrations** and add your Apollo API key. Once connected, open any lead list and click **Enrich** to pull in verified emails and company data. + +### 5. Import a lead list + +Go to **Lists → New list** and paste a LinkedIn Sales Navigator list URL. Linki imports all leads with human-like pacing to avoid detection. + +> **Note:** A LinkedIn Sales Navigator subscription is required to import leads. + +### 6. Build and launch a campaign + +Go to **Workflows → New workflow**. Add your steps — LinkedIn actions, email steps, delays — write your messages (or use templates and A/B pools), then create a run and launch. + +--- + +## Managed edition + +Everything above is open-source and runs standalone. If you'd rather not manage servers — or want the AI features on top — the fully-managed edition on **[Opsily](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki)** adds an **AI agent that writes every message per-lead** (any model via OpenRouter), a **hosted MCP endpoint** to drive Linki from Claude / Cursor, **automatic AI reply handling**, and **Sales Navigator InMail**. [Deploy in a minute →](https://opsily.com/hosting/linki?utm_source=github&utm_medium=readme&utm_campaign=linki) + +--- + +## License + +Linki is source-available under the [Linki Sustainable Use License](LICENSE). + +**You can:** use it personally, use it for your business, self-host it on your own VPS or laptop, modify it, contribute to it. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..10c0880 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.7.4 diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx new file mode 100644 index 0000000..8360eb4 --- /dev/null +++ b/components/layout/Layout.tsx @@ -0,0 +1,23 @@ +import { ReactNode, useCallback } from "react"; +import { useRouter } from "next/router"; +import Sidebar from "./Sidebar"; +import TourGate from "@/components/onboarding/TourGate"; + +const NO_LAYOUT_PATHS = ["/login"]; + +export default function Layout({ children }: { children: ReactNode }) { + const router = useRouter(); + const handleCollapse = useCallback((_c: boolean) => {}, []); + + if (NO_LAYOUT_PATHS.includes(router.pathname)) { + return <>{children}; + } + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx new file mode 100644 index 0000000..22fd86c --- /dev/null +++ b/components/layout/Sidebar.tsx @@ -0,0 +1,316 @@ +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { signOut } from "next-auth/react"; +import { useEffect, useRef, useState } from "react"; +import { + RiLayoutGridLine, + RiFlowChart, + RiFileList3Line, + RiLogoutBoxLine, + RiUserSettingsLine, + RiArrowUpCircleLine, + RiBuildingLine, + RiContactsLine, + RiInboxLine, + RiMailCheckLine, + RiCheckboxCircleLine, + RiQuestionLine, + RiCompassLine, + RiPlayCircleLine, +} from "react-icons/ri"; +import { pathToTourPage, replayPageTour } from "@/lib/tour"; + +const LEARNING_PLAYLIST_URL = "https://www.youtube.com/playlist?list=PLBf6xNJOmsIQ"; + +const mainNav = [ + { href: "/", label: "Dashboard", icon: RiLayoutGridLine, color: "#5aa2ff", tour: "nav-dashboard" }, + { href: "/lists", label: "Lists", icon: RiFileList3Line, color: "#32d583", tour: "nav-lists" }, + { href: "/contacts", label: "Contacts", icon: RiContactsLine, color: "#34d399", tour: "nav-contacts" }, + { href: "/companies", label: "Companies", icon: RiBuildingLine, color: "#a78bfa", tour: "nav-companies" }, + { href: "/workflows", label: "Campaigns", icon: RiFlowChart, color: "#f4b740", tour: "nav-workflows" }, + { href: "/inbox", label: "Inbox", icon: RiInboxLine, color: "#38bdf8", tour: "nav-inbox" }, + { href: "/email-health", label: "Email Health", icon: RiMailCheckLine, color: "#f4b740", tour: "nav-email-health" }, +]; + +// Nav items that only exist in the commercial build (ee/). Filtered out when premium absent. +const premiumNav = [ + { href: "/todos", label: "Todos", icon: RiCheckboxCircleLine, color: "#fb923c", tour: "nav-todos" }, +]; + +export const SIDEBAR_WIDTH_EXPANDED = 52; +export const SIDEBAR_WIDTH_COLLAPSED = 52; + +export default function Sidebar({ onCollapse }: { onCollapse?: (collapsed: boolean) => void }) { + const router = useRouter(); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [latestVersion, setLatestVersion] = useState(null); + const [currentVersion, setCurrentVersion] = useState(null); + const [hovered, setHovered] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const helpRef = useRef(null); + const tourPage = pathToTourPage(router.pathname); + // Open-core: hide premium-only nav (Todos) in the public build. + const [hasPremium, setHasPremium] = useState(true); + const nav = hasPremium ? [...mainNav, ...premiumNav] : mainNav; + + useEffect(() => { + if (!helpOpen) return; + function onClick(e: MouseEvent) { + if (helpRef.current && !helpRef.current.contains(e.target as Node)) setHelpOpen(false); + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [helpOpen]); + + useEffect(() => { + onCollapse?.(false); + }, [onCollapse]); + + useEffect(() => { + fetch("/api/premium-status").then((r) => r.ok ? r.json() : null) + .then((d) => { if (d) setHasPremium(!!d.hasPremium); }).catch(() => {}); + }, []); + + useEffect(() => { + fetch("/api/system/update") + .then((r) => r.json()) + .then((d) => { + setCurrentVersion(d.current ?? null); + if (d.updateAvailable) { + setUpdateAvailable(true); + setLatestVersion(d.latest); + } + }) + .catch(() => {}); + }, []); + + function isActive(href: string) { + if (href === "/") return router.pathname === "/"; + if (href === "/settings") { + return ["/settings", "/accounts"].some((p) => router.pathname.startsWith(p)); + } + return router.pathname.startsWith(href); + } + + return ( + + ); +} diff --git a/components/onboarding/TourGate.tsx b/components/onboarding/TourGate.tsx new file mode 100644 index 0000000..061657c --- /dev/null +++ b/components/onboarding/TourGate.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { startPageTour, getSeenTours, pathToTourPage, type TourPage } from "@/lib/tour"; + +// Mounted once in Layout. Auto-starts the tour for the current page the first time +// the user lands on it (tracked server-side via /api/tour), one page at a time. +export default function TourGate() { + const router = useRouter(); + const [seen, setSeen] = useState | null>(null); + const startedRef = useRef>(new Set()); + + useEffect(() => { + getSeenTours().then(setSeen); + }, []); + + useEffect(() => { + if (!seen) return; + const page = pathToTourPage(router.pathname); + if (!page || seen.has(page) || startedRef.current.has(page)) return; + // Let the page finish its own data fetch / render before spotlighting elements. + // Guarded at fire-time (not schedule-time) so React StrictMode's dev double-effect + // (which would otherwise clear this timeout via the first run's cleanup) can't + // silently swallow the only scheduled start. + const t = setTimeout(() => { + if (startedRef.current.has(page)) return; + startedRef.current.add(page); + startPageTour(page); + }, 600); + return () => clearTimeout(t); + }, [seen, router.pathname]); + + return null; +} diff --git a/components/ui/FilterBar.tsx b/components/ui/FilterBar.tsx new file mode 100644 index 0000000..a07136a --- /dev/null +++ b/components/ui/FilterBar.tsx @@ -0,0 +1,482 @@ +import { useState, useRef, useEffect } from "react"; +import { RiAddLine, RiCloseLine, RiFilter3Line } from "react-icons/ri"; + +// ─── Field definitions ─────────────────────────────────────────────────────── + +export type FieldType = "enum" | "presence" | "boolean" | "number" | "text"; + +export interface FieldDef { + key: string; + label: string; + type: FieldType; + options?: { value: string; label: string }[]; +} + +export const FILTER_FIELDS: FieldDef[] = [ + { + key: "connection_status", + label: "Connection", + type: "enum", + options: [ + { value: "not_contacted", label: "Not contacted" }, + { value: "request_sent", label: "Request sent" }, + { value: "connected", label: "Connected (1st)" }, + { value: "messaged", label: "Messaged" }, + { value: "replied", label: "Replied" }, + ], + }, + { + key: "seniority", + label: "Seniority", + type: "enum", + options: [ + { value: "owner", label: "Owner" }, + { value: "founder", label: "Founder" }, + { value: "c_suite", label: "C-Suite" }, + { value: "partner", label: "Partner" }, + { value: "vp", label: "VP" }, + { value: "head", label: "Head" }, + { value: "director", label: "Director" }, + { value: "manager", label: "Manager" }, + { value: "senior", label: "Senior" }, + { value: "entry", label: "Entry" }, + { value: "intern", label: "Intern" }, + ], + }, + { + key: "email_status", + label: "Email status", + type: "enum", + options: [ + { value: "verified", label: "Verified" }, + { value: "unverified", label: "Unverified" }, + { value: "invalid", label: "Invalid" }, + { value: "catchall", label: "Catch-all" }, + ], + }, + { + key: "degree", + label: "Degree", + type: "enum", + options: [ + { value: "1", label: "1st" }, + { value: "2", label: "2nd" }, + { value: "3", label: "3rd" }, + ], + }, + { + key: "email", + label: "Email", + type: "presence", + }, + { + key: "apollo_enriched_at", + label: "Apollo enriched", + type: "presence", + }, + { + key: "connection_requested_at", + label: "Connection request", + type: "presence", + }, + { + key: "connected_at", + label: "Connected at", + type: "presence", + }, + { + key: "message_sent_at", + label: "Message sent", + type: "presence", + }, + { + key: "last_replied_at", + label: "Replied", + type: "presence", + }, + { + key: "open_link", + label: "Open link", + type: "boolean", + }, + { + key: "email_domain_catchall", + label: "Catch-all domain", + type: "boolean", + }, + { + key: "company_size", + label: "Company size", + type: "number", + }, + { + key: "tenure_months", + label: "Tenure (months)", + type: "number", + }, + { + key: "country", + label: "Country", + type: "text", + }, + { + key: "company_industry", + label: "Industry", + type: "text", + }, + { + key: "company", + label: "Company", + type: "text", + }, +]; + +// ─── Filter model ───────────────────────────────────────────────────────────── + +export type FilterOp = + | "is" | "is_not" // enum / text + | "contains" // text + | "is_set" | "is_not_set" // presence + | "is_true" | "is_false" // boolean + | "gt" | "lt"; // number + +export interface ActiveFilter { + id: string; + field: string; + op: FilterOp; + value?: string; +} + +function opLabel(op: FilterOp): string { + switch (op) { + case "is": return "is"; + case "is_not": return "is not"; + case "contains": return "contains"; + case "is_set": return "is set"; + case "is_not_set": return "is not set"; + case "is_true": return "is true"; + case "is_false": return "is false"; + case "gt": return ">"; + case "lt": return "<"; + } +} + +function opsForField(field: FieldDef): FilterOp[] { + switch (field.type) { + case "enum": return ["is", "is_not"]; + case "presence": return ["is_set", "is_not_set"]; + case "boolean": return ["is_true", "is_false"]; + case "number": return ["gt", "lt"]; + case "text": return ["contains", "is", "is_not"]; + } +} + +function defaultOp(field: FieldDef): FilterOp { + return opsForField(field)[0]; +} + +// ─── Chip ───────────────────────────────────────────────────────────────────── + +function FilterChip({ + filter, + onRemove, + onChange, +}: { + filter: ActiveFilter; + onRemove: () => void; + onChange: (updated: ActiveFilter) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const fieldDef = FILTER_FIELDS.find((f) => f.key === filter.field)!; + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + if (open) document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const ops = opsForField(fieldDef); + const needsValue = !["is_set", "is_not_set", "is_true", "is_false"].includes(filter.op); + + // Chip label + const valueLabel = + fieldDef.options?.find((o) => o.value === filter.value)?.label ?? filter.value ?? ""; + + return ( +
+ + + + {open && ( +
+ {/* Field label */} +
+ {fieldDef.label} +
+ + {/* Operator */} +
+ {ops.map((op) => ( + + ))} +
+ + {/* Value input */} + {needsValue && ( +
+ {fieldDef.type === "enum" && fieldDef.options ? ( +
+ {fieldDef.options.map((opt) => ( + + ))} +
+ ) : ( + onChange({ ...filter, value: e.target.value })} + onKeyDown={(e) => e.key === "Enter" && setOpen(false)} + className="w-full bg-base-300/50 border border-base-300/80 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50" + /> + )} +
+ )} +
+ )} +
+ ); +} + +// ─── Add filter dropdown ─────────────────────────────────────────────────────── + +function AddFilterButton({ onAdd, fields }: { onAdd: (field: string) => void; fields: FieldDef[] }) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const ref = useRef(null); + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + setSearch(""); + } + } + if (open) document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const filtered = fields.filter((f) => + f.label.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ + + {open && ( +
+
+ setSearch(e.target.value)} + className="w-full bg-base-300/50 border border-base-300/60 rounded-lg px-2.5 py-1.5 text-xs text-base-content placeholder:text-base-content/30 focus:outline-none focus:border-primary/50" + /> +
+
+ {filtered.length === 0 ? ( +
No fields found
+ ) : ( + filtered.map((f) => ( + + )) + )} +
+
+ )} +
+ ); +} + +// ─── FilterBar ──────────────────────────────────────────────────────────────── + +interface FilterBarProps { + filters: ActiveFilter[]; + onChange: (filters: ActiveFilter[]) => void; + fieldSubset?: string[]; +} + +export default function FilterBar({ filters, onChange, fieldSubset }: FilterBarProps) { + const visibleFields = fieldSubset + ? FILTER_FIELDS.filter((f) => fieldSubset.includes(f.key)) + : FILTER_FIELDS; + + function addFilter(fieldKey: string) { + const fieldDef = FILTER_FIELDS.find((f) => f.key === fieldKey)!; + const newFilter: ActiveFilter = { + id: Math.random().toString(36).slice(2), + field: fieldKey, + op: defaultOp(fieldDef), + }; + onChange([...filters, newFilter]); + } + + function updateFilter(id: string, updated: ActiveFilter) { + onChange(filters.map((f) => (f.id === id ? updated : f))); + } + + function removeFilter(id: string) { + onChange(filters.filter((f) => f.id !== id)); + } + + return ( +
+ {filters.length > 0 && ( + + + + )} + {filters.map((f) => ( + removeFilter(f.id)} + onChange={(updated) => updateFilter(f.id, updated)} + /> + ))} + + {filters.length > 0 && ( + + )} +
+ ); +} + +// ─── Client-side filter logic (for list detail page) ───────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function applyFiltersClient(items: T[], filters: ActiveFilter[]): T[] { + if (filters.length === 0) return items; + return items.filter((item) => filters.every((f) => matchesFilter(item as Record, f))); +} + +function matchesFilter(item: Record, filter: ActiveFilter): boolean { + // connection_status is a derived field — handle specially + if (filter.field === "connection_status") { + const degree = item.degree as number | null; + const crAt = item.connection_requested_at as string | null; + const msgAt = item.message_sent_at as string | null; + const repAt = item.last_replied_at as string | null; + + let status: string; + if (repAt) status = "replied"; + else if (msgAt) status = "messaged"; + else if (degree === 1) status = "connected"; + else if (crAt) status = "request_sent"; + else status = "not_contacted"; + + if (filter.op === "is") return status === filter.value; + if (filter.op === "is_not") return status !== filter.value; + return true; + } + + const raw = item[filter.field]; + + switch (filter.op) { + case "is_set": return raw !== null && raw !== undefined && raw !== ""; + case "is_not_set": return raw === null || raw === undefined || raw === ""; + case "is_true": return raw === 1 || raw === true || raw === "1"; + case "is_false": return raw === 0 || raw === false || raw === "0" || raw === null || raw === undefined; + case "is": return String(raw ?? "").toLowerCase() === String(filter.value ?? "").toLowerCase(); + case "is_not": return String(raw ?? "").toLowerCase() !== String(filter.value ?? "").toLowerCase(); + case "contains": return String(raw ?? "").toLowerCase().includes(String(filter.value ?? "").toLowerCase()); + case "gt": return Number(raw) > Number(filter.value ?? 0); + case "lt": return Number(raw) < Number(filter.value ?? 0); + default: return true; + } +} + +// ─── Server-side filter → query params (for contacts page) ─────────────────── + +export function filtersToParams(filters: ActiveFilter[]): URLSearchParams { + const params = new URLSearchParams(); + filters.forEach((f, i) => { + params.set(`f[${i}][field]`, f.field); + params.set(`f[${i}][op]`, f.op); + if (f.value !== undefined) params.set(`f[${i}][value]`, f.value); + }); + return params; +} diff --git a/components/ui/ModelPicker.tsx b/components/ui/ModelPicker.tsx new file mode 100644 index 0000000..25b0637 --- /dev/null +++ b/components/ui/ModelPicker.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { RiSearchLine, RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri"; + +export interface OrModel { id: string; name: string; provider: string; } + +const PROVIDER_DISPLAY: Record = { + google: "Google", + anthropic: "Anthropic", + openai: "OpenAI", + mistral: "Mistral", + qwen: "Qwen (Alibaba)", + alibaba: "Alibaba", +}; + +const PROVIDER_ORDER = ["google", "anthropic", "openai", "mistral", "qwen", "alibaba"]; + +interface ModelPickerProps { + models: OrModel[]; + value: string; + onChange: (id: string) => void; + placeholder?: string; +} + +export function ModelPicker({ models, value, onChange, placeholder = "Select a model…" }: ModelPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); + + const isSearching = search.trim().length > 0; + + const filtered = models.filter(m => + !isSearching || m.name.toLowerCase().includes(search.toLowerCase()) || m.id.toLowerCase().includes(search.toLowerCase()) + ); + + const byProvider: Record = {}; + for (const m of filtered) { + (byProvider[m.provider] ??= []).push(m); + } + + const providerOrder = [ + ...PROVIDER_ORDER.filter(p => byProvider[p]), + ...Object.keys(byProvider).filter(p => !PROVIDER_ORDER.includes(p)).sort(), + ]; + + const selectedModel = models.find(m => m.id === value); + + function toggleProvider(p: string) { + setCollapsedProviders(prev => { + const n = new Set(prev); + n.has(p) ? n.delete(p) : n.add(p); + return n; + }); + } + + return ( +
+ + + {open && ( + <> +
{ setOpen(false); setSearch(""); }} /> +
+
+ setSearch(e.target.value)} + className="w-full bg-base-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none placeholder:text-base-content/30" + /> +
+
+ {models.length === 0 ? ( +

No OpenRouter key configured

+ ) : filtered.length === 0 ? ( +

No models match

+ ) : providerOrder.map(provider => { + const providerModels = byProvider[provider]; + const isCollapsed = !isSearching && collapsedProviders.has(provider); + const displayName = PROVIDER_DISPLAY[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1); + return ( +
+ + {!isCollapsed && providerModels.map(m => ( + + ))} +
+ ); + })} +
+
+ + )} +
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4446692 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + linki: + image: moaljumaa/linki:latest + # To build from source instead: comment out image above and uncomment below + # build: . + ports: + - "127.0.0.1:${PORT:-3456}:3000" + volumes: + # Persist SQLite database outside the container + - ./data:/data + env_file: + - .env.local + environment: + # Runtime — always set by compose, do not put in .env.local + - NODE_ENV=production + - LINKI_DB_PATH=/data/linki.db + + # --- copy the block below into .env.local and fill in values --- + # + # Public URL of the app (used by NextAuth for redirects) + # NEXTAUTH_URL= + # + # Random secret for NextAuth session signing + # Generate with: openssl rand -base64 32 + # NEXTAUTH_SECRET= + # + # Password to log in to the Linki UI + # AUTH_PASSWORD= + # + # Set to "false" to show the browser during automation (debug only) + # HEADLESS=true + + restart: unless-stopped + # Caps Linki's CPU so a busy runner can't starve other containers on a + # small box. Raise/remove this if you have cores to spare. + deploy: + resources: + limits: + cpus: "1.5" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..6df491c --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,11 @@ +export async function register() { + // Only run on the Node.js server runtime, not in the browser/edge + if (process.env.NEXT_RUNTIME === "nodejs") { + try { + const { ensureGlobalRunnerStarted } = await import("@/lib/linkedin/runner"); + ensureGlobalRunnerStarted(); + } catch (err) { + console.error("[instrumentation] Failed to start runner:", err); + } + } +} diff --git a/lib/apollo.ts b/lib/apollo.ts new file mode 100644 index 0000000..4bb67b9 --- /dev/null +++ b/lib/apollo.ts @@ -0,0 +1,107 @@ +const APOLLO_BASE = "https://api.apollo.io/api/v1"; + +export interface ApolloMatchResult { + apollo_id: string; + linkedin_url: string | null; + headline: string | null; + seniority: string | null; + functions: string[] | null; + departments: string[] | null; + email: string | null; + email_status: string | null; + email_domain_catchall: boolean; + city: string | null; + country: string | null; + time_zone: string | null; + positions_json: string | null; + organization: { + name: string | null; + domain: string | null; + industry: string | null; + estimated_num_employees: number | null; + short_description: string | null; + location: string | null; + linkedin_url: string | null; + website_url: string | null; + founded_year: number | null; + logo_url: string | null; + phone: string | null; + annual_revenue_printed: string | null; + technology_names: string[] | null; + keywords: string[] | null; + city: string | null; + country: string | null; + } | null; +} + +export async function matchPerson( + linkedinUrl: string, + apiKey: string +): Promise { + const res = await fetch(`${APOLLO_BASE}/people/match`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Api-Key": apiKey }, + body: JSON.stringify({ linkedin_url: linkedinUrl, reveal_personal_emails: false }), + }); + + if (!res.ok) return null; + + const data = await res.json(); + const p = data.person; + if (!p || !p.id) return null; + + const org = p.organization ?? null; + const location = + org + ? [org.city, org.country].filter(Boolean).join(", ") || null + : null; + + // Map employment_history to positions_json format (same shape as Sales Nav enrichment) + let positions_json: string | null = null; + if (Array.isArray(p.employment_history) && p.employment_history.length > 0) { + const positions = p.employment_history.map((e: Record) => ({ + title: e.title ?? "", + companyName: e.organization_name ?? "", + current: e.current === true, + startDate: e.start_date ?? undefined, + endDate: e.end_date ?? undefined, + })); + positions_json = JSON.stringify(positions); + } + + return { + apollo_id: p.id, + linkedin_url: p.linkedin_url ?? null, + headline: p.headline ?? null, + seniority: p.seniority ?? null, + functions: Array.isArray(p.functions) && p.functions.length > 0 ? p.functions : null, + departments: Array.isArray(p.departments) && p.departments.length > 0 ? p.departments : null, + email: p.email ?? null, + email_status: p.email_status ?? null, + email_domain_catchall: p.email_domain_catchall === true, + city: p.city ?? null, + country: p.country ?? null, + time_zone: p.time_zone ?? null, + positions_json, + organization: org + ? { + name: org.name ?? null, + domain: org.primary_domain ?? null, + industry: org.industry ?? null, + estimated_num_employees: org.estimated_num_employees ?? null, + short_description: org.short_description ?? null, + location, + linkedin_url: org.linkedin_url ?? null, + website_url: org.website_url ?? null, + founded_year: org.founded_year ?? null, + logo_url: org.logo_url ?? null, + phone: org.phone ?? org.sanitized_phone ?? null, + annual_revenue_printed: org.annual_revenue_printed ?? null, + technology_names: Array.isArray(org.technology_names) && org.technology_names.length > 0 ? org.technology_names : null, + keywords: Array.isArray(org.keywords) && org.keywords.length > 0 ? org.keywords : null, + city: org.city ?? null, + country: org.country ?? null, + } + : null, + }; +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..68ee980 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,49 @@ +import { getToken } from "next-auth/jwt"; +import type { NextRequest } from "next/server"; + +const INTERNAL_HEADER = "x-internal-secret"; + +/** + * Shared request-auth check used by proxy.ts to gate /api/*. + * + * Accepts either: + * - a NextAuth session cookie (browser app) — verified via getToken(). + * - the internal service secret (server-to-server loopback calls, e.g. the MCP server's + * tool handlers calling Linki's own /api/* routes — see ee/mcp/server.ts). This secret + * never leaves the host: it's not sent to a browser and never crosses the public ngrok/ + * reverse-proxy path, only Node processes on 127.0.0.1 exchange it. + * + * Routes with their own independent auth (Authorization: Bearer, e.g. /api/mcp) are + * excluded from the gate entirely in proxy.ts rather than special-cased here. + */ +export async function isAuthenticated(req: NextRequest): Promise { + if (await hasValidInternalSecret(req)) return true; + + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + return token !== null; +} + +async function hasValidInternalSecret(req: NextRequest): Promise { + const expected = process.env.INTERNAL_API_SECRET; + if (!expected) return false; + + const provided = req.headers.get(INTERNAL_HEADER); + if (!provided) return false; + + return timingSafeEqual(provided, expected); +} + +// Constant-time string comparison via Web Crypto (available on both the Edge and Node.js +// runtimes) so an internal-secret guess can't be timed byte-by-byte. +async function timingSafeEqual(a: string, b: string): Promise { + const enc = new TextEncoder(); + const [digestA, digestB] = await Promise.all([ + crypto.subtle.digest("SHA-256", enc.encode(a)), + crypto.subtle.digest("SHA-256", enc.encode(b)), + ]); + const bytesA = new Uint8Array(digestA); + const bytesB = new Uint8Array(digestB); + let diff = 0; + for (let i = 0; i < bytesA.length; i++) diff |= bytesA[i] ^ bytesB[i]; + return diff === 0; +} diff --git a/lib/crypto.ts b/lib/crypto.ts new file mode 100644 index 0000000..c4d9a42 --- /dev/null +++ b/lib/crypto.ts @@ -0,0 +1,43 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto"; + +// Encrypts secrets at rest (LinkedIn session cookies, email passwords, API keys) so a +// leaked/copied linki.db doesn't hand those out in plaintext. Key is derived from +// NEXTAUTH_SECRET (already a required, real secret) via HKDF — no new env var needed. +// +// Format: "v1:::". decryptSecret() passes +// any value NOT in this format straight through unchanged, so pre-migration plaintext +// rows keep working — see lib/db.ts's encryptLegacySecretsMigration. + +const VERSION = "v1"; +const ALGORITHM = "aes-256-gcm"; + +function deriveKey(): Buffer { + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) throw new Error("NEXTAUTH_SECRET must be set to encrypt/decrypt stored secrets"); + return Buffer.from(hkdfSync("sha256", secret, "", "linki-secret-encryption", 32)); +} + +export function encryptSecret(plaintext: string): string { + const key = deriveKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [VERSION, iv.toString("base64"), authTag.toString("base64"), ciphertext.toString("base64")].join(":"); +} + +export function decryptSecret(value: string | null): string | null { + if (value === null) return null; + if (!isEncrypted(value)) return value; // not-yet-migrated plaintext — pass through + + const [, ivB64, authTagB64, dataB64] = value.split(":"); + const key = deriveKey(); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, "base64")); + decipher.setAuthTag(Buffer.from(authTagB64, "base64")); + const plaintext = Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]); + return plaintext.toString("utf8"); +} + +export function isEncrypted(value: string): boolean { + return value.startsWith(`${VERSION}:`); +} diff --git a/lib/csv-import.ts b/lib/csv-import.ts new file mode 100644 index 0000000..61921cb --- /dev/null +++ b/lib/csv-import.ts @@ -0,0 +1,169 @@ +import Papa from "papaparse"; +import type DatabaseType from "better-sqlite3"; +import { randomUUID } from "crypto"; + +type DB = DatabaseType.Database; + +// User-fillable target fields — everything else on `targets` (URNs, JSON blobs, +// enrichment timestamps, apollo/automation internals) is system-owned and not +// importable. Mirrors the PATCH /api/targets/[id] editable set. +const EDITABLE_FIELDS = [ + "first_name", "last_name", "title", "company", "location", + "city", "country", "phone", "headline", "summary", "notes", +] as const; +type EditableField = (typeof EDITABLE_FIELDS)[number]; + +// One template covers every case: a pure LinkedIn list, a pure email list (incl. +// generic inboxes like info@company.com), or an export that already has both +// (e.g. a list exported from another tool). Each row just needs linkedin_url +// and/or email filled in — whatever the contact actually has. +const TEMPLATE_COLUMNS = ["linkedin_url", "sales_nav_url", "email", ...EDITABLE_FIELDS] as const; + +const SAMPLE_VALUES: Record = { + first_name: "Jane", + last_name: "Doe", + title: "Head of Marketing", + company: "Acme Inc", + location: "Berlin, Germany", + city: "Berlin", + country: "Germany", + phone: "+49 30 1234567", + headline: "Head of Marketing @ Acme Inc", + summary: "10+ years in B2B SaaS marketing.", + notes: "Met at SaaStr 2026", +}; + +export function buildCsvTemplate(): string { + const sample = TEMPLATE_COLUMNS.map((c) => + c === "linkedin_url" ? "https://www.linkedin.com/in/example-profile/" : + c === "sales_nav_url" ? "" : + c === "email" ? "jane@acme.com" : + SAMPLE_VALUES[c as EditableField] + ); + return Papa.unparse({ fields: [...TEMPLATE_COLUMNS], data: [sample] }); +} + +export interface CsvImportResult { + imported: number; + updated: number; + skipped: number; + errors: string[]; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeLinkedinUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed || !trimmed.includes("linkedin.com/in/")) return null; + return trimmed; +} + +interface ParsedRow { + linkedin_url: string | null; + sales_nav_url: string | null; + email: string | null; + full_name: string | null; + fields: Record; +} + +function get(row: Record, key: string): string | null { + const v = row[key]; + const t = typeof v === "string" ? v.trim() : ""; + return t.length > 0 ? t : null; +} + +export function importCsv(db: DB, listId: string, csvText: string): CsvImportResult { + const parsed = Papa.parse>(csvText, { + header: true, + skipEmptyLines: true, + transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, "_"), + }); + + const errors: string[] = []; + let imported = 0; + let updated = 0; + let skipped = 0; + + const rows: ParsedRow[] = []; + parsed.data.forEach((raw, idx) => { + const rowNum = idx + 2; // header is row 1 + const fields = Object.fromEntries(EDITABLE_FIELDS.map((f) => [f, get(raw, f)])) as Record; + const full_name = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || null; + + const rawUrl = get(raw, "linkedin_url"); + const linkedin_url = rawUrl ? normalizeLinkedinUrl(rawUrl) : null; + if (rawUrl && !linkedin_url) { errors.push(`Row ${rowNum}: "${rawUrl}" is not a valid linkedin.com/in/ URL`); return; } + + const sales_nav_url = get(raw, "sales_nav_url"); + + const rawEmail = get(raw, "email"); + let email: string | null = null; + if (rawEmail) { + if (!EMAIL_RE.test(rawEmail)) { errors.push(`Row ${rowNum}: "${rawEmail}" is not a valid email`); return; } + email = rawEmail.toLowerCase(); + } + + if (!linkedin_url && !email) { + errors.push(`Row ${rowNum}: needs at least a linkedin_url or an email`); + return; + } + + rows.push({ linkedin_url, sales_nav_url, email, full_name, fields }); + }); + + const editableCols = [...EDITABLE_FIELDS, "full_name", "sales_nav_url"] as const; + + const insertByLinkedin = db.prepare(` + INSERT INTO targets (id, linkedin_url, email, sales_nav_url, full_name, ${EDITABLE_FIELDS.join(", ")}) + VALUES (?, ?, ?, ?, ?, ${EDITABLE_FIELDS.map(() => "?").join(", ")}) + ON CONFLICT(linkedin_url) DO UPDATE SET + email = COALESCE(excluded.email, targets.email), + ${editableCols.map((c) => `${c} = COALESCE(excluded.${c}, targets.${c})`).join(",\n ")} + `); + const findByLinkedin = db.prepare("SELECT id FROM targets WHERE linkedin_url = ?"); + const findByEmail = db.prepare("SELECT id FROM targets WHERE email = ? LIMIT 1"); + const insertByEmail = db.prepare(` + INSERT INTO targets (id, email, full_name, ${EDITABLE_FIELDS.join(", ")}, sales_nav_url) + VALUES (?, ?, ?, ${EDITABLE_FIELDS.map(() => "?").join(", ")}, ?) + `); + const updateByEmail = db.prepare(` + UPDATE targets SET + ${editableCols.map((c) => `${c} = COALESCE(?, ${c})`).join(",\n ")} + WHERE id = ? + `); + const linkToList = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)"); + + db.transaction(() => { + for (const row of rows) { + let targetId: string; + let isNew: boolean; + const fieldValues = EDITABLE_FIELDS.map((f) => row.fields[f]); + + if (row.linkedin_url) { + const existing = findByLinkedin.get(row.linkedin_url) as { id: string } | undefined; + isNew = !existing; + targetId = existing?.id ?? randomUUID(); + insertByLinkedin.run(targetId, row.linkedin_url, row.email, row.sales_nav_url, row.full_name, ...fieldValues); + } else { + const existing = findByEmail.get(row.email) as { id: string } | undefined; + isNew = !existing; + if (existing) { + targetId = existing.id; + updateByEmail.run(...fieldValues, row.full_name, row.sales_nav_url, targetId); + } else { + targetId = randomUUID(); + insertByEmail.run(targetId, row.email, row.full_name, ...fieldValues, row.sales_nav_url); + } + } + + const linkResult = linkToList.run(listId, targetId); + if (linkResult.changes > 0) { + if (isNew) imported++; else updated++; + } else { + skipped++; // already in this list, no changes + } + } + })(); + + return { imported, updated, skipped, errors }; +} diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..a0a8a54 --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,883 @@ +import Database from "better-sqlite3"; +import path from "path"; +import { randomUUID } from "crypto"; +import { scheduleUpdateCheck } from "@/lib/update-check"; +import { encryptSecret, isEncrypted } from "@/lib/crypto"; + +const DB_PATH = process.env.LINKI_DB_PATH || path.join(process.cwd(), "linki.db"); + +let db: Database.Database; + +export function getDb(): Database.Database { + if (!db) { + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initDb(db); + runMigrations(db); + scheduleUpdateCheck(); + } + return db; +} + +function runParallelTracksMigration(db: Database.Database) { + // This backfill reads the legacy run_profiles.state column. If that column no longer + // exists, dropDeprecatedRunProfileColumns has already run (a prior startup) and this + // migration is moot — skip, otherwise the SELECT rp.state below throws "no such column". + // (Fresh DBs hit this: state is created, dropped on one startup, then this guard would + // otherwise re-enter on the next because no email-tracked steps exist yet.) + try { + const rpCols = db.prepare("PRAGMA table_info(run_profiles)").all() as { name: string }[]; + if (!rpCols.some(c => c.name === "state")) return; + } catch { return; } + + // Idempotent: skip if run_profile_tracks already has rows or workflow_steps already has email-tracked rows + try { + const alreadyRun = (db.prepare("SELECT COUNT(*) as c FROM run_profile_tracks").get() as { c: number }).c > 0 + || (db.prepare("SELECT COUNT(*) as c FROM workflow_steps WHERE track = 'email'").get() as { c: number }).c > 0; + if (alreadyRun) return; + } catch { return; } + + db.transaction(() => { + // 1. Assign email step_type rows to the email track + db.exec("UPDATE workflow_steps SET track = 'email' WHERE step_type = 'email'"); + // Also assign delay steps to the email track if the next non-delay step after them is an email step. + // Without this, delays between email steps default to 'linkedin' and create a ghost linkedin track. + db.exec(` + UPDATE workflow_steps SET track = 'email' + WHERE step_type = 'delay' + AND ( + SELECT step_type FROM workflow_steps ws2 + WHERE ws2.workflow_id = workflow_steps.workflow_id + AND ws2.step_order > workflow_steps.step_order + AND ws2.step_type != 'delay' + ORDER BY ws2.step_order ASC + LIMIT 1 + ) = 'email' + `); + + // 2. Re-number step_order densely within each (workflow_id, track), preserving original order + const stepGroups = db.prepare( + "SELECT id, workflow_id, track, step_order FROM workflow_steps ORDER BY workflow_id, track, step_order" + ).all() as Array<{ id: string; workflow_id: string; track: string; step_order: number }>; + + // Group by (workflow_id, track) and assign dense 1-based order + const grouped = new Map>(); + for (const row of stepGroups) { + const key = `${row.workflow_id}|${row.track}`; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push({ id: row.id, step_order: row.step_order }); + } + const updateStep = db.prepare("UPDATE workflow_steps SET step_order = ? WHERE id = ?"); + for (const steps of grouped.values()) { + steps.sort((a, b) => a.step_order - b.step_order); + steps.forEach((s, i) => updateStep.run(i + 1, s.id)); + } + + // 3. Backfill run_profile_tracks from existing run_profiles + // Only backfill if there are run_profiles rows to process + const allProfiles = db.prepare( + `SELECT rp.id, rp.run_id, rp.state, rp.current_step, rp.next_step_at, + rp.error_message, rp.last_email_subject, rp.last_email_body, rp.last_linkedin_message, + r.workflow_id + FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id` + ).all() as Array<{ + id: string; run_id: string; state: string; current_step: number; + next_step_at: string | null; error_message: string | null; + last_email_subject: string | null; last_email_body: string | null; + last_linkedin_message: string | null; workflow_id: string; + }>; + + if (allProfiles.length === 0) return; + + // Load all workflow steps grouped by workflow_id, preserving their original ordering + // We need the ORIGINAL step_order to map legacy current_step (0-based flat index) to tracks. + // After the re-numbering above, step_order is now per-track. We stored the original order as the + // sort key inside stepGroups above. Rebuild a per-workflow flat list from the original ordering. + const workflowStepsOrig = db.prepare( + "SELECT id, workflow_id, track, step_order FROM workflow_steps ORDER BY workflow_id, step_order" + ).all() as Array<{ id: string; workflow_id: string; track: string; step_order: number }>; + + // For each workflow, build the flat list of steps in their original order (by new step_order within track, then track order linkedin < email) + // BUT: we need the ORIGINAL flat order before re-numbering. Since we already re-numbered, we have to reconstruct. + // Approach: the original flat step_order was cross-track. The re-numbered step_order is per-track and starts at 1. + // We stored the original step_order in stepGroups (before modification). Use that. + const origStepOrderMap = new Map(); // step id → original flat step_order + for (const row of stepGroups) { + origStepOrderMap.set(row.id, row.step_order); + } + + // Per workflow: flat list of steps sorted by original step_order + const workflowFlatSteps = new Map>(); + for (const step of workflowStepsOrig) { + if (!workflowFlatSteps.has(step.workflow_id)) workflowFlatSteps.set(step.workflow_id, []); + workflowFlatSteps.get(step.workflow_id)!.push({ + id: step.id, + track: step.track, + orig_order: origStepOrderMap.get(step.id) ?? step.step_order, + }); + } + for (const steps of workflowFlatSteps.values()) { + steps.sort((a, b) => a.orig_order - b.orig_order); + } + + // Per workflow: which tracks exist + const workflowTracks = new Map>(); + for (const step of workflowStepsOrig) { + if (!workflowTracks.has(step.workflow_id)) workflowTracks.set(step.workflow_id, new Set()); + workflowTracks.get(step.workflow_id)!.add(step.track); + } + + const insertTrack = db.prepare(` + INSERT OR IGNORE INTO run_profile_tracks + (id, run_profile_id, track, state, current_step, last_step_at, next_step_at, + error_message, last_email_subject, last_email_body, last_linkedin_message) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + `); + + for (const rp of allProfiles) { + const flatSteps = workflowFlatSteps.get(rp.workflow_id) ?? []; + const tracks = workflowTracks.get(rp.workflow_id) ?? new Set(["linkedin"]); + const terminalStates = new Set(["completed", "failed", "skipped"]); + + if (terminalStates.has(rp.state)) { + // Terminal profile — mark all tracks with the same terminal state + for (const track of tracks) { + insertTrack.run( + randomUUID(), rp.id, track, rp.state, 0, + null, rp.error_message ?? null, null, null, null + ); + } + continue; + } + + // Active profile — compute per-track completed step count + // legacy current_step is 0-based index into the flat step list = steps already completed + const completedCount = rp.current_step; + const completedSteps = flatSteps.slice(0, completedCount); + + const trackCompletedCount = new Map(); + for (const track of tracks) trackCompletedCount.set(track, 0); + for (const s of completedSteps) { + trackCompletedCount.set(s.track, (trackCompletedCount.get(s.track) ?? 0) + 1); + } + + // Which track owned the step we were waiting on (index = completedCount)? + const currentFlatStep = flatSteps[completedCount]; + const waitingTrack = currentFlatStep?.track ?? null; + + for (const track of tracks) { + const trackCurrentStep = trackCompletedCount.get(track) ?? 0; + // next_step_at: only carry over to the track that was waiting; other track starts immediately + const nextStepAt = track === waitingTrack ? rp.next_step_at : null; + const lastEmailSubject = track === "email" ? rp.last_email_subject : null; + const lastEmailBody = track === "email" ? rp.last_email_body : null; + const lastLinkedinMessage = track === "linkedin" ? rp.last_linkedin_message : null; + + insertTrack.run( + randomUUID(), rp.id, track, rp.state, trackCurrentStep, + nextStepAt, rp.error_message ?? null, + lastEmailSubject, lastEmailBody, lastLinkedinMessage + ); + } + } + })(); +} + +function dropDeprecatedRunProfileColumns(db: Database.Database) { + // Idempotent: check if state column still exists on run_profiles + try { + const tableInfo = db.prepare("PRAGMA table_info(run_profiles)").all() as { name: string }[]; + const hasState = tableInfo.some(c => c.name === "state"); + if (!hasState) return; // already dropped + } catch { return; } + + // SQLite requires a table rebuild to drop columns + try { + db.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE run_profiles_new ( + id TEXT PRIMARY KEY, + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + target_id TEXT REFERENCES targets(id), + email_account_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(run_id, target_id) + ); + INSERT INTO run_profiles_new (id, run_id, target_id, email_account_id, created_at) + SELECT id, run_id, target_id, email_account_id, created_at FROM run_profiles; + DROP TABLE run_profiles; + ALTER TABLE run_profiles_new RENAME TO run_profiles; + PRAGMA foreign_keys = ON; + `); + } catch { /* ignore — may already be done */ } +} + +function runMigrations(db: Database.Database) { + // Add columns introduced after initial schema — safe to run on existing DBs + const migrations = [ + "ALTER TABLE targets ADD COLUMN degree INTEGER", + "ALTER TABLE targets ADD COLUMN connection_requested_at TEXT", + "ALTER TABLE targets ADD COLUMN connected_at TEXT", + "ALTER TABLE targets ADD COLUMN message_sent_at TEXT", + "ALTER TABLE targets ADD COLUMN last_replied_at TEXT", + "ALTER TABLE targets ADD COLUMN linkedin_member_urn TEXT", + "ALTER TABLE targets ADD COLUMN sales_nav_url TEXT", + "ALTER TABLE lists ADD COLUMN sales_nav_url TEXT", + "ALTER TABLE accounts ADD COLUMN inbox_synced_at TEXT", + "ALTER TABLE accounts ADD COLUMN active_hours_start INTEGER DEFAULT 9", + "ALTER TABLE accounts ADD COLUMN active_hours_end INTEGER DEFAULT 18", + "ALTER TABLE accounts ADD COLUMN timezone TEXT DEFAULT 'UTC'", + "ALTER TABLE accounts ADD COLUMN working_days TEXT DEFAULT '1,2,3,4,5'", + "ALTER TABLE workflow_steps ADD COLUMN connect_note TEXT", + "ALTER TABLE workflow_steps ADD COLUMN message_body TEXT", + "ALTER TABLE targets ADD COLUMN headline TEXT", + "ALTER TABLE targets ADD COLUMN summary TEXT", + "ALTER TABLE accounts ADD COLUMN accepted_sync_at TEXT", + // Messaging identity (fsd_profile URN, "urn:li:fsd_profile:ACoAA...") — the + // form the messaging GraphQL API returns. It is NOT convertible to the + // numeric urn:li:member we store elsewhere, so we capture it on first reply + // (matched by name) and then join by it directly on every later sync. + "ALTER TABLE targets ADD COLUMN messaging_urn TEXT", + "CREATE INDEX IF NOT EXISTS idx_targets_messaging_urn ON targets(messaging_urn)", + // Boundary for the incremental connections sync: the createdAt of the newest + // connection seen last run. NULL = never synced (first run does a full pass). + "ALTER TABLE accounts ADD COLUMN connections_synced_through_ms INTEGER", + "ALTER TABLE accounts ADD COLUMN li_connections INTEGER", + "ALTER TABLE accounts ADD COLUMN li_pending INTEGER", + "ALTER TABLE accounts ADD COLUMN li_profile_views INTEGER", + "ALTER TABLE accounts ADD COLUMN li_stats_synced_at TEXT", + `CREATE TABLE IF NOT EXISTS workflow_step_templates ( + step_id TEXT REFERENCES workflow_steps(id) ON DELETE CASCADE, + template_id TEXT REFERENCES templates(id) ON DELETE CASCADE, + PRIMARY KEY (step_id, template_id) + )`, + // Extended lead data from salesApiLeadSearch + "ALTER TABLE targets ADD COLUMN object_urn TEXT", + "ALTER TABLE targets ADD COLUMN open_link INTEGER DEFAULT 0", + "ALTER TABLE targets ADD COLUMN company_industry TEXT", + "ALTER TABLE targets ADD COLUMN company_location TEXT", + "ALTER TABLE targets ADD COLUMN tenure_months INTEGER", + "ALTER TABLE targets ADD COLUMN spotlight_badges TEXT", + // Profile enrichment — populated by visiting their Sales Nav profile page + "ALTER TABLE targets ADD COLUMN positions_json TEXT", + "ALTER TABLE targets ADD COLUMN skills_json TEXT", + "ALTER TABLE targets ADD COLUMN enriched_profile_at TEXT", + // Email outreach fields + "ALTER TABLE targets ADD COLUMN email TEXT", + "ALTER TABLE targets ADD COLUMN email_replied_at TEXT", + "ALTER TABLE targets ADD COLUMN company_id TEXT", + // Email account on runs (nullable — only needed when workflow has email steps) + "ALTER TABLE runs ADD COLUMN email_account_id TEXT REFERENCES email_accounts(id)", + // Workflow step email fields + "ALTER TABLE workflow_steps ADD COLUMN email_subject TEXT", + "ALTER TABLE workflow_steps ADD COLUMN email_body TEXT", + // Apollo enrichment fields + "ALTER TABLE targets ADD COLUMN apollo_id TEXT", + "ALTER TABLE targets ADD COLUMN seniority TEXT", + "ALTER TABLE targets ADD COLUMN apollo_functions TEXT", + "ALTER TABLE targets ADD COLUMN company_description TEXT", + "ALTER TABLE targets ADD COLUMN company_size INTEGER", + "ALTER TABLE targets ADD COLUMN apollo_enriched_at TEXT", + "ALTER TABLE targets ADD COLUMN email_status TEXT", + // Manual fields + "ALTER TABLE targets ADD COLUMN notes TEXT", + // Apollo extra person fields + "ALTER TABLE targets ADD COLUMN city TEXT", + "ALTER TABLE targets ADD COLUMN country TEXT", + "ALTER TABLE targets ADD COLUMN time_zone TEXT", + "ALTER TABLE targets ADD COLUMN apollo_departments TEXT", + // Apollo extra company fields on companies table + "ALTER TABLE companies ADD COLUMN founded_year INTEGER", + "ALTER TABLE companies ADD COLUMN logo_url TEXT", + "ALTER TABLE companies ADD COLUMN phone TEXT", + "ALTER TABLE companies ADD COLUMN annual_revenue TEXT", + "ALTER TABLE companies ADD COLUMN technology_names TEXT", + "ALTER TABLE companies ADD COLUMN keywords TEXT", + "ALTER TABLE companies ADD COLUMN city TEXT", + "ALTER TABLE companies ADD COLUMN country TEXT", + // Email signature + "ALTER TABLE email_accounts ADD COLUMN signature TEXT", + // Reply-To override — if set, outgoing emails include Reply-To header + "ALTER TABLE email_accounts ADD COLUMN reply_to TEXT", + // Ramp-up: start slow and increase sending volume over time + "ALTER TABLE email_accounts ADD COLUMN ramp_up_enabled INTEGER DEFAULT 1", + "ALTER TABLE email_accounts ADD COLUMN ramp_start_date TEXT", + // Company description and employee count moved from targets to companies + "ALTER TABLE companies ADD COLUMN description TEXT", + "ALTER TABLE companies ADD COLUMN employee_count INTEGER", + // AI agent columns on workflow steps + "ALTER TABLE workflow_steps ADD COLUMN ai_enabled INTEGER DEFAULT 0", + "ALTER TABLE workflow_steps ADD COLUMN ai_model TEXT", + "ALTER TABLE workflow_steps ADD COLUMN ai_prompt TEXT", + "ALTER TABLE workflow_steps ADD COLUMN ai_max_words INTEGER", + // Email step position — which followup number (1 = cold email, 2 = first followup, etc.) + "ALTER TABLE workflow_steps ADD COLUMN email_position INTEGER DEFAULT 1", + // Agent default model (stored on agent_config) + "ALTER TABLE agent_config ADD COLUMN default_model TEXT", + // Email threading — store sent email message-id for reply threading (future use) + "ALTER TABLE run_profiles ADD COLUMN last_email_subject TEXT", + "ALTER TABLE run_profiles ADD COLUMN last_email_body TEXT", + // LinkedIn message follow-up tracking + "ALTER TABLE workflow_steps ADD COLUMN message_position INTEGER DEFAULT 1", + "ALTER TABLE run_profiles ADD COLUMN last_linkedin_message TEXT", + // Language for AI-generated content per step + "ALTER TABLE workflow_steps ADD COLUMN ai_language TEXT DEFAULT 'English'", + // Campaign-level prompt — per-workflow AI context (USP, persona, tone for this campaign) + "ALTER TABLE workflows ADD COLUMN prompt TEXT", + // Email domain invalid flag on companies — set when a bounce is detected for any contact at this company + "ALTER TABLE companies ADD COLUMN email_domain_invalid INTEGER DEFAULT 0", + // Apollo extra person fields + "ALTER TABLE targets ADD COLUMN email_domain_catchall INTEGER DEFAULT 0", + // Per-profile email account assignment (multi-account routing) + "ALTER TABLE run_profiles ADD COLUMN email_account_id TEXT", + // Backfill: copy email_account_id from runs → run_profiles for existing records + `UPDATE run_profiles SET email_account_id = (SELECT email_account_id FROM runs WHERE runs.id = run_profiles.run_id) WHERE email_account_id IS NULL`, + // Import job progress tracking + `CREATE TABLE IF NOT EXISTS list_imports ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'running', + phase TEXT, + page INTEGER DEFAULT 0, + total_pages INTEGER DEFAULT 0, + count INTEGER DEFAULT 0, + total INTEGER DEFAULT 0, + imported INTEGER DEFAULT 0, + skipped INTEGER DEFAULT 0, + error TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + )`, + // Parallel tracks: add track column to workflow_steps + "ALTER TABLE workflow_steps ADD COLUMN track TEXT NOT NULL DEFAULT 'linkedin' CHECK(track IN ('linkedin', 'email'))", + // Parallel tracks: create run_profile_tracks table + `CREATE TABLE IF NOT EXISTS run_profile_tracks ( + id TEXT PRIMARY KEY, + run_profile_id TEXT NOT NULL REFERENCES run_profiles(id) ON DELETE CASCADE, + track TEXT NOT NULL CHECK(track IN ('linkedin', 'email')), + state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'in_progress', 'completed', 'failed', 'skipped')), + current_step INTEGER NOT NULL DEFAULT 0, + last_step_at TEXT, + next_step_at TEXT, + error_message TEXT, + last_email_subject TEXT, + last_email_body TEXT, + last_linkedin_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(run_profile_id, track) + )`, + "CREATE INDEX IF NOT EXISTS idx_run_profile_tracks_run_profile_id ON run_profile_tracks(run_profile_id)", + "CREATE INDEX IF NOT EXISTS idx_run_profile_tracks_state_next ON run_profile_tracks(state, next_step_at)", + // Drop deprecated columns from run_profiles — all consumers now read from run_profile_tracks + // SQLite does not support DROP COLUMN directly before 3.35; handled via table rebuild below + // Separate IMAP credentials for custom mail providers where SMTP ≠ IMAP auth + "ALTER TABLE email_accounts ADD COLUMN imap_username TEXT", + "ALTER TABLE email_accounts ADD COLUMN imap_password TEXT", + "ALTER TABLE workflows ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0", + // Per-step signature override for email steps (null = use email account default) + "ALTER TABLE workflow_steps ADD COLUMN email_signature TEXT", + // CRM: todos per contact + `CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY, + target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + title TEXT NOT NULL, + due_date TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'done')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + "CREATE INDEX IF NOT EXISTS idx_todos_target_id ON todos(target_id)", + "CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)", + "ALTER TABLE todos ADD COLUMN description TEXT", + // CRM: activity log per contact + `CREATE TABLE IF NOT EXISTS activity_logs ( + id TEXT PRIMARY KEY, + target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'note' CHECK(type IN ('call', 'email', 'meeting', 'note', 'other')), + body TEXT NOT NULL, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + "CREATE INDEX IF NOT EXISTS idx_activity_logs_target_id ON activity_logs(target_id)", + // Reply classifier: captured email replies + classifier verdict + dispatcher result + `CREATE TABLE IF NOT EXISTS email_replies ( + id TEXT PRIMARY KEY, + target_id TEXT NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + run_id TEXT REFERENCES runs(id) ON DELETE SET NULL, + from_email TEXT NOT NULL, + subject TEXT, + body_text TEXT NOT NULL, + received_at TEXT NOT NULL, + classified_at TEXT, + classification_json TEXT, + classification_error TEXT, + dispatched_at TEXT, + dispatch_result_json TEXT, + manually_edited INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + "CREATE INDEX IF NOT EXISTS idx_email_replies_target_id ON email_replies(target_id)", + "CREATE INDEX IF NOT EXISTS idx_email_replies_dispatched_at ON email_replies(dispatched_at)", + // Reply classifier verdict stamped on the target — enables reply-rate-by-kind metrics + "ALTER TABLE targets ADD COLUMN reply_kind TEXT", + "ALTER TABLE targets ADD COLUMN inmail_sent_at TEXT", + "ALTER TABLE targets ADD COLUMN posts_json TEXT", // recent posts from visit_profile + "ALTER TABLE targets ADD COLUMN posts_scraped_at TEXT", + // One-shot OOO reply context for the AI follow-up writer — set by the dispatcher, + // read + cleared by the runner on the next email send. Distinct from last_email_body + // (which holds the last email WE sent, used for follow-up threading). + "ALTER TABLE run_profile_tracks ADD COLUMN pending_reply_context TEXT", + // Removed the in-app chat agent (replaced by the hosted MCP endpoint at /api/mcp) — drop its tables. + "DROP TABLE IF EXISTS chat_messages", + "DROP TABLE IF EXISTS chat_sessions", + // Batched/scheduled imports — split large lists across days under a daily cap. + "ALTER TABLE list_imports ADD COLUMN account_id TEXT", + "ALTER TABLE list_imports ADD COLUMN sales_nav_url TEXT", + "ALTER TABLE list_imports ADD COLUMN scheduled_for TEXT", // 'YYYY-MM-DD'; NULL = run now + "ALTER TABLE list_imports ADD COLUMN start_page INTEGER DEFAULT 1", + "ALTER TABLE list_imports ADD COLUMN cap INTEGER", // max contacts for this batch + "ALTER TABLE list_imports ADD COLUMN cancel_requested INTEGER DEFAULT 0", + "ALTER TABLE list_imports ADD COLUMN batch_index INTEGER DEFAULT 1", + "ALTER TABLE list_imports ADD COLUMN enrich INTEGER DEFAULT 0", + "CREATE INDEX IF NOT EXISTS idx_list_imports_scheduled ON list_imports(status, scheduled_for)", + // Simple app-wide key/value settings (e.g. daily_import_cap) + `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + // Sales Nav InMail gets its own daily budget — separate from daily_message_limit, + // since InMail (non-connections) and regular messages (connections) were being + // gated off the same counter, starving one whenever the other was busy. + "ALTER TABLE accounts ADD COLUMN daily_inmail_limit INTEGER DEFAULT 15", + // Sales Nav search: persistent cache of resolved filter values (typeahead + // ids). Dedup on (filter_type, id) — LinkedIn ids are stable (e.g. Berlin's + // geoUrn never changes), so once resolved a value is reused forever with no + // further live typeahead calls. Lives here (not ee/) per the open-core rule: + // all migrations stay in lib/db.ts. Consumed only by the ee/ search feature. + `CREATE TABLE IF NOT EXISTS search_filter_cache ( + id TEXT NOT NULL, + filter_type TEXT NOT NULL, + display_value TEXT NOT NULL, + headline TEXT, + query TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (filter_type, id) + )`, + "CREATE INDEX IF NOT EXISTS idx_sfc_type_query ON search_filter_cache(filter_type, query)", + "CREATE INDEX IF NOT EXISTS idx_sfc_type_display ON search_filter_cache(filter_type, display_value)", + // CSV import: lists can be flagged as linkedin- or email-only, so the runner/UI + // can warn before enrolling a purpose-mismatched list into a campaign. + "ALTER TABLE lists ADD COLUMN purpose TEXT", + // Manual/CSV-only field — no automation reads or writes this, reference data only. + "ALTER TABLE targets ADD COLUMN phone TEXT", + ]; + for (const sql of migrations) { + try { db.exec(sql); } catch { /* column already exists */ } + } + + // Parallel tracks: assign email steps to email track, re-number step_order, backfill run_profile_tracks + runParallelTracksMigration(db); + // Drop deprecated run_profiles columns (state, current_step, etc.) — consumers now read track-runs + dropDeprecatedRunProfileColumns(db); + + // Migrate workflow_steps CHECK constraint to allow 'delay' and 'email' step_types + try { + const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='workflow_steps'").get() as { sql: string } | undefined; + if (tableInfo && (!tableInfo.sql.includes("'delay'") || !tableInfo.sql.includes("'email'"))) { + db.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE workflow_steps_new ( + id TEXT PRIMARY KEY, + workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE, + step_order INTEGER NOT NULL, + step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'delay', 'email')), + template_id TEXT REFERENCES templates(id), + delay_seconds INTEGER DEFAULT 0, + connect_note TEXT, + message_body TEXT, + email_subject TEXT, + email_body TEXT, + enabled INTEGER DEFAULT 1 + ); + INSERT INTO workflow_steps_new + SELECT id, workflow_id, step_order, step_type, template_id, delay_seconds, + connect_note, message_body, + NULL, NULL, + enabled + FROM workflow_steps; + DROP TABLE workflow_steps; + ALTER TABLE workflow_steps_new RENAME TO workflow_steps; + PRAGMA foreign_keys = ON; + `); + } + } catch { /* migration already done */ } + + // Allow the 'sales_inmail' step_type (Sales Navigator InMail). Rebuilds the + // table preserving EVERY current column (the historical rebuild above only + // copied the original columns — do NOT reuse it). InMail reuses message_body + // for the body and email_subject for the required subject. + try { + const ti = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='workflow_steps'").get() as { sql: string } | undefined; + if (ti && !ti.sql.includes("'sales_inmail'")) { + const cols = (db.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>).map((c) => c.name); + const colList = cols.join(", "); + db.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE workflow_steps_new ( + id TEXT PRIMARY KEY, + workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE, + step_order INTEGER NOT NULL, + step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'sales_inmail', 'delay', 'email')), + template_id TEXT REFERENCES templates(id), + delay_seconds INTEGER DEFAULT 0, + connect_note TEXT, + message_body TEXT, + email_subject TEXT, + email_body TEXT, + enabled INTEGER DEFAULT 1, + ai_enabled INTEGER DEFAULT 0, + ai_model TEXT, + ai_prompt TEXT, + ai_max_words INTEGER, + email_position INTEGER DEFAULT 1, + message_position INTEGER DEFAULT 1, + ai_language TEXT DEFAULT 'English', + track TEXT NOT NULL DEFAULT 'linkedin' CHECK(track IN ('linkedin', 'email')), + email_signature TEXT + ); + INSERT INTO workflow_steps_new (${colList}) SELECT ${colList} FROM workflow_steps; + DROP TABLE workflow_steps; + ALTER TABLE workflow_steps_new RENAME TO workflow_steps; + PRAGMA foreign_keys = ON; + `); + } + } catch { /* migration already done */ } + + // CSV import: allow email-only targets (no LinkedIn URL). targets.linkedin_url was + // NOT NULL UNIQUE from the base schema — rebuild to make it nullable (still UNIQUE, + // SQLite allows multiple NULLs under UNIQUE) preserving EVERY current column, same + // pattern as the sales_inmail rebuild above (do not reuse the older historical rebuilds + // that only copied the original columns). + try { + const ti = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='targets'").get() as { sql: string } | undefined; + if (ti && ti.sql.includes("linkedin_url TEXT NOT NULL")) { + const cols = (db.prepare("PRAGMA table_info(targets)").all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>); + const colDefs = cols.map((c) => { + if (c.name === "linkedin_url") return "linkedin_url TEXT UNIQUE"; + if (c.name === "id") return "id TEXT PRIMARY KEY"; + const notnull = c.notnull ? " NOT NULL" : ""; + // Non-literal defaults (e.g. datetime('now')) must be parenthesized in SQLite DDL. + const isLiteral = c.dflt_value === null || /^-?\d+(\.\d+)?$/.test(c.dflt_value) || /^'.*'$/.test(c.dflt_value); + const dflt = c.dflt_value !== null ? ` DEFAULT ${isLiteral ? c.dflt_value : `(${c.dflt_value})`}` : ""; + return `${c.name} ${c.type}${notnull}${dflt}`; + }); + const colList = cols.map((c) => c.name).join(", "); + db.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE targets_new ( + ${colDefs.join(",\n ")} + ); + INSERT INTO targets_new (${colList}) SELECT ${colList} FROM targets; + DROP TABLE targets; + ALTER TABLE targets_new RENAME TO targets; + PRAGMA foreign_keys = ON; + `); + } + } catch { /* migration already done */ } + + // Backfill: move company_description and company_size from targets into companies + try { + db.exec(` + UPDATE companies + SET + description = COALESCE(description, ( + SELECT t.company_description FROM targets t + WHERE t.company_id = companies.id AND t.company_description IS NOT NULL + LIMIT 1 + )), + employee_count = COALESCE(employee_count, ( + SELECT t.company_size FROM targets t + WHERE t.company_id = companies.id AND t.company_size IS NOT NULL + LIMIT 1 + )) + WHERE description IS NULL OR employee_count IS NULL + `); + } catch { /* ignore */ } + + // Backfill: for old records where linkedin_url is a Sales Nav URL, move it to sales_nav_url + try { + db.exec(` + UPDATE targets + SET sales_nav_url = linkedin_url + WHERE linkedin_url LIKE '%/sales/lead/%' AND (sales_nav_url IS NULL OR sales_nav_url = '') + `); + } catch { /* ignore */ } + + // Create unique index on run_profiles if not already present (idempotent) + try { + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_run_profiles_unique ON run_profiles(run_id, target_id);"); + } catch { /* ignore */ } + + encryptLegacySecretsMigration(db); +} + +// One-time (per-row) migration: encrypt any plaintext accounts.cookies_json, +// email_accounts.password/imap_password, integrations.api_key left over from before +// encryption-at-rest was added. isEncrypted() lets this run safely on every boot — already +//-encrypted rows (real "v1:" ciphertext) are skipped, so this is idempotent and cheap once +// migrated. See lib/crypto.ts for the format and lib/premium.ts-style boundary notes. +function encryptLegacySecretsMigration(db: Database.Database) { + const accounts = db.prepare("SELECT id, cookies_json FROM accounts WHERE cookies_json IS NOT NULL").all() as + { id: string; cookies_json: string }[]; + for (const row of accounts) { + if (isEncrypted(row.cookies_json)) continue; + db.prepare("UPDATE accounts SET cookies_json = ? WHERE id = ?").run(encryptSecret(row.cookies_json), row.id); + } + + const emailAccounts = db + .prepare("SELECT id, password, imap_password FROM email_accounts") + .all() as { id: string; password: string; imap_password: string | null }[]; + for (const row of emailAccounts) { + const needsPassword = !isEncrypted(row.password); + const needsImapPassword = row.imap_password !== null && !isEncrypted(row.imap_password); + if (!needsPassword && !needsImapPassword) continue; + db.prepare("UPDATE email_accounts SET password = ?, imap_password = ? WHERE id = ?").run( + needsPassword ? encryptSecret(row.password) : row.password, + needsImapPassword ? encryptSecret(row.imap_password!) : row.imap_password, + row.id + ); + } + + const integrations = db.prepare("SELECT key, api_key FROM integrations WHERE api_key IS NOT NULL").all() as + { key: string; api_key: string }[]; + for (const row of integrations) { + if (isEncrypted(row.api_key)) continue; + db.prepare("UPDATE integrations SET api_key = ? WHERE key = ?").run(encryptSecret(row.api_key), row.key); + } +} + +function initDb(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + cookies_json TEXT, + is_authenticated INTEGER DEFAULT 0, + daily_connection_limit INTEGER DEFAULT 20, + daily_message_limit INTEGER DEFAULT 50, + daily_inmail_limit INTEGER DEFAULT 15, + active_hours_start INTEGER DEFAULT 9, + active_hours_end INTEGER DEFAULT 18, + timezone TEXT DEFAULT 'UTC', + working_days TEXT DEFAULT '1,2,3,4,5', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS targets ( + id TEXT PRIMARY KEY, + linkedin_url TEXT UNIQUE, + sales_nav_url TEXT, + first_name TEXT, + last_name TEXT, + full_name TEXT, + title TEXT, + company TEXT, + location TEXT, + profile_image_url TEXT, + degree INTEGER, + connection_requested_at TEXT, + connected_at TEXT, + message_sent_at TEXT, + last_replied_at TEXT, + linkedin_member_urn TEXT, + enriched_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + sales_nav_url TEXT, + purpose TEXT CHECK(purpose IN ('linkedin', 'email')), + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS list_targets ( + list_id TEXT REFERENCES lists(id) ON DELETE CASCADE, + target_id TEXT REFERENCES targets(id) ON DELETE CASCADE, + PRIMARY KEY (list_id, target_id) + ); + + CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT REFERENCES workflows(id) ON DELETE CASCADE, + step_order INTEGER NOT NULL, + step_type TEXT NOT NULL CHECK(step_type IN ('visit', 'connect', 'message', 'delay')), + template_id TEXT REFERENCES templates(id), + delay_seconds INTEGER DEFAULT 0, + connect_note TEXT, + message_body TEXT, + enabled INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS workflow_step_templates ( + step_id TEXT REFERENCES workflow_steps(id) ON DELETE CASCADE, + template_id TEXT REFERENCES templates(id) ON DELETE CASCADE, + PRIMARY KEY (step_id, template_id) + ); + + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT REFERENCES workflows(id), + list_id TEXT REFERENCES lists(id), + account_id TEXT REFERENCES accounts(id), + status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'paused', 'completed', 'failed')), + created_at TEXT DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + runner_pid INTEGER + ); + + CREATE TABLE IF NOT EXISTS run_profiles ( + id TEXT PRIMARY KEY, + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + target_id TEXT REFERENCES targets(id), + state TEXT DEFAULT 'pending' CHECK(state IN ('pending', 'in_progress', 'completed', 'failed', 'skipped')), + current_step INTEGER DEFAULT 0, + last_step_at TEXT, + next_step_at TEXT, + error_message TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(run_id, target_id) + ); + + CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + target_id TEXT REFERENCES targets(id), + level TEXT DEFAULT 'info' CHECK(level IN ('info', 'warn', 'error')), + message TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS companies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + domain TEXT, + industry TEXT, + location TEXT, + linkedin_url TEXT, + website TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS integrations ( + key TEXT PRIMARY KEY, + api_key TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS agent_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + system_prompt TEXT, + user_prompt TEXT, + email_examples TEXT, + linkedin_examples TEXT, + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY, + run_id TEXT, + target_id TEXT, + step_id TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cost_usd REAL, + prompt TEXT, + generated_text TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Hosted MCP OAuth 2.1 server: clients (DCR), single-use auth codes, access/refresh tokens. + -- Tokens/codes are stored only as sha256 hashes. Reuses NEXTAUTH user identity. + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT, + redirect_uris TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scope TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS oauth_tokens ( + id TEXT PRIMARY KEY, + access_hash TEXT NOT NULL UNIQUE, + refresh_hash TEXT UNIQUE, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scope TEXT, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS email_accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + from_email TEXT NOT NULL, + from_name TEXT, + smtp_host TEXT NOT NULL, + smtp_port INTEGER DEFAULT 587, + smtp_secure INTEGER DEFAULT 0, + imap_host TEXT, + imap_port INTEGER DEFAULT 993, + username TEXT NOT NULL, + password TEXT NOT NULL, + daily_email_limit INTEGER DEFAULT 50, + active_hours_start INTEGER DEFAULT 9, + active_hours_end INTEGER DEFAULT 18, + timezone TEXT DEFAULT 'UTC', + working_days TEXT DEFAULT '1,2,3,4,5', + is_verified INTEGER DEFAULT 0, + inbox_synced_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + `); +} diff --git a/lib/email/imap-simple.d.ts b/lib/email/imap-simple.d.ts new file mode 100644 index 0000000..dbda490 --- /dev/null +++ b/lib/email/imap-simple.d.ts @@ -0,0 +1 @@ +declare module 'imap-simple'; diff --git a/lib/email/inbox.ts b/lib/email/inbox.ts new file mode 100644 index 0000000..9fc371d --- /dev/null +++ b/lib/email/inbox.ts @@ -0,0 +1,367 @@ +import Imap from "imap"; +import { simpleParser } from "mailparser"; +import { randomUUID } from "crypto"; +import { getDb } from "@/lib/db"; +import { premium } from "@/lib/premium"; +import { decryptSecret } from "@/lib/crypto"; + +const IMAP_POLL_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours +// Jul 2026 incident: all email accounts became "due" in the same tick and synced +// back-to-back (one 20-account burst ran 3 minutes straight, one account alone took +// 85s), which lined up with NocoDB/n8n healthcheck failures on the shared host. A +// deterministic per-account offset spreads accounts across a window so they drift +// apart over successive cycles instead of staying clustered together forever. +const IMAP_POLL_JITTER_MS = 30 * 60 * 1000; // 30 min + +function accountJitterMs(accountId: string): number { + let h = 0; + for (let i = 0; i < accountId.length; i++) h = (h * 31 + accountId.charCodeAt(i)) >>> 0; + return h % IMAP_POLL_JITTER_MS; +} + +const BOUNCE_SENDER_PATTERNS = [ + /mailer-daemon@/i, + /postmaster@/i, + /mail-delivery-subsystem@/i, + /delivery-status@/i, + /amazonses\.com$/i, +]; + +function extractEmails(text: string): string[] { + return [...text.matchAll(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g)].map(m => m[0].toLowerCase()); +} + +function isBounce(fromEmail: string): boolean { + return BOUNCE_SENDER_PATTERNS.some(p => p.test(fromEmail)); +} + +function parseHeaderValue(raw: string, field: string): string { + const regex = new RegExp(`^${field}:[ \\t]*(.+?)(?=\\r?\\n[^\\s]|$)`, "im"); + const m = raw.match(regex); + if (!m) return ""; + return m[1].replace(/\r?\n[\t ]+/g, " ").trim(); +} + +interface EmailAccount { + id: string; + imap_host: string | null; + imap_port: number | null; + username: string; + password: string; + imap_username: string | null; + imap_password: string | null; + inbox_synced_at: string | null; +} + +/** + * Fetches the body + headers for a given UID and inserts a row into + * `email_replies` (idempotent — skips if a row with the same target_id + + * received_at already exists). Best-effort: errors are swallowed by the caller. + */ +export function captureReplyBody( + imap: Imap, + db: ReturnType, + targetId: string, + fromEmail: string, + uid: number, +): Promise { + return new Promise((resolve) => { + // Fetch the full raw RFC822 message — mailparser handles MIME multipart, + // base64 / quoted-printable transfer encodings, and charset decoding. The + // old HEADER+TEXT regex approach stored raw base64 / =XX escapes for the + // common German auto-reply formats, feeding the classifier garbage. + const fetch = imap.fetch(uid, { bodies: [""], struct: false }); + + const chunks: Buffer[] = []; + + fetch.on("message", (msg) => { + msg.on("body", (stream) => { + stream.on("data", (c: Buffer) => chunks.push(c)); + }); + }); + + fetch.once("error", () => resolve(null)); + fetch.once("end", () => { + void (async () => { + try { + const raw = Buffer.concat(chunks); + const parsed = await simpleParser(raw); + + const subject = parsed.subject ?? null; + const parsedFrom = + parsed.from?.value?.[0]?.address?.toLowerCase().trim() || fromEmail.toLowerCase(); + const receivedAt = parsed.date ? parsed.date.toISOString() : new Date().toISOString(); + + // Prefer decoded plain text; fall back to stripping the HTML part. + const rawText = + parsed.text ?? + (parsed.html ? parsed.html.replace(/<[^>]+>/g, " ") : ""); + const bodyText = rawText + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]{2,}/g, " ") + .trim() + .slice(0, 16_000); + + if (!bodyText) { resolve(null); return; } + + // Dedup: skip if we already have a row for this target with the same received_at + const existing = db.prepare( + "SELECT id FROM email_replies WHERE target_id = ? AND received_at = ?" + ).get(targetId, receivedAt) as { id: string } | undefined; + + if (existing) { resolve(null); return; } + + // Look up the most recent active run for this target — the dispatcher needs + // it to find the email track to reschedule / enroll a substitute into. + const runRow = db.prepare( + `SELECT r.id FROM runs r + JOIN run_profiles rp ON rp.run_id = r.id + WHERE rp.target_id = ? AND r.status IN ('running', 'paused') + ORDER BY r.created_at DESC LIMIT 1` + ).get(targetId) as { id: string } | undefined; + + const replyId = randomUUID(); + db.prepare( + `INSERT INTO email_replies (id, target_id, run_id, from_email, subject, body_text, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(replyId, targetId, runRow?.id ?? null, parsedFrom, subject, bodyText, receivedAt); + resolve(replyId); + } catch (err) { + console.warn(`[email-inbox] captureReplyBody parse/insert failed:`, err); + resolve(null); + } + })(); + }); + }); +} + +export function shouldSyncEmailInbox(emailAccountId: string): boolean { + const db = getDb(); + const account = db + .prepare("SELECT inbox_synced_at FROM email_accounts WHERE id = ?") + .get(emailAccountId) as { inbox_synced_at: string | null } | undefined; + if (!account?.inbox_synced_at) return true; + const dueAfterMs = IMAP_POLL_INTERVAL_MS + accountJitterMs(emailAccountId); + return Date.now() - new Date(account.inbox_synced_at).getTime() >= dueAfterMs; +} + +/** + * Opens one IMAP connection, then for each lead that has been emailed but + * not yet replied, runs a server-side FROM search. Only touches the mailbox + * index — never downloads message bodies for reply detection. + * + * Also scans the last 50 messages for bounces (mailer-daemon etc.). + */ +export async function syncEmailInbox(emailAccountId: string): Promise<{ replies: number; bounces: number }> { + const db = getDb(); + + const account = db + .prepare("SELECT id, imap_host, imap_port, username, password, imap_username, imap_password, inbox_synced_at FROM email_accounts WHERE id = ?") + .get(emailAccountId) as EmailAccount | undefined; + + if (!account?.imap_host) { + console.warn(`[email-inbox] Account ${emailAccountId} has no IMAP config — skipping`); + return { replies: 0, bounces: 0 }; + } + + // Leads that were emailed via this account and haven't replied yet + const pendingTargets = db.prepare(` + SELECT DISTINCT t.id, t.email + FROM targets t + JOIN run_profiles rp ON rp.target_id = t.id + JOIN run_profile_tracks rt ON rt.run_profile_id = rp.id + WHERE t.email IS NOT NULL + AND t.email_replied_at IS NULL + AND t.email_status != 'invalid' + AND rt.track = 'email' + AND rt.state NOT IN ('pending') + AND rp.email_account_id = ? + `).all(emailAccountId) as { id: string; email: string }[]; + + if (pendingTargets.length === 0) { + db.prepare("UPDATE email_accounts SET inbox_synced_at = datetime('now') WHERE id = ?").run(emailAccountId); + return { replies: 0, bounces: 0 }; + } + + console.log(`[email-inbox] Checking ${pendingTargets.length} leads via IMAP FROM search`); + + const imapUser = account.imap_username ?? account.username; + const imapPass = decryptSecret(account.imap_password) ?? decryptSecret(account.password)!; + + let replies = 0; + let bounces = 0; + + await new Promise((resolve) => { + const imap = new Imap({ + host: account.imap_host!, + port: account.imap_port ?? 993, + tls: true, + tlsOptions: { rejectUnauthorized: false }, + user: imapUser, + password: imapPass, + authTimeout: 10_000, + connTimeout: 12_000, + }); + + const done = () => { + try { imap.end(); } catch { /* ignore */ } + resolve(); + }; + + imap.once("error", (err: Error) => { + console.warn(`[email-inbox] IMAP error for account ${emailAccountId}:`, err.message); + done(); + }); + + imap.once("ready", () => { + imap.openBox("INBOX", true, async (err, box) => { + if (err || !box) { console.warn("[email-inbox] openBox failed:", err?.message); done(); return; } + + // ── Reply detection: one FROM search per lead ────────────────────────── + // Run sequentially — the imap library serialises commands over one TCP connection + for (const target of pendingTargets) { + await new Promise((resSearch) => { + imap.search([["FROM", target.email]], async (searchErr, uids) => { + if (searchErr) { resSearch(); return; } + if (uids.length > 0) { + console.log(`[email-inbox] Reply detected for ${target.email} (target ${target.id})`); + replies++; + + // Capture the body, then let the classifier+dispatcher decide the action. + // We no longer eagerly stamp email_replied_at here — the dispatcher does it + // for skip-buckets (call_task / human_reply / not_interested) but leaves it + // unset for OOO follow-ups so the runner keeps the contact enrolled. + // On any failure the contact stays enrolled (safe fallback, plan §3.2). + try { + const latestUid = uids[uids.length - 1]; + const replyId = await captureReplyBody(imap, db, target.id, target.email, latestUid); + // The reply is always STORED (open-core). AI classification + auto-followup + // is a premium feature — skipped cleanly when ee/ is absent. + if (replyId && premium?.replies) { + await premium.replies.classifyAndDispatch(replyId); + } + } catch (err) { + console.warn(`[email-inbox] Failed to capture/dispatch reply for ${target.email}:`, err); + } + } + resSearch(); + }); + }); + } + + // ── Bounce detection: scan last 50 messages for mailer-daemon ───────── + if (box.messages.total > 0) { + const total = box.messages.total; + const start = Math.max(1, total - 49); + const range = `${start}:${total}`; + + await new Promise((resFetch) => { + const fetch = imap.seq.fetch(range, { + bodies: ["HEADER.FIELDS (FROM TO)", "TEXT"], + struct: false, + }); + + type RawMsg = { header: string; body: string }; + const msgs: RawMsg[] = []; + + fetch.on("message", (msg) => { + const entry: RawMsg = { header: "", body: "" }; + msg.on("body", (stream, info) => { + const chunks: Buffer[] = []; + stream.on("data", (c: Buffer) => chunks.push(c)); + stream.once("end", () => { + const text = Buffer.concat(chunks).toString(); + if (info.which.startsWith("HEADER")) entry.header = text; + else entry.body = text.slice(0, 3000); + }); + }); + msg.once("end", () => msgs.push(entry)); + }); + + fetch.once("error", () => resFetch()); + fetch.once("end", () => { + for (const msg of msgs) { + const fromRaw = parseHeaderValue(msg.header, "From"); + const toRaw = parseHeaderValue(msg.header, "To"); + + const emailMatch = fromRaw.match(/<([^>]+)>/) ?? fromRaw.match(/([^\s]+@[^\s]+)/); + const fromEmail = emailMatch?.[1]?.toLowerCase().trim(); + if (!fromEmail || !isBounce(fromEmail)) continue; + + // Also extract Final-Recipient from DSN bodies (SES bounce format) + const finalRecipient = msg.body.match(/Final-Recipient:\s*rfc822;\s*([^\s\r\n]+)/i)?.[1] ?? ""; + const candidates = extractEmails(msg.body + " " + toRaw + " " + finalRecipient); + for (const candidate of candidates) { + if (BOUNCE_SENDER_PATTERNS.some(p => p.test(candidate))) continue; + + const target = db + .prepare("SELECT id, email_status, company_id FROM targets WHERE lower(email) = ?") + .get(candidate) as { id: string; email_status: string | null; company_id: string | null } | undefined; + + if (!target || target.email_status === "invalid") continue; + + const note = `Email bounced on ${new Date().toISOString().slice(0, 10)} — marked invalid`; + db.prepare(` + UPDATE targets SET email_status = 'invalid', + notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END + WHERE id = ? + `).run(note, note, target.id); + + db.prepare(` + UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Email bounced — invalid address' + WHERE run_profile_id IN (SELECT id FROM run_profiles WHERE target_id = ?) + AND state IN ('pending', 'in_progress') + `).run(target.id); + + if (target.company_id) { + const companyNote = `Email domain flagged invalid — bounce for ${candidate} on ${new Date().toISOString().slice(0, 10)}`; + db.prepare(` + UPDATE companies SET email_domain_invalid = 1, + notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END + WHERE id = ? + `).run(companyNote, companyNote, target.company_id); + + const siblings = db.prepare(` + SELECT id FROM targets WHERE company_id = ? AND id != ? AND email IS NOT NULL AND email_status != 'invalid' + `).all(target.company_id, target.id) as { id: string }[]; + + for (const sibling of siblings) { + const sibNote = `Email bounced on ${new Date().toISOString().slice(0, 10)} — marked invalid (domain flagged via company)`; + db.prepare(` + UPDATE targets SET email_status = 'invalid', + notes = CASE WHEN notes IS NULL OR notes = '' THEN ? ELSE notes || char(10) || ? END + WHERE id = ? + `).run(sibNote, sibNote, sibling.id); + db.prepare(` + UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Email domain invalid — company flagged' + WHERE run_profile_id IN (SELECT id FROM run_profiles WHERE target_id = ?) + AND state IN ('pending', 'in_progress') + `).run(sibling.id); + } + + if (siblings.length > 0) { + console.log(`[email-inbox] Company ${target.company_id} flagged — ${siblings.length} sibling(s) marked invalid`); + } + } + + console.log(`[email-inbox] Bounce for ${candidate} (target ${target.id}) — marked invalid`); + bounces++; + break; + } + } + resFetch(); + }); + }); + } + + done(); + }); + }); + + imap.connect(); + }); + + db.prepare("UPDATE email_accounts SET inbox_synced_at = datetime('now') WHERE id = ?").run(emailAccountId); + return { replies, bounces }; +} diff --git a/lib/email/sender.ts b/lib/email/sender.ts new file mode 100644 index 0000000..8ac522a --- /dev/null +++ b/lib/email/sender.ts @@ -0,0 +1,103 @@ +import nodemailer from "nodemailer"; +import Imap from "imap"; + +export interface EmailAccount { + id: string; + from_email: string; + from_name: string | null; + reply_to?: string | null; + smtp_host: string; + smtp_port: number; + smtp_secure: number; // 0 = STARTTLS, 1 = SSL + username: string; + password: string; +} + +export async function sendEmail( + account: EmailAccount, + to: string, + subject: string, + body: string +): Promise { + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port, + secure: account.smtp_secure === 1, + auth: { + user: account.username, + pass: account.password, + }, + // Allow self-signed certs (common in some corp SMTP setups) + tls: { rejectUnauthorized: false }, + }); + + const from = account.from_name + ? `"${account.from_name}" <${account.from_email}>` + : account.from_email; + + await transporter.sendMail({ + from, to, subject, text: body, + ...(account.reply_to ? { replyTo: account.reply_to } : {}), + }); +} + +/** + * Verifies SMTP connectivity — used by the test-connection endpoint. + * Returns null on success, error message string on failure. + */ +export async function testSmtpConnection(account: Omit): Promise { + try { + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port, + secure: account.smtp_secure === 1, + auth: { user: account.username, pass: account.password }, + tls: { rejectUnauthorized: false }, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + }); + await transporter.verify(); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +export interface ImapTestAccount { + imap_host: string; + imap_port: number; + username: string; + password: string; + imap_username: string | null; + imap_password: string | null; +} + +/** + * Verifies IMAP connectivity — connects, authenticates, then disconnects. + * Returns null on success, error message string on failure. + */ +export async function testImapConnection(account: ImapTestAccount): Promise { + return new Promise((resolve) => { + const imap = new Imap({ + host: account.imap_host, + port: account.imap_port, + tls: true, + tlsOptions: { rejectUnauthorized: false }, + user: account.imap_username ?? account.username, + password: account.imap_password ?? account.password, + authTimeout: 10_000, + connTimeout: 12_000, + }); + + imap.once("ready", () => { + try { imap.end(); } catch { /* ignore */ } + resolve(null); + }); + + imap.once("error", (err: Error) => { + resolve(err.message ?? String(err)); + }); + + imap.connect(); + }); +} diff --git a/lib/import-jobs.ts b/lib/import-jobs.ts new file mode 100644 index 0000000..9cb2400 --- /dev/null +++ b/lib/import-jobs.ts @@ -0,0 +1,289 @@ +import type DatabaseType from "better-sqlite3"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +type DB = DatabaseType.Database; + +const PAGE_SIZE = 25; +export const DEFAULT_DAILY_CAP = 1500; + +export interface ImportRow { + id: string; + list_id: string; + account_id: string | null; + sales_nav_url: string | null; + status: string; + phase: string | null; + page: number; + total_pages: number; + count: number; + total: number; + imported: number; + skipped: number; + error: string | null; + scheduled_for: string | null; + start_page: number; + cap: number | null; + cancel_requested: number; + batch_index: number; + enrich: number; + started_at: string; + finished_at: string | null; +} + +// ─── settings ──────────────────────────────────────────────────────────────── + +export function getDailyImportCap(db: DB = getDb()): number { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'daily_import_cap'").get() as + | { value: string } + | undefined; + const n = row ? parseInt(row.value, 10) : NaN; + return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_CAP; +} + +export function setDailyImportCap(db: DB, n: number): void { + const v = String(Math.max(1, Math.floor(n))); + db.prepare( + `INSERT INTO app_settings (key, value, updated_at) VALUES ('daily_import_cap', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')` + ).run(v); +} + +// ─── quota ─────────────────────────────────────────────────────────────────── + +/** Contacts imported across ALL lists today (the global daily budget). */ +export function importedToday(db: DB): number { + const row = db + .prepare( + `SELECT COALESCE(SUM(imported), 0) c FROM list_imports + WHERE status IN ('done', 'running') AND date(COALESCE(finished_at, started_at)) = date('now')` + ) + .get() as { c: number }; + return row.c; +} + +function todayStr(): string { + return new Date().toISOString().slice(0, 10); +} +function addDaysStr(base: string, days: number): string { + const d = new Date(base + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +// ─── public API ────────────────────────────────────────────────────────────── + +/** + * Queue an import for a list. Creates the first batch as 'scheduled' for today; + * the runner's scheduler picks it up (one import at a time). Large lists are + * split across days under the daily cap by runBatch chaining continuations. + */ +export function startImport( + db: DB, + opts: { listId: string; accountId: string; salesNavUrl: string; enrich?: boolean } +): { importId: string } { + cancelImportsForList(db, opts.listId); // supersede any prior import for this list + const importId = randomUUID(); + db.prepare( + `INSERT INTO list_imports + (id, list_id, account_id, sales_nav_url, status, scheduled_for, start_page, batch_index, enrich, started_at) + VALUES (?, ?, ?, ?, 'scheduled', ?, 1, 1, ?, datetime('now'))` + ).run(importId, opts.listId, opts.accountId, opts.salesNavUrl, todayStr(), opts.enrich ? 1 : 0); + return { importId }; +} + +export function cancelImportsForList(db: DB, listId: string): void { + db.prepare( + `UPDATE list_imports + SET cancel_requested = 1, + status = CASE WHEN status = 'scheduled' THEN 'canceled' ELSE status END, + finished_at = CASE WHEN status = 'scheduled' THEN datetime('now') ELSE finished_at END + WHERE list_id = ? AND status IN ('scheduled', 'running')` + ).run(listId); +} + +export function cancelImport(db: DB, importId: string): void { + db.prepare( + `UPDATE list_imports + SET cancel_requested = 1, + status = CASE WHEN status = 'scheduled' THEN 'canceled' ELSE status END, + finished_at = CASE WHEN status = 'scheduled' THEN datetime('now') ELSE finished_at END + WHERE id = ?` + ).run(importId); +} + +// ─── scheduler + executor ──────────────────────────────────────────────────── + +let importRunning = false; + +/** Runner hook (called each tick): start the next due batch if none is running. */ +export async function processScheduledImports(db: DB): Promise { + if (importRunning) return; + const due = db + .prepare( + `SELECT * FROM list_imports + WHERE status = 'scheduled' AND cancel_requested = 0 + AND (scheduled_for IS NULL OR scheduled_for <= date('now')) + ORDER BY scheduled_for ASC, batch_index ASC LIMIT 1` + ) + .get() as ImportRow | undefined; + if (!due) return; + + importRunning = true; + db.prepare("UPDATE list_imports SET status = 'running', started_at = datetime('now') WHERE id = ?").run(due.id); + runBatch(due.id) + .catch((e) => console.error("[import] batch crashed:", e)) + .finally(() => { importRunning = false; }); +} + +async function runBatch(importId: string): Promise { + const db = getDb(); + const job = db.prepare("SELECT * FROM list_imports WHERE id = ?").get(importId) as ImportRow | undefined; + if (!job || !job.account_id || !job.sales_nav_url) return; + + // List deleted out from under us? + const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(job.list_id); + if (!list) { + db.prepare("UPDATE list_imports SET status = 'canceled', finished_at = datetime('now') WHERE id = ?").run(importId); + return; + } + + // Today's remaining budget → max whole pages this run + const cap = getDailyImportCap(db); + const remaining = cap - importedToday(db); + const maxPages = Math.floor(remaining / PAGE_SIZE); + if (maxPages < 1) { + db.prepare("UPDATE list_imports SET status = 'scheduled', scheduled_for = ? WHERE id = ?").run( + addDaysStr(todayStr(), 1), + importId + ); + return; + } + + console.log(`[import] batch ${importId} (b${job.batch_index}) start_page=${job.start_page} maxPages=${maxPages} cap=${cap}`); + const { getSessionContext } = await import("@/lib/linkedin/session"); + const { scrapeNavigatorUrl } = await import("@/lib/linkedin/scraper"); + + const updateProgress = db.prepare( + "UPDATE list_imports SET phase = ?, page = ?, total_pages = ?, count = ?, total = ? WHERE id = ?" + ); + const isCanceled = () => { + const r = db.prepare("SELECT cancel_requested FROM list_imports WHERE id = ?").get(importId) as + | { cancel_requested: number } + | undefined; + return !r || r.cancel_requested === 1; // row deleted (list cascade) or explicit cancel + }; + + try { + const ctx = await getSessionContext(job.account_id); + const { profiles, lastPage, knownTotal, exhausted } = await scrapeNavigatorUrl(ctx, job.sales_nav_url, { + startPage: job.start_page, + maxPages, + onProgress: (p) => updateProgress.run(p.phase, p.page ?? 0, p.totalPages ?? 0, p.count, p.total, importId), + isCanceled, + }); + + if (isCanceled()) { + if (db.prepare("SELECT id FROM list_imports WHERE id = ?").get(importId)) { + db.prepare("UPDATE list_imports SET status = 'canceled', finished_at = datetime('now') WHERE id = ?").run(importId); + } + return; + } + + const { imported, skipped } = insertProfiles(db, job.list_id, profiles); + console.log(`[import] batch ${importId} inserted ${imported} new, skipped ${skipped} (lastPage=${lastPage}, exhausted=${exhausted})`); + + db.prepare( + `UPDATE list_imports + SET status = 'done', imported = ?, skipped = ?, count = ?, total = ?, page = ?, total_pages = ?, finished_at = datetime('now') + WHERE id = ?` + ).run(imported, skipped, profiles.length, knownTotal, lastPage, Math.ceil(knownTotal / PAGE_SIZE), importId); + + // More of the list left → chain the remainder to the next day + if (!exhausted) { + db.prepare( + `INSERT INTO list_imports + (id, list_id, account_id, sales_nav_url, status, scheduled_for, start_page, batch_index, enrich, total, total_pages, started_at) + VALUES (?, ?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, datetime('now'))` + ).run( + randomUUID(), + job.list_id, + job.account_id, + job.sales_nav_url, + addDaysStr(todayStr(), 1), + lastPage + 1, + job.batch_index + 1, + job.enrich, + knownTotal, + Math.ceil(knownTotal / PAGE_SIZE) + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[import] FAILED:", message); + if (db.prepare("SELECT id FROM list_imports WHERE id = ?").get(importId)) { + db.prepare("UPDATE list_imports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?").run( + message, + importId + ); + } + // A "no data intercepted / re-authentication" failure means the session died. + if (/re-authentication|No data intercepted/i.test(message) && job.account_id) { + try { + const { markNeedsReauth } = await import("@/lib/linkedin/session"); + await markNeedsReauth(job.account_id); + } catch { /* ignore */ } + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function insertProfiles(db: DB, listId: string, profiles: any[]): { imported: number; skipped: number } { + const insertTarget = db.prepare( + `INSERT INTO targets ( + id, linkedin_url, sales_nav_url, first_name, last_name, full_name, + title, company, location, degree, + object_urn, summary, open_link, company_industry, company_location, + tenure_months, spotlight_badges + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(linkedin_url) DO UPDATE SET + sales_nav_url = excluded.sales_nav_url, + first_name = excluded.first_name, + last_name = excluded.last_name, + full_name = excluded.full_name, + title = excluded.title, + company = excluded.company, + location = excluded.location, + degree = excluded.degree, + object_urn = excluded.object_urn, + summary = excluded.summary, + open_link = excluded.open_link, + company_industry = excluded.company_industry, + company_location = excluded.company_location, + tenure_months = excluded.tenure_months, + spotlight_badges = excluded.spotlight_badges` + ); + const insertLink = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)"); + const findTarget = db.prepare("SELECT id FROM targets WHERE linkedin_url = ?"); + + let imported = 0; + let skipped = 0; + db.transaction(() => { + for (const p of profiles) { + const url = p.linkedinUrl ?? p.salesNavUrl; + insertTarget.run( + randomUUID(), url, p.salesNavUrl, + p.firstName, p.lastName, p.fullName, + p.title, p.company, p.location, p.degree, + p.objectUrn, p.summary, p.openLink ? 1 : 0, + p.companyIndustry, p.companyLocation, + p.tenureMonths, p.spotlightBadges + ); + const target = findTarget.get(url) as { id: string }; + const result = insertLink.run(listId, target.id); + if (result.changes > 0) imported++; + else skipped++; + } + })(); + return { imported, skipped }; +} diff --git a/lib/linkedin/connect.ts b/lib/linkedin/connect.ts new file mode 100644 index 0000000..64d94e6 --- /dev/null +++ b/lib/linkedin/connect.ts @@ -0,0 +1,89 @@ +import type { Page } from "playwright"; + +export class WeeklyLimitError extends Error {} +export class AlreadyConnectedError extends Error {} +export class PendingInviteError extends Error {} + +/** + * Sends a LinkedIn connection request without a note. + * Navigates to the profile page and clicks the Connect button. + * Throws WeeklyLimitError if the weekly limit popup appears. + * Throws AlreadyConnectedError / PendingInviteError if already in that state. + */ +export async function sendConnectionRequest(page: Page, linkedinUrl: string): Promise { + await page.goto(linkedinUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(2000 + Math.random() * 1000); + + // Already connected? Primary signal: presence of the profile's "Message" link + // (only shown to 1st-degree connections) — an href attribute, not a CSS class + // or translated text, so it survives LinkedIn's class-name hashing and non- + // English UI languages. Falls back to the old text-scrape for accounts/layouts + // where that link isn't found as a plain . + // + // MUST be scoped to the visited person's own intro/top card — a page-wide + // search also matches "Message" links / "1st" badges belonging to OTHER + // people rendered elsewhere on the page (sidebar modules like "People also + // viewed"). For a non-connected target this false-positived AlreadyConnected, + // which skipped the real invite AND (via the message step that follows) sent + // a message to a random unrelated 1st-degree connection instead of the + // target — see CLAUDE.md / memory for the Jul 2026 incident. The top card is + // identified structurally as the section containing the page's own

name + // heading, robust to class-name hashing. Mirrors the same fix in visit.ts. + const topCard = page.locator("main section").filter({ has: page.locator("h1") }).first(); + const hasMessageLink = await topCard.locator('a[href*="/messaging/compose"]').first().count() > 0; + if (hasMessageLink) throw new AlreadyConnectedError("Already connected"); + const pageText = await topCard.innerText().catch(() => ""); + if (/\b1st\b/.test(pageText)) throw new AlreadyConnectedError("Already connected"); + + // Pending? + if (/\bPending\b/.test(pageText)) throw new PendingInviteError("Invitation already pending"); + const pendingBtn = page.locator('button[aria-label*="Pending"]:visible'); + if (await pendingBtn.count() > 0) throw new PendingInviteError("Invitation already pending"); + + // Case 1: Direct Connect link (primary CTA) — navigate to its href directly. + // Clicking fails because the Sales Nav overlay SVG intercepts pointer events. + const directConnect = page.locator('a[aria-label*="Invite"][aria-label*="to connect"]:visible, a[href*="custom-invite"]:visible').first(); + if (await directConnect.count() > 0) { + const href = await directConnect.getAttribute("href"); + if (!href) throw new Error("Connect link has no href"); + const inviteUrl = href.startsWith("http") ? href : `https://www.linkedin.com${href}`; + await page.goto(inviteUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(1000); + } else { + // Case 2: Connect is inside the "..." More menu + // LinkedIn has two "More" buttons on page: [0] = nav bar, [1] = profile card + const moreBtn = page.locator('button[aria-label="More"]:visible').nth(1); + await moreBtn.click(); + await page.waitForTimeout(800); + + // Check for Pending in the menu — means invite was already sent + const pendingMenuItem = page.locator('[role="menuitem"]:has-text("Pending"):visible'); + if (await pendingMenuItem.count() > 0) throw new PendingInviteError("Invitation already pending (found in More menu)"); + + const connectOption = page.locator('[role="menuitem"]:has-text("Connect"):visible'); + if (await connectOption.count() === 0) throw new Error("Connect option not found in More menu"); + await connectOption.first().click(); + } + + await page.waitForTimeout(1000); + + // Click "Send without a note" / "Send now" + const sendBtn = page.locator( + 'button:has-text("Send now"), button[aria-label*="Send without"], button[aria-label*="Send invitation"]:not([aria-label*="note"])' + ); + if (await sendBtn.count() > 0) { + await sendBtn.first().click({ force: true }); + await page.waitForTimeout(1500); + } + + // Check for weekly limit popup + const limitPopup = page.locator('div[class*="ip-fuse-limit-alert__warning"]'); + if (await limitPopup.count() > 0) throw new WeeklyLimitError("Weekly connection limit reached"); + + // Check for error toast + const errorToast = page.locator('div[data-test-artdeco-toast-item-type="error"]:visible'); + if (await errorToast.count() > 0) { + const msg = await errorToast.innerText(); + throw new Error(`Connection error: ${msg.trim()}`); + } +} diff --git a/lib/linkedin/enrich.ts b/lib/linkedin/enrich.ts new file mode 100644 index 0000000..54f66fe --- /dev/null +++ b/lib/linkedin/enrich.ts @@ -0,0 +1,161 @@ +/** + * Profile enrichment via Sales Navigator profile page intercept. + * + * Strategy: navigate to the lead's sales_nav_url. Sales Nav automatically + * fires a salesApiProfiles call that returns the full profile — headline, + * all positions with descriptions, degree, flagshipProfileUrl, summary. + * We intercept that response instead of making extra API calls. + * + * Weight: ~5-8s per profile (one real browser page load). Run fire-and-forget + * after import — do not await in API routes. + */ +import type { BrowserContext } from "playwright"; +import { getDb } from "@/lib/db"; + +interface EnrichedPosition { + title: string; + companyName: string; + current: boolean; + startedOn?: { year?: number; month?: number }; + endedOn?: { year?: number; month?: number }; + description?: string; +} + +interface SalesNavProfileData { + entityUrn?: string; + firstName?: string; + lastName?: string; + fullName?: string; + headline?: string; + summary?: string; + location?: string; + degree?: number; + flagshipProfileUrl?: string; + objectUrn?: string; + positions?: EnrichedPosition[]; + skills?: Array<{ name?: string }>; +} + +interface InterceptedResponse { + data?: SalesNavProfileData; + [key: string]: unknown; +} + +export async function enrichProfile( + ctx: BrowserContext, + target: { id: string; sales_nav_url: string; full_name: string } +): Promise { + const db = getDb(); + const page = await ctx.newPage(); + + try { + // Box it so the async response listener can mutate it (TS can't narrow across async callbacks) + const box: { data: SalesNavProfileData | null } = { data: null }; + + // Intercept the salesApiProfiles call that Sales Nav fires automatically on profile load. + // Two calls fire per page load: + // 1st — flat object with entityUrn, headline, positions, flagshipProfileUrl (what we want) + // 2nd — flat object with educations, skills, numOfConnections (supplementary) + // Both are flat — no .data wrapper. Accept the first one that has entityUrn. + page.on("response", async (resp) => { + if (box.data) return; + if (!resp.url().includes("salesApiProfiles")) return; + if (resp.status() !== 200) return; + try { + const json = await resp.json() as Record; + if ("entityUrn" in json && "firstName" in json) { + box.data = json as unknown as SalesNavProfileData; + } + } catch { /* ignore */ } + }); + + await page.goto(target.sales_nav_url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(8000); + + const intercepted = box.data; + if (!intercepted) { + console.log(`[enrich] No profile data intercepted for ${target.full_name} — skipping`); + return false; + } + + // Extract positions — include all, not just current + const positions: EnrichedPosition[] = (intercepted.positions ?? []).map((p) => ({ + title: p.title ?? "", + companyName: p.companyName ?? "", + current: p.current ?? false, + startedOn: p.startedOn, + endedOn: p.endedOn, + description: p.description ?? undefined, + })); + + // Skills come from a separate decoration call — if not in intercept, skip for now + // (the page-load intercept does not include skills; they'd need a second fetch) + const skills: string[] = (intercepted.skills ?? []) + .map((s) => s.name) + .filter((n): n is string => !!n); + + db.prepare(` + UPDATE targets SET + headline = COALESCE(?, headline), + summary = COALESCE(?, summary), + positions_json = ?, + skills_json = CASE WHEN ? IS NOT NULL THEN ? ELSE skills_json END, + enriched_profile_at = datetime('now') + WHERE id = ? + `).run( + intercepted.headline ?? null, + intercepted.summary ?? null, + positions.length > 0 ? JSON.stringify(positions) : null, + skills.length > 0 ? "1" : null, + skills.length > 0 ? JSON.stringify(skills) : null, + target.id + ); + + console.log(`[enrich] ${target.full_name} — ${positions.length} positions, ${skills.length} skills, headline: ${!!intercepted.headline}`); + return true; + } catch (err) { + console.error(`[enrich] Error enriching ${target.full_name}:`, err instanceof Error ? err.message : err); + return false; + } finally { + await page.close(); + } +} + +export async function enrichList( + ctx: BrowserContext, + listId: string, + delayMs = 2000, + onProgress?: (count: number, total: number) => void +): Promise<{ enriched: number; failed: number }> { + const db = getDb(); + + // Only targets with a sales_nav_url that haven't been enriched yet + const targets = db.prepare(` + SELECT t.id, t.sales_nav_url, t.full_name + FROM targets t + JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ? + AND t.sales_nav_url IS NOT NULL + AND t.enriched_profile_at IS NULL + ORDER BY t.created_at ASC + `).all(listId) as Array<{ id: string; sales_nav_url: string; full_name: string }>; + + const total = targets.length; + console.log(`[enrich] Starting enrichment for ${total} profiles in list ${listId}`); + + let enriched = 0; + let failed = 0; + + for (let i = 0; i < targets.length; i++) { + const target = targets[i]; + console.log(`[enrich] ${i + 1}/${total} — ${target.full_name}`); + const ok = await enrichProfile(ctx, target); + if (ok) enriched++; else failed++; + console.log(`[enrich] ${i + 1}/${total} done (${enriched} ok, ${failed} failed)`); + onProgress?.(i + 1, total); + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + } + + console.log(`[enrich] Done — ${enriched} enriched, ${failed} failed`); + return { enriched, failed }; +} diff --git a/lib/linkedin/li-stats.ts b/lib/linkedin/li-stats.ts new file mode 100644 index 0000000..0046df1 --- /dev/null +++ b/lib/linkedin/li-stats.ts @@ -0,0 +1,60 @@ +import type { Page } from "playwright"; + +export interface LinkedInStats { + connections: number; + pending: number; + profile_views: number; +} + +function parseNum(str: string): number { + return parseInt(str.replace(/[^0-9]/g, ""), 10) || 0; +} + +export async function scrapeLinkedInStats(page: Page): Promise { + // ── Connections ─────────────────────────────────────────── + await page.goto("https://www.linkedin.com/mynetwork/invite-connect/connections/", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForTimeout(2500); + + const connectionsTexts = await page.evaluate(() => + [...document.querySelectorAll("h1,h2,h3,span,p")] + .map(el => ((el as HTMLElement).innerText ?? "").trim()) + .filter(t => /\d.*connection/i.test(t)) + ); + const connections = parseNum(connectionsTexts[0] ?? "0"); + + // ── Pending sent ────────────────────────────────────────── + await page.goto("https://www.linkedin.com/mynetwork/invitation-manager/sent/", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForTimeout(2500); + + const pendingTexts = await page.evaluate(() => + [...document.querySelectorAll("button,a,span,h1,h2,h3")] + .map(el => ((el as HTMLElement).innerText ?? "").trim()) + .filter(t => /People\s*\(\d+\)/i.test(t)) + ); + const pendingMatch = (pendingTexts[0] ?? "").match(/\((\d+)\)/); + const pending = pendingMatch ? parseInt(pendingMatch[1], 10) : 0; + + // ── Profile views ───────────────────────────────────────── + await page.goto("https://www.linkedin.com/analytics/profile-views/", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForTimeout(3000); + + const profileViewsTexts = await page.evaluate(() => + [...document.querySelectorAll("*")] + .map(el => ((el as HTMLElement).innerText ?? "").trim()) + .filter(t => /Profile viewers/i.test(t) && t.length < 100) + ); + // The block looks like "185\n\nProfile viewers\n\n30% previous week" + const pvBlock = profileViewsTexts[0] ?? ""; + const profile_views = parseNum(pvBlock.split("\n")[0]); + + return { connections, pending, profile_views }; +} diff --git a/lib/linkedin/message.ts b/lib/linkedin/message.ts new file mode 100644 index 0000000..8ee8f04 --- /dev/null +++ b/lib/linkedin/message.ts @@ -0,0 +1,138 @@ +import type { Page } from "playwright"; +import { visitProfile } from "./visit"; + +export class NotConnectedError extends Error {} + +export interface SendMessageResult { + messagingUrn: string | null; + isFirstDegree: boolean; +} + +/** + * Sends a message to a LinkedIn 1st-degree connection. + * + * Self-contained URN resolution — does NOT depend on a prior 'visit' workflow + * step. If messagingUrn is already cached, it's used directly (no extra page + * load). Otherwise this does its own live profile check (the same top-card- + * scoped logic as the 'visit' step, see visit.ts) to fetch a fresh URN and + * verify the target is still actually connected, immediately before sending. + * Only if that live check finds no URN despite confirming 1st-degree does it + * fall back to the connections-only name-search typeahead — a rare last + * resort now, not the default path for every contact that lacks a cached URN. + * + * Throws NotConnectedError if the live check finds the target is NOT 1st- + * degree, instead of guessing via typeahead search (which can silently hit + * an unrelated connection with a similar/truncated name — see CLAUDE.md / + * memory for the Jul 2026 incident this replaced). + * + * Returns the resolved { messagingUrn, isFirstDegree } so the caller can + * persist it to the target record, same as the 'visit' step does. + */ +export async function sendMessage( + page: Page, + fullName: string, + text: string, + linkedinUrl: string, + messagingUrn?: string | null +): Promise { + if (messagingUrn) { + const opened = await openComposeByUrn(page, messagingUrn); + if (opened) { + await sendFromComposeBox(page, text); + return { messagingUrn, isFirstDegree: true }; + } + } + + const resolved = await visitProfile(page, linkedinUrl); + if (resolved.messagingUrn) { + const opened = await openComposeByUrn(page, resolved.messagingUrn); + if (opened) { + await sendFromComposeBox(page, text); + return resolved; + } + } + if (!resolved.isFirstDegree) { + throw new NotConnectedError(`${fullName} is not a 1st-degree connection — refusing to message`); + } + + // Connected, but no message link could be resolved live (unusual layout) — + // last-resort fallback to name search. + await sendMessageViaTypeahead(page, fullName, text); + return resolved; +} + +async function openComposeByUrn(page: Page, messagingUrn: string): Promise { + try { + const recipientId = messagingUrn.split(":").pop(); + const composeUrl = `https://www.linkedin.com/messaging/compose/?profileUrn=${encodeURIComponent(messagingUrn)}&recipient=${recipientId}`; + await page.goto(composeUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(1500 + Math.random() * 1000); + const msgInput = page.locator("div.msg-form__contenteditable").first(); + await msgInput.waitFor({ timeout: 8000 }); + return true; + } catch { + return false; + } +} + +async function sendMessageViaTypeahead(page: Page, fullName: string, text: string): Promise { + await page.goto("https://www.linkedin.com/messaging/thread/new/", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForTimeout(1500 + Math.random() * 1000); + + // Search for recipient by name + const searchField = page.locator("input.msg-connections-typeahead__search-field").first(); + await searchField.waitFor({ timeout: 10000 }); + await searchField.click(); + await searchField.type(fullName, { delay: 60 + Math.random() * 40 }); + await page.waitForTimeout(1500); + + // Select first result — but verify it's actually the intended person first. + // This search only returns 1st-degree connections; if the real target isn't + // connected, LinkedIn will still happily return a same/similar-named + // connection as the top result, and we'd silently message a stranger. + const firstResult = page.locator('div[class*="msg-connections-typeahead__search-result-row"]').first(); + await firstResult.waitFor({ timeout: 8000 }); + const resultText = (await firstResult.innerText().catch(() => "")).trim(); + if (!resultNameMatches(resultText, fullName)) { + throw new Error( + `Typeahead search for "${fullName}" returned a non-matching result ("${resultText.replace(/\s+/g, " ")}") — refusing to send to avoid messaging the wrong person` + ); + } + await firstResult.click({ delay: 100 }); + await page.waitForTimeout(800); + + await sendFromComposeBox(page, text); +} + +function resultNameMatches(resultText: string, fullName: string): boolean { + const normalize = (s: string) => + s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z\s]/g, " ").replace(/\s+/g, " ").trim(); + const target = normalize(fullName); + if (!target) return false; + return normalize(resultText).includes(target); +} + +async function sendFromComposeBox(page: Page, text: string): Promise { + // Paste message into compose area + const msgInput = page.locator("div.msg-form__contenteditable").first(); + await msgInput.waitFor({ timeout: 8000 }); + await msgInput.click(); + try { + await page.evaluate((t) => navigator.clipboard.writeText(t), text); + await page.waitForTimeout(300); + await msgInput.press("Control+V"); + } catch { + // Clipboard blocked in headless — fall back to keyboard typing + await msgInput.pressSequentially(text, { delay: 20 }); + } + await page.waitForTimeout(500); + + // Send + const sendBtn = page.locator("button.msg-form__send-button:visible").first(); + await sendBtn.waitFor({ timeout: 5000 }); + await sendBtn.click({ delay: 100 }); + await page.waitForTimeout(2000); +} diff --git a/lib/linkedin/pending-invitations.ts b/lib/linkedin/pending-invitations.ts new file mode 100644 index 0000000..1ee3af1 --- /dev/null +++ b/lib/linkedin/pending-invitations.ts @@ -0,0 +1,61 @@ +import type { Page } from "playwright"; + +/** + * Scrapes the full list of vanity names from the sent invitations page. + * Returns a Set of lowercase vanity names that are STILL pending on LinkedIn. + * + * Strategy: navigate to invitation-manager/sent/, scroll the #workspace container + * until the count stabilises, then extract all /in/ links. + * + * Works headless — the scrollable element is
, not window. + */ +export async function scrapePendingInvitationVanityNames(page: Page): Promise> { + await page.goto("https://www.linkedin.com/mynetwork/invitation-manager/sent/", { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + await page.waitForTimeout(3000); + + // Scroll #workspace until link count stabilises + let lastCount = 0; + let stableRounds = 0; + + for (let round = 0; round < 40; round++) { + await page.evaluate(() => { + const workspace = document.getElementById("workspace"); + if (workspace) { + workspace.scrollTop = workspace.scrollHeight; + } else { + // Fallback: scroll window + body + window.scrollTo(0, document.body.scrollHeight); + document.documentElement.scrollTop = document.documentElement.scrollHeight; + } + }); + await page.waitForTimeout(1200); + + const currentCount = await page.evaluate(() => + document.querySelectorAll("a[href*='/in/']").length + ); + + if (currentCount === lastCount) { + stableRounds++; + if (stableRounds >= 3) break; + } else { + stableRounds = 0; + lastCount = currentCount; + } + } + + // Extract unique vanity names from all /in/ links + const vanityNames = await page.evaluate(() => { + const seen = new Set(); + for (const link of document.querySelectorAll("a[href*='/in/']")) { + const href = link.getAttribute("href") ?? ""; + const match = href.match(/\/in\/([^/?#]+)/); + if (match) seen.add(match[1].toLowerCase()); + } + return [...seen]; + }); + + return new Set(vanityNames); +} diff --git a/lib/linkedin/profile-scrape.ts b/lib/linkedin/profile-scrape.ts new file mode 100644 index 0000000..be25121 --- /dev/null +++ b/lib/linkedin/profile-scrape.ts @@ -0,0 +1,298 @@ +/** + * Live profile scrape → clean structured JSON for an LLM. + * + * Two confirmed sources (probed against prod, Jun 2026 — see docs §18): + * 1. Sales Nav `salesApiProfiles` intercept (navigate the lead's sales_nav_url): + * two flat responses give headline, summary, positions, educations, skills, + * languages, connections, contactInfo, inmailRestriction, memberBadges, degree. + * 2. Voyager `feed/updates?q=memberShareFeed` on the /in/ publicId: + * the 10 most recent authored posts (text + media type + engagement counts) + * plus the comments left on those posts. (q=memberComments → 400, not used.) + * + * Read-only. ~10-12s per profile (one Sales Nav page load + one Voyager fetch). + */ +import type { BrowserContext } from "playwright"; + +export interface ProfilePost { + activityUrn: string; + text: string; + postedAt: string | null; // ISO, when derivable from the activity id + mediaType: "video" | "image" | "article" | "text"; + reactions: number; + comments: number; + shares: number; +} + +export interface ProfileScrape { + full_name: string | null; + first_name: string | null; + last_name: string | null; + headline: string | null; + summary: string | null; + location: string | null; + degree: number | null; // network distance (1/2/3) + linkedin_url: string | null; // real /in/ URL + num_connections: number | null; + num_shared_connections: number | null; + is_premium: boolean; + is_open_profile: boolean; // openLink — can be messaged free (note: API often under-reports) + can_inmail: boolean; // inmailRestriction === NO_RESTRICTION + pending_invitation: boolean; + websites: string[]; + positions: Array<{ + title: string; + company: string; + current: boolean; + started: { year?: number; month?: number } | null; + ended: { year?: number; month?: number } | null; + description: string | null; + }>; + educations: Array<{ school: string | null; degree: string | null; field: string | null }>; + skills: string[]; + languages: string[]; + recent_posts: ProfilePost[]; // up to 10, most recent first +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function textOf(v: unknown): string | null { + if (typeof v === "string") return v; + if (v && typeof v === "object" && "text" in v && typeof (v as { text: unknown }).text === "string") { + return (v as { text: string }).text; + } + return null; +} + +// LinkedIn activity/ugcPost ids encode the creation epoch-ms in the high 41 bits +// (the low 22 bits are a sequence). Shift right 22 = divide by 2^22, via float +// math to avoid BigInt (tsconfig targets < ES2020). +const SHIFT_22 = 4194304; // 2^22 +function timeFromActivityUrn(urn: string): string | null { + const m = urn.match(/(\d{18,})/); + if (!m) return null; + const ms = Math.floor(Number(m[1]) / SHIFT_22); + return ms > 1_000_000_000_000 ? new Date(ms).toISOString() : null; +} + +interface SalesPosition { + title?: string; companyName?: string; current?: boolean; + startedOn?: { year?: number; month?: number }; + endedOn?: { year?: number; month?: number }; + description?: string; +} +interface SalesProfileFlat { + entityUrn?: string; firstName?: string; lastName?: string; fullName?: string; + headline?: string; summary?: string; location?: string; degree?: number; + flagshipProfileUrl?: string; pendingInvitation?: boolean; + positions?: SalesPosition[]; + contactInfo?: { websites?: Array<{ url?: string }> }; + educations?: Array<{ schoolName?: string; degreeName?: string; fieldOfStudy?: string }>; + skills?: Array<{ name?: string }>; + languages?: Array<{ name?: string }>; + numOfConnections?: number; numOfSharedConnections?: number; + inmailRestriction?: string; + memberBadges?: { premium?: boolean; openLink?: boolean }; +} + +// ─── Sales Nav career ───────────────────────────────────────────────────────── + +async function scrapeSalesCareer( + ctx: BrowserContext, + salesNavUrl: string +): Promise> { + const page = await ctx.newPage(); + const responses: SalesProfileFlat[] = []; + page.on("response", async (resp) => { + if (!resp.url().includes("salesApiProfiles") || resp.status() !== 200) return; + try { + const j = (await resp.json()) as Record; + // Only the two flat profile responses (have entityUrn); ignore the data/included one. + if ("entityUrn" in j && ("firstName" in j || "educations" in j || "skills" in j)) { + responses.push(j as unknown as SalesProfileFlat); + } + } catch { /* ignore */ } + }); + + try { + await page.goto(salesNavUrl, { waitUntil: "domcontentloaded", timeout: 35000 }); + await page.waitForTimeout(9000); + } finally { + await page.close(); + } + + // Merge the (typically two) flat responses — first carries the core profile, + // second carries educations/skills/languages/connections/inmailRestriction. + const core = responses.find((r) => r.firstName || r.positions) ?? {}; + const extra = responses.find((r) => r.educations || r.skills || r.numOfConnections != null) ?? {}; + + const positions = (core.positions ?? []).map((p) => ({ + title: p.title ?? "", + company: p.companyName ?? "", + current: p.current ?? false, + started: p.startedOn ?? null, + ended: p.endedOn ?? null, + description: p.description?.trim() ? p.description : null, + })); + + return { + full_name: core.fullName ?? null, + first_name: core.firstName ?? null, + last_name: core.lastName ?? null, + headline: core.headline ?? null, + summary: core.summary ?? null, + location: core.location ?? null, + degree: core.degree ?? null, + linkedin_url: core.flagshipProfileUrl ?? null, + pending_invitation: core.pendingInvitation ?? false, + websites: (core.contactInfo?.websites ?? []).map((w) => w.url).filter((u): u is string => !!u), + positions, + num_connections: extra.numOfConnections ?? null, + num_shared_connections: extra.numOfSharedConnections ?? null, + is_premium: extra.memberBadges?.premium ?? false, + is_open_profile: extra.memberBadges?.openLink ?? false, + can_inmail: (extra.inmailRestriction ?? core.inmailRestriction) === "NO_RESTRICTION", + educations: (extra.educations ?? []).map((e) => ({ + school: e.schoolName ?? null, degree: e.degreeName ?? null, field: e.fieldOfStudy ?? null, + })), + skills: (extra.skills ?? []).map((s) => s.name).filter((n): n is string => !!n), + languages: (extra.languages ?? []).map((l) => l.name).filter((n): n is string => !!n), + }; +} + +// ─── Voyager posts ──────────────────────────────────────────────────────────── + +function publicIdFromUrl(url: string | null | undefined): string | null { + const m = url && url.match(/\/in\/([^/?#]+)/); + return m ? decodeURIComponent(m[1]) : null; +} + +async function scrapePosts( + ctx: BrowserContext, + publicId: string +): Promise { + const page = await ctx.newPage(); + try { + await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded", timeout: 25000 }); + const cookies = await ctx.cookies(); + const csrf = (cookies.find((c) => c.name === "JSESSIONID")?.value || "").replace(/"/g, ""); + if (!csrf) return []; + + const url = + `https://www.linkedin.com/voyager/api/feed/updates` + + `?profileId=${encodeURIComponent(publicId)}&q=memberShareFeed&count=10&start=0`; + const raw = await page.evaluate( + async ({ url, csrf }: { url: string; csrf: string }) => { + const r = await fetch(url, { + headers: { + accept: "application/vnd.linkedin.normalized+json+2.1", + "csrf-token": csrf, + "x-restli-protocol-version": "2.0.0", + }, + credentials: "include", + }); + return r.status === 200 ? r.text() : ""; + }, + { url, csrf } + ); + if (!raw) return []; + + const j = JSON.parse(raw) as { included?: Array> }; + const inc = j.included ?? []; + + // Engagement counts keyed by the post's ugcPost/share urn. + const countsByUrn = new Map(); + for (const x of inc) { + if (!String(x["$type"]).endsWith("SocialActivityCounts")) continue; + const urn = String(x.urn ?? ""); + if (!urn.includes("ugcPost") && !urn.includes("activity")) continue; + countsByUrn.set(urn, { + reactions: Number(x.numLikes ?? 0), + comments: Number(x.numComments ?? 0), + shares: Number(x.numShares ?? 0), + }); + } + + const posts: ProfilePost[] = []; + for (const x of inc) { + if (!String(x["$type"]).endsWith("UpdateV2")) continue; + const commentary = x.commentary as { text?: unknown } | undefined; + const text = commentary ? textOf(commentary.text) : null; + if (!text) continue; + const meta = x.updateMetadata as { urn?: string } | undefined; + const activityUrn = meta?.urn ?? ""; + + // Find the matching counts (the SocialActivityCounts urn references the backing ugcPost). + let counts = { reactions: 0, comments: 0, shares: 0 }; + for (const [urn, c] of countsByUrn) { + const idActivity = activityUrn.match(/(\d{18,})/)?.[1]; + if (idActivity && urn.includes(idActivity)) { counts = c; break; } + } + + const content = x.content as Record | undefined; + const ctype = content ? String(Object.keys(content)[0] ?? "").toLowerCase() : ""; + const mediaType: ProfilePost["mediaType"] = + ctype.includes("video") ? "video" : + ctype.includes("image") ? "image" : + ctype.includes("article") ? "article" : "text"; + + posts.push({ + activityUrn, + text, + postedAt: timeFromActivityUrn(activityUrn), + mediaType, + reactions: counts.reactions, + comments: counts.comments, + shares: counts.shares, + }); + } + + // Most recent first (by encoded time when available). + posts.sort((a, b) => (b.postedAt ?? "").localeCompare(a.postedAt ?? "")); + return posts.slice(0, 10); + } catch { + return []; + } finally { + await page.close(); + } +} + +// ─── public ─────────────────────────────────────────────────────────────────── + +export async function scrapeProfile( + ctx: BrowserContext, + target: { sales_nav_url: string | null; linkedin_url: string | null } +): Promise { + if (!target.sales_nav_url) { + throw new Error("Contact has no sales_nav_url — cannot scrape the Sales Nav profile."); + } + + const career = await scrapeSalesCareer(ctx, target.sales_nav_url); + + // Prefer the flagship /in/ URL from the career scrape; fall back to the stored one. + const inUrl = career.linkedin_url ?? target.linkedin_url ?? null; + const publicId = publicIdFromUrl(inUrl); + const recent_posts = publicId ? await scrapePosts(ctx, publicId) : []; + + return { + full_name: career.full_name ?? null, + first_name: career.first_name ?? null, + last_name: career.last_name ?? null, + headline: career.headline ?? null, + summary: career.summary ?? null, + location: career.location ?? null, + degree: career.degree ?? null, + linkedin_url: inUrl, + num_connections: career.num_connections ?? null, + num_shared_connections: career.num_shared_connections ?? null, + is_premium: career.is_premium ?? false, + is_open_profile: career.is_open_profile ?? false, + can_inmail: career.can_inmail ?? false, + pending_invitation: career.pending_invitation ?? false, + websites: career.websites ?? [], + positions: career.positions ?? [], + educations: career.educations ?? [], + skills: career.skills ?? [], + languages: career.languages ?? [], + recent_posts, + }; +} diff --git a/lib/linkedin/resolve-account.ts b/lib/linkedin/resolve-account.ts new file mode 100644 index 0000000..c0614da --- /dev/null +++ b/lib/linkedin/resolve-account.ts @@ -0,0 +1,50 @@ +import type DatabaseType from "better-sqlite3"; + +type DB = DatabaseType.Database; +export interface ResolvedAccount { id: string; email: string } + +/** + * Resolve which authenticated LinkedIn account to act through for a contact. + * Order: explicit id → the contact's most recent run assignment → the sole + * authenticated account. Only ever returns an `is_authenticated = 1` account + * so callers never drive a dead session. + */ +export function resolveLinkedInAccount(db: DB, targetId: string, explicitId?: string): ResolvedAccount | null { + const byId = (aid: string) => + db.prepare("SELECT id, email FROM accounts WHERE id = ? AND is_authenticated = 1").get(aid) as + | ResolvedAccount + | undefined; + + if (explicitId) return byId(explicitId) ?? null; + + const assigned = db.prepare(` + SELECT r.account_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE rp.target_id = ? + ORDER BY rp.created_at DESC LIMIT 1 + `).get(targetId) as { account_id: string } | undefined; + if (assigned?.account_id) { + const a = byId(assigned.account_id); + if (a) return a; + } + + const all = db.prepare("SELECT id, email FROM accounts WHERE is_authenticated = 1").all() as ResolvedAccount[]; + return all.length === 1 ? all[0] : null; +} + +/** + * Resolve an authenticated LinkedIn account when there's no target/contact to + * anchor the lookup to (e.g. Sales Navigator search, which runs before any + * lead exists locally). Order: explicit id → the sole authenticated account. + */ +export function resolveAnyAuthenticatedAccount(db: DB, explicitId?: string): ResolvedAccount | null { + const byId = (aid: string) => + db.prepare("SELECT id, email FROM accounts WHERE id = ? AND is_authenticated = 1").get(aid) as + | ResolvedAccount + | undefined; + + if (explicitId) return byId(explicitId) ?? null; + + const all = db.prepare("SELECT id, email FROM accounts WHERE is_authenticated = 1").all() as ResolvedAccount[]; + return all.length === 1 ? all[0] : null; +} diff --git a/lib/linkedin/runner.ts b/lib/linkedin/runner.ts new file mode 100644 index 0000000..bea18e2 --- /dev/null +++ b/lib/linkedin/runner.ts @@ -0,0 +1,1422 @@ +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; +import { getSessionPage, saveSessionState, getSessionContext } from "@/lib/linkedin/session"; +import { visitProfile } from "@/lib/linkedin/visit"; +import { sendConnectionRequest, WeeklyLimitError, AlreadyConnectedError, PendingInviteError } from "@/lib/linkedin/connect"; +import { sendMessage, NotConnectedError } from "@/lib/linkedin/message"; +import { shouldSyncAccepted, syncAcceptedConnections } from "@/lib/linkedin/sync-accepted"; +import { sendEmail } from "@/lib/email/sender"; +import { shouldSyncEmailInbox, syncEmailInbox } from "@/lib/email/inbox"; +import { enrichProfile } from "@/lib/linkedin/enrich"; +import { matchPerson } from "@/lib/apollo"; +import { premium } from "@/lib/premium"; +import { decryptSecret } from "@/lib/crypto"; + +// Minimum gap between Sales Nav profile enrichment calls per account (ms) +const SALES_NAV_ENRICH_MIN_GAP_MS = 5 * 60 * 1000; +// Per-account timestamp of last ensureSalesNavEnriched execution +const lastSalesNavEnrichAt: Record = {}; + +// Accounts that reported "No InMail credits left" today (Jul 2026 incident — LinkedIn's +// own credit balance, distinct from daily_inmail_limit; without this, a depleted account +// re-attempted InMail on every queued lead, each burning a ~30-50s Sales Nav page load for +// nothing). Keyed by accountId -> the date (YYYY-MM-DD, local) it was detected exhausted. +// In-memory only — worst case after a restart is one wasted attempt before re-detecting. +const inmailCreditsExhaustedOn: Record = {}; +function todayLocalDate(): string { return new Date().toISOString().slice(0, 10); } +function inmailCreditsExhaustedToday(accountId: string): boolean { + return inmailCreditsExhaustedOn[accountId] === todayLocalDate(); +} + +// Initial wait before first acceptance check (6h) +const CONNECTION_RECHECK_HOURS = 6; +// Max days to wait for acceptance before giving up +const CONNECTION_MAX_WAIT_DAYS = 7; +// Delay between profiles (seconds) +const PROFILE_DELAY_MIN = 8; +const PROFILE_DELAY_MAX = 20; +// Poll interval (ms) +const POLL_INTERVAL_MS = 30_000; + +interface ScheduleConfig { + active_hours_start: number; + active_hours_end: number; + timezone: string; + working_days: string; +} + +interface AccountLimits extends ScheduleConfig { + daily_connection_limit: number; + daily_message_limit: number; + daily_inmail_limit: number; +} + +interface EmailAccountLimits extends ScheduleConfig { + daily_email_limit: number; + ramp_up_enabled: number | null; + ramp_start_date: string | null; +} + +function effectiveEmailLimit(account: EmailAccountLimits): number { + if (!account.ramp_up_enabled || !account.ramp_start_date) return account.daily_email_limit; + const daysActive = Math.max(1, Math.floor((Date.now() - new Date(account.ramp_start_date).getTime()) / 86_400_000) + 1); + const ramped = daysActive * 2; + return Math.min(account.daily_email_limit, ramped); +} + +function getLocalParts(tz: string, date = new Date()): { hour: number; minute: number; isoWeekday: number } { + const safeZone = (() => { try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return tz; } catch { return "UTC"; } })(); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: safeZone, + hour: "numeric", minute: "numeric", weekday: "short", hour12: false, + }).formatToParts(date); + const get = (t: string) => parts.find(p => p.type === t)?.value ?? ""; + const hour = parseInt(get("hour"), 10) % 24; + const minute = parseInt(get("minute"), 10); + const weekdayMap: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; + return { hour, minute, isoWeekday: weekdayMap[get("weekday")] ?? 1 }; +} + +function isWithinSchedule(account: ScheduleConfig): boolean { + const { hour, minute, isoWeekday } = getLocalParts(account.timezone || "UTC"); + const allowedDays = (account.working_days || "1,2,3,4,5").split(",").map(Number); + if (!allowedDays.includes(isoWeekday)) return false; + const frac = hour + minute / 60; + return frac >= (account.active_hours_start ?? 9) && frac < (account.active_hours_end ?? 18); +} + +function randomSlotInActiveWindow(account: ScheduleConfig, targetDate?: Date): string { + const start = account.active_hours_start ?? 9; + const end = account.active_hours_end ?? 18; + const base = targetDate ? new Date(targetDate) : new Date(); + const startMs = new Date(base.getFullYear(), base.getMonth(), base.getDate(), start, 0, 0).getTime(); + const endMs = new Date(base.getFullYear(), base.getMonth(), base.getDate(), end, 0, 0).getTime(); + return new Date(startMs + Math.random() * (endMs - startMs)).toISOString(); +} + +function rescheduleToTomorrow(account: ScheduleConfig): string { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return randomSlotInActiveWindow(account, tomorrow); +} + +function nextScheduledSlot(account: ScheduleConfig): string { + const tz = account.timezone || "UTC"; + const allowedDays = (account.working_days || "1,2,3,4,5").split(",").map(Number); + const end = account.active_hours_end ?? 18; + const { hour: nowHour, minute: nowMin, isoWeekday: nowDay } = getLocalParts(tz); + const nowFrac = nowHour + nowMin / 60; + if (allowedDays.includes(nowDay) && nowFrac < end - 0.25) { + const remaining = (end - nowFrac) * 3600_000; + return new Date(Date.now() + Math.random() * remaining).toISOString(); + } + const candidate = new Date(); + for (let i = 1; i <= 14; i++) { + candidate.setDate(candidate.getDate() + 1); + const { isoWeekday } = getLocalParts(tz, candidate); + if (allowedDays.includes(isoWeekday)) return randomSlotInActiveWindow(account, candidate); + } + return new Date(Date.now() + 86_400_000).toISOString(); +} + +interface WorkflowStep { + id: string; + step_order: number; + track: "linkedin" | "email"; + step_type: "visit" | "connect" | "message" | "sales_inmail" | "delay" | "email"; + template_id: string | null; + delay_seconds: number; + connect_note: string | null; + message_body: string | null; + email_subject: string | null; + email_body: string | null; + ai_enabled: number | null; + ai_model: string | null; + ai_prompt: string | null; + ai_max_words: number | null; + ai_language: string | null; + email_position: number | null; + message_position: number | null; + email_signature: string | null; +} + +// A track-run row joined with its parent run_profile and run context +interface TrackRun { + // run_profile_tracks columns + id: string; + run_profile_id: string; + track: "linkedin" | "email"; + state: string; + current_step: number; + next_step_at: string | null; + error_message: string | null; + last_email_subject: string | null; + last_email_body: string | null; + last_linkedin_message: string | null; + pending_reply_context: string | null; + // joined from run_profiles / runs + run_id: string; + target_id: string; + email_account_id: string | null; + account_id: string; + workflow_id: string; + // joined from targets — lets the daily-limit gate tell a NEW connect send apart + // from a free acceptance recheck on an already-sent request + connection_requested_at: string | null; +} + +interface Target { + id: string; + linkedin_url: string; + sales_nav_url: string | null; + first_name: string | null; + last_name: string | null; + full_name: string | null; + title: string | null; + company: string | null; + location: string | null; + degree: number | null; + connection_requested_at: string | null; + connected_at: string | null; + email: string | null; + email_status: string | null; + email_replied_at: string | null; + company_id: string | null; + messaging_urn: string | null; +} + +interface Template { id: string; body: string; } + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function log(db: ReturnType, runId: string, targetId: string | null, level: "info" | "warn" | "error", message: string) { + db.prepare("INSERT INTO logs (id, run_id, target_id, level, message) VALUES (?, ?, ?, ?, ?)").run(randomUUID(), runId, targetId, level, message); + console.log(`[runner] [${level}] run=${runId} target=${targetId ?? "-"} ${message}`); +} + +function renderTemplate(body: string, target: Target): string { + return body + .replace(/\{\{first_name\}\}/gi, target.first_name ?? target.full_name?.split(" ")[0] ?? "") + .replace(/\{\{last_name\}\}/gi, target.last_name ?? target.full_name?.split(" ").slice(1).join(" ") ?? "") + .replace(/\{\{full_name\}\}/gi, target.full_name ?? "") + .replace(/\{\{company\}\}/gi, target.company ?? "") + .replace(/\{\{title\}\}/gi, target.title ?? "") + .replace(/\{\{location\}\}/gi, target.location ?? "") + .trim(); +} + +function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } +function randomDelay(minSec: number, maxSec: number) { return sleep((minSec + Math.random() * (maxSec - minSec)) * 1000); } +function nowIso() { return new Date().toISOString(); } +function addHours(h: number) { return new Date(Date.now() + h * 3600_000).toISOString(); } +function hoursSince(isoStr: string) { return (Date.now() - new Date(isoStr).getTime()) / 3600_000; } + +// ─── TrackRun verb layer ───────────────────────────────────────────────────── +// These are the only functions that write to run_profile_tracks rows. + +function trAdvance(db: ReturnType, tr: TrackRun, steps: WorkflowStep[]) { + const nextIndex = tr.current_step + 1; + if (nextIndex >= steps.length) { + db.prepare( + "UPDATE run_profile_tracks SET state = 'completed', current_step = ?, last_step_at = datetime('now'), next_step_at = NULL WHERE id = ?" + ).run(nextIndex, tr.id); + } else { + const nextStep = steps[nextIndex]; + const nextAt = nextStep.delay_seconds > 0 ? new Date(Date.now() + nextStep.delay_seconds * 1000).toISOString() : null; + db.prepare( + "UPDATE run_profile_tracks SET current_step = ?, last_step_at = datetime('now'), next_step_at = ? WHERE id = ?" + ).run(nextIndex, nextAt, tr.id); + } +} + +function trWait(db: ReturnType, tr: TrackRun, hours: number) { + db.prepare("UPDATE run_profile_tracks SET next_step_at = ? WHERE id = ?").run(addHours(hours), tr.id); +} + +function trReschedule(db: ReturnType, tr: TrackRun, isoTimestamp: string) { + db.prepare("UPDATE run_profile_tracks SET next_step_at = ? WHERE id = ?").run(isoTimestamp, tr.id); +} + +function trSkip(db: ReturnType, tr: TrackRun, reason: string) { + db.prepare("UPDATE run_profile_tracks SET state = 'skipped', error_message = ? WHERE id = ?").run(reason, tr.id); +} + +function trFail(db: ReturnType, tr: TrackRun, reason: string) { + db.prepare("UPDATE run_profile_tracks SET state = 'failed', error_message = ? WHERE id = ?").run(reason, tr.id); +} + +function trRecordContext(db: ReturnType, tr: TrackRun, ctx: { linkedinMessage?: string; emailSubject?: string; emailBody?: string }) { + if (ctx.linkedinMessage !== undefined) { + db.prepare("UPDATE run_profile_tracks SET last_linkedin_message = ? WHERE id = ?").run(ctx.linkedinMessage, tr.id); + } + if (ctx.emailSubject !== undefined || ctx.emailBody !== undefined) { + db.prepare("UPDATE run_profile_tracks SET last_email_subject = ?, last_email_body = ? WHERE id = ?") + .run(ctx.emailSubject ?? null, ctx.emailBody ?? null, tr.id); + } +} + +// ─── enforceSchedule helper ────────────────────────────────────────────────── +// Returns true if the step may proceed. Returns false and reschedules if outside the window. + +function enforceSchedule( + db: ReturnType, + tr: TrackRun, + runId: string, + targetId: string, + name: string, + schedule: ScheduleConfig +): boolean { + if (isWithinSchedule(schedule)) return true; + const nextSlot = nextScheduledSlot(schedule); + log(db, runId, targetId, "info", `Outside working schedule — rescheduling ${name} to ${nextSlot}`); + trReschedule(db, tr, nextSlot); + return false; +} + +// ─── URL resolution ────────────────────────────────────────────────────────── + +async function resolveLinkedinUrl(db: ReturnType, target: Target, accountId: string): Promise { + if (target.linkedin_url?.includes("/in/")) return target.linkedin_url; + const salesNavUrl = target.sales_nav_url ?? target.linkedin_url; + if (!salesNavUrl) throw new Error(`${target.full_name ?? target.id} has no Sales Nav URL to resolve from`); + const leadMatch = salesNavUrl.match(/\/sales\/lead\/(.+)/); + if (!leadMatch) throw new Error(`${target.full_name ?? target.id} has no Sales Nav lead URL — cannot resolve LinkedIn URL`); + + const page = await getSessionPage(accountId); + let profileJson: Record | null = null; + try { + page.on("response", async (response) => { + if (response.url().includes("salesApiProfiles/") && response.status() === 200 && !profileJson) { + try { profileJson = await response.json() as Record; } catch { /* ignore */ } + } + }); + await page.goto(`https://www.linkedin.com/sales/lead/${leadMatch[1]}`, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(10000); + } finally { + await page.close(); + } + + const p = profileJson as Record | null; + const flagshipUrl = typeof p?.flagshipProfileUrl === "string" ? p.flagshipProfileUrl : null; + if (!flagshipUrl) throw new Error(`Could not resolve LinkedIn URL for ${target.full_name ?? target.id}`); + const linkedinUrl = flagshipUrl.endsWith("/") ? flagshipUrl : flagshipUrl + "/"; + + type RawPosition = { title?: unknown; companyName?: unknown; current?: unknown; startedOn?: unknown; endedOn?: unknown; description?: unknown }; + const rawPositions = Array.isArray(p?.positions) ? (p.positions as RawPosition[]) : []; + const positions = rawPositions.map((pos) => ({ + title: typeof pos.title === "string" ? pos.title : "", + companyName: typeof pos.companyName === "string" ? pos.companyName : "", + current: pos.current === true, + startedOn: pos.startedOn as { year?: number; month?: number } | undefined, + endedOn: pos.endedOn as { year?: number; month?: number } | undefined, + description: typeof pos.description === "string" ? pos.description : undefined, + })); + type RawSkill = { name?: unknown }; + const rawSkills = Array.isArray(p?.skills) ? (p.skills as RawSkill[]) : []; + const skills = rawSkills.map((s) => (typeof s.name === "string" ? s.name : "")).filter(Boolean); + + db.prepare(` + UPDATE targets SET + linkedin_url = ?, + linkedin_member_urn = COALESCE(linkedin_member_urn, ?), + headline = COALESCE(headline, ?), + summary = COALESCE(summary, ?), + positions_json = COALESCE(positions_json, ?), + skills_json = CASE WHEN skills_json IS NULL AND ? IS NOT NULL THEN ? ELSE skills_json END, + enriched_profile_at = COALESCE(enriched_profile_at, datetime('now')) + WHERE id = ? + `).run( + linkedinUrl, + typeof p?.objectUrn === "string" ? p.objectUrn : null, + typeof p?.headline === "string" ? p.headline : null, + typeof p?.summary === "string" ? p.summary : null, + positions.length > 0 ? JSON.stringify(positions) : null, + skills.length > 0 ? "1" : null, + skills.length > 0 ? JSON.stringify(skills) : null, + target.id + ); + return linkedinUrl; +} + +async function getLinkedinUrl(db: ReturnType, target: Target, accountId: string): Promise { + if (target.linkedin_url?.includes("/in/")) return target.linkedin_url; + return resolveLinkedinUrl(db, target, accountId); +} + +// ─── pre-action enrichment ─────────────────────────────────────────────────── + +async function ensureSalesNavEnriched(db: ReturnType, target: Target, accountId: string): Promise { + const fresh = db.prepare("SELECT enriched_profile_at, apollo_enriched_at, sales_nav_url, full_name FROM targets WHERE id = ?").get(target.id) as { enriched_profile_at: string | null; apollo_enriched_at: string | null; sales_nav_url: string | null; full_name: string | null } | undefined; + if (!fresh || fresh.enriched_profile_at || fresh.apollo_enriched_at || !fresh.sales_nav_url) return; + const last = lastSalesNavEnrichAt[accountId] ?? 0; + if (Date.now() - last < SALES_NAV_ENRICH_MIN_GAP_MS) return; + try { + lastSalesNavEnrichAt[accountId] = Date.now(); + const ctx = await getSessionContext(accountId); + await enrichProfile(ctx, { id: target.id, sales_nav_url: fresh.sales_nav_url, full_name: fresh.full_name ?? target.full_name ?? target.id }); + } catch (e) { + console.warn(`[runner] Sales Nav enrichment failed for ${target.full_name ?? target.id}:`, e instanceof Error ? e.message : e); + } +} + +async function ensureApolloEnriched(db: ReturnType, target: Target, runId: string): Promise { + const fresh = db.prepare("SELECT apollo_enriched_at, email, linkedin_url, sales_nav_url FROM targets WHERE id = ?").get(target.id) as { apollo_enriched_at: string | null; email: string | null; linkedin_url: string | null; sales_nav_url: string | null } | undefined; + if (!fresh || fresh.apollo_enriched_at || fresh.email) return; + const apolloUrl = fresh.linkedin_url?.includes("/in/") ? fresh.linkedin_url : fresh.sales_nav_url; + if (!apolloUrl) return; + + const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'apollo'").get() as { api_key: string } | undefined; + if (!integration?.api_key) return; + + try { + const result = await matchPerson(apolloUrl, decryptSecret(integration.api_key)!); + if (!result) { + db.prepare("UPDATE targets SET apollo_enriched_at = datetime('now') WHERE id = ?").run(target.id); + return; + } + + let companyId: string | null = null; + if (result.organization?.domain) { + const domain = result.organization.domain.replace(/^www\./, "").toLowerCase(); + const existing = db.prepare("SELECT id FROM companies WHERE domain = ?").get(domain) as { id: string } | undefined; + const org = result.organization; + if (existing) { + companyId = existing.id; + db.prepare(` + UPDATE companies SET + industry = COALESCE(industry, ?), location = COALESCE(location, ?), + linkedin_url = COALESCE(linkedin_url, ?), website = COALESCE(website, ?), + founded_year = COALESCE(founded_year, ?), logo_url = COALESCE(logo_url, ?), + phone = COALESCE(phone, ?), annual_revenue = COALESCE(annual_revenue, ?), + technology_names = COALESCE(technology_names, ?), keywords = COALESCE(keywords, ?), + city = COALESCE(city, ?), country = COALESCE(country, ?), + description = COALESCE(description, ?), employee_count = COALESCE(employee_count, ?) + WHERE id = ? + `).run( + org.industry ?? null, org.location ?? null, org.linkedin_url ?? null, + org.website_url ?? null, org.founded_year ?? null, org.logo_url ?? null, + org.phone ?? null, org.annual_revenue_printed ?? null, + org.technology_names ? JSON.stringify(org.technology_names) : null, + org.keywords ? JSON.stringify(org.keywords) : null, + org.city ?? null, org.country ?? null, + org.short_description ?? null, org.estimated_num_employees ?? null, + existing.id + ); + } else { + companyId = randomUUID(); + db.prepare(` + INSERT INTO companies (id, name, domain, industry, location, linkedin_url, website, founded_year, logo_url, phone, annual_revenue, technology_names, keywords, city, country, description, employee_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + companyId, org.name ?? "", domain, + org.industry ?? null, org.location ?? null, org.linkedin_url ?? null, + org.website_url ?? null, org.founded_year ?? null, org.logo_url ?? null, + org.phone ?? null, org.annual_revenue_printed ?? null, + org.technology_names ? JSON.stringify(org.technology_names) : null, + org.keywords ? JSON.stringify(org.keywords) : null, + org.city ?? null, org.country ?? null, + org.short_description ?? null, org.estimated_num_employees ?? null + ); + } + } + + db.prepare(` + UPDATE targets SET + apollo_id = ?, seniority = ?, apollo_functions = ?, apollo_departments = ?, + email = COALESCE(email, ?), email_status = COALESCE(email_status, ?), + email_domain_catchall = ?, + city = COALESCE(city, ?), country = COALESCE(country, ?), + time_zone = COALESCE(time_zone, ?), + headline = COALESCE(headline, ?), + positions_json = COALESCE(positions_json, ?), + company_id = COALESCE(company_id, ?), + linkedin_url = COALESCE(linkedin_url, ?), + apollo_enriched_at = datetime('now') + WHERE id = ? + `).run( + result.apollo_id, + result.seniority ?? null, + result.functions ? JSON.stringify(result.functions) : null, + result.departments ? JSON.stringify(result.departments) : null, + result.email ?? null, + result.email_status ?? null, + result.email_domain_catchall ? 1 : 0, + result.city ?? null, + result.country ?? null, + result.time_zone ?? null, + result.headline ?? null, + result.positions_json ?? null, + companyId, + result.linkedin_url ?? null, + target.id + ); + console.log(`[runner] Apollo enriched ${target.full_name ?? target.id} — email: ${result.email ?? "not found"}`); + } catch (e) { + console.warn(`[runner] Apollo enrichment failed for ${target.full_name ?? target.id}:`, e instanceof Error ? e.message : e); + } +} + +// ─── step execution ────────────────────────────────────────────────────────── + +async function executeStep( + db: ReturnType, + runId: string, + tr: TrackRun, + target: Target, + steps: WorkflowStep[], + accountId: string, + accountLimits: AccountLimits, + emailAccountId?: string | null, + emailAccountLimits?: EmailAccountLimits | null, + campaignPrompt?: string | null +): Promise { + const stepIndex = tr.current_step; + if (stepIndex >= steps.length) { + db.prepare("UPDATE run_profile_tracks SET state = 'completed', last_step_at = datetime('now') WHERE id = ?").run(tr.id); + return; + } + + // Auto-unenroll if lead has replied on either channel — mark ALL track-runs for this profile skipped + const replyCheck = db.prepare("SELECT last_replied_at, email_replied_at FROM targets WHERE id = ?").get(target.id) as { last_replied_at: string | null; email_replied_at: string | null }; + if (replyCheck?.last_replied_at || replyCheck?.email_replied_at) { + const channel = replyCheck.email_replied_at ? "email" : "LinkedIn"; + log(db, runId, target.id, "info", `${target.full_name ?? target.linkedin_url} replied via ${channel} — unenrolling from workflow`); + db.prepare( + "UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Lead replied' WHERE run_profile_id = ? AND state NOT IN ('completed', 'failed', 'skipped')" + ).run(tr.run_profile_id); + return; + } + + const step = steps[stepIndex]; + const name = target.full_name ?? target.linkedin_url; + + try { + if (step.step_type === "delay") { + trAdvance(db, tr, steps); + log(db, runId, target.id, "info", `Delay step passed for ${name}`); + return; + } + + if (step.step_type === "visit") { + db.prepare("UPDATE run_profile_tracks SET last_step_at = datetime('now') WHERE id = ?").run(tr.id); + log(db, runId, target.id, "info", `Visiting ${name}`); + const linkedinUrl = await getLinkedinUrl(db, target, accountId); + const page = await getSessionPage(accountId); + let visitResult: { isFirstDegree: boolean; messagingUrn: string | null }; + try { visitResult = await visitProfile(page, linkedinUrl); } finally { await page.close(); } + await saveSessionState(accountId); + if (visitResult.isFirstDegree && target.degree !== 1) { + db.prepare("UPDATE targets SET degree = 1, connected_at = COALESCE(connected_at, ?) WHERE id = ?").run(nowIso(), target.id); + log(db, runId, target.id, "info", `${name} already 1st-degree — backfilled connection status`); + } + if (visitResult.messagingUrn) { + db.prepare("UPDATE targets SET messaging_urn = COALESCE(messaging_urn, ?) WHERE id = ?").run(visitResult.messagingUrn, target.id); + } + trAdvance(db, tr, steps); + log(db, runId, target.id, "info", `Visited ${name}`); + + } else if (step.step_type === "connect") { + if (!enforceSchedule(db, tr, runId, target.id, name, accountLimits)) return; + + const freshTarget = db.prepare("SELECT * FROM targets WHERE id = ?").get(target.id) as Target; + if (freshTarget.degree === 1) { + if (!freshTarget.connected_at) db.prepare("UPDATE targets SET connected_at = ? WHERE id = ?").run(nowIso(), target.id); + log(db, runId, target.id, "info", `${name} already connected — skipping connect step`); + trAdvance(db, tr, steps); + return; + } + + if (freshTarget.connection_requested_at) { + const hoursSinceRequest = hoursSince(freshTarget.connection_requested_at); + if (hoursSinceRequest / 24 > CONNECTION_MAX_WAIT_DAYS) { + log(db, runId, target.id, "warn", `${name} did not accept after ${CONNECTION_MAX_WAIT_DAYS} days — skipping`); + trSkip(db, tr, `Did not accept connection after ${CONNECTION_MAX_WAIT_DAYS} days`); + return; + } + // Acceptance is detected by the daily sync-accepted job (scrolls invitation manager). + // Runner just re-checks degree from DB — no per-profile page visits needed. + log(db, runId, target.id, "info", `${name} not yet accepted — rechecking in ${CONNECTION_RECHECK_HOURS}h`); + trWait(db, tr, CONNECTION_RECHECK_HOURS); + return; + } + + db.prepare("UPDATE run_profile_tracks SET last_step_at = datetime('now') WHERE id = ?").run(tr.id); + log(db, runId, target.id, "info", `Sending connection request to ${name}`); + const linkedinUrl = await getLinkedinUrl(db, target, accountId); + const page = await getSessionPage(accountId); + try { await sendConnectionRequest(page, linkedinUrl); } finally { await page.close(); } + await saveSessionState(accountId); + db.prepare("UPDATE targets SET connection_requested_at = ? WHERE id = ?").run(nowIso(), target.id); + trWait(db, tr, CONNECTION_RECHECK_HOURS); + log(db, runId, target.id, "info", `Connection request sent to ${name} — will recheck in ${CONNECTION_RECHECK_HOURS}h`); + + } else if (step.step_type === "message") { + await ensureSalesNavEnriched(db, target, accountId); + if (!enforceSchedule(db, tr, runId, target.id, name, accountLimits)) return; + + const freshTarget = db.prepare("SELECT * FROM targets WHERE id = ?").get(target.id) as Target; + if (freshTarget.degree !== 1) { + const requested = freshTarget.connection_requested_at; + if (requested && hoursSince(requested) / 24 > CONNECTION_MAX_WAIT_DAYS) { + log(db, runId, target.id, "warn", `${name} never accepted — skipping message step`); + trSkip(db, tr, "Never accepted connection"); + return; + } + log(db, runId, target.id, "info", `${name} not yet connected — rescheduling message in ${CONNECTION_RECHECK_HOURS}h`); + trWait(db, tr, CONNECTION_RECHECK_HOURS); + return; + } + + let messageText = ""; + if (step.ai_enabled) { + if (!premium?.ai) { + log(db, runId, target.id, "warn", `AI writer is a premium feature — not available in this build. Skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'openrouter'").get() as { api_key: string } | undefined; + const agentCfgForMsg = premium.ai.getAgentConfig(); + const resolvedMsgModel = step.ai_model || agentCfgForMsg.default_model; + if (!integration?.api_key || !resolvedMsgModel) { + log(db, runId, target.id, "warn", `AI enabled on message step but OpenRouter key or model missing — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const contactData = premium.ai.getContactWithCompany(target.id); + if (!contactData) { + log(db, runId, target.id, "warn", `Could not load contact data for AI message — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + log(db, runId, target.id, "info", `Generating AI message for ${name} with ${resolvedMsgModel}`); + const msgPosition = step.message_position ?? 1; + let previousMessageContext: { followupNumber: number; previousMessage: string } | undefined; + if (msgPosition > 1 && tr.last_linkedin_message) { + previousMessageContext = { followupNumber: msgPosition - 1, previousMessage: tr.last_linkedin_message }; + } + const result = await premium.ai.writeLinkedInMessage({ + apiKey: decryptSecret(integration.api_key)!, + model: resolvedMsgModel, + stepType: "message", + stepPrompt: step.ai_prompt ?? "", + maxWords: step.ai_max_words ?? undefined, + language: step.ai_language ?? undefined, + campaignPrompt: campaignPrompt ?? undefined, + contact: contactData.contact, + company: contactData.company, + agentConfig: agentCfgForMsg, + previousMessageContext, + runId, + targetId: target.id, + stepId: step.id, + }); + messageText = result.body; + } else { + const multiTemplateIds = (db.prepare("SELECT template_id FROM workflow_step_templates WHERE step_id = ?").all(step.id) as Array<{ template_id: string }>).map(r => r.template_id); + if (multiTemplateIds.length > 0) { + const randomId = multiTemplateIds[Math.floor(Math.random() * multiTemplateIds.length)]; + const tmpl = db.prepare("SELECT * FROM templates WHERE id = ?").get(randomId) as Template | undefined; + if (tmpl) messageText = renderTemplate(tmpl.body, freshTarget); + } else if (step.template_id) { + const tmpl = db.prepare("SELECT * FROM templates WHERE id = ?").get(step.template_id) as Template | undefined; + if (tmpl) messageText = renderTemplate(tmpl.body, freshTarget); + } + if (!messageText && step.message_body) messageText = renderTemplate(step.message_body, freshTarget); + } + if (!messageText) { + log(db, runId, target.id, "warn", `No message body for message step — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + + db.prepare("UPDATE run_profile_tracks SET last_step_at = datetime('now') WHERE id = ?").run(tr.id); + log(db, runId, target.id, "info", `Sending message to ${name}`); + const messageLinkedinUrl = await getLinkedinUrl(db, target, accountId); + const page = await getSessionPage(accountId); + try { + if (!target.full_name) throw new Error(`Target ${target.id} has no full_name — cannot search messaging`); + const result = await sendMessage(page, target.full_name, messageText, messageLinkedinUrl, freshTarget.messaging_urn); + if (result.messagingUrn) { + db.prepare("UPDATE targets SET messaging_urn = COALESCE(messaging_urn, ?) WHERE id = ?").run(result.messagingUrn, target.id); + } + } catch (err) { + if (err instanceof NotConnectedError) { + await saveSessionState(accountId); + db.prepare("UPDATE targets SET degree = NULL, connected_at = NULL WHERE id = ?").run(target.id); + log(db, runId, target.id, "warn", `${name} no longer appears 1st-degree — resetting connection status and rescheduling`); + trWait(db, tr, CONNECTION_RECHECK_HOURS); + return; + } + throw err; + } finally { + await page.close(); + } + await saveSessionState(accountId); + db.prepare("UPDATE targets SET message_sent_at = ? WHERE id = ?").run(nowIso(), target.id); + trRecordContext(db, tr, { linkedinMessage: messageText }); + trAdvance(db, tr, steps); + log(db, runId, target.id, "info", `Message sent to ${name}`); + + } else if (step.step_type === "sales_inmail") { + // Sales Navigator InMail — reaches NON-connections (no degree gate), needs a + // subject + body, costs one InMail credit. Body config mirrors the message + // step (AI writer OR templates OR raw body); subject comes from email_subject. + if (!premium?.inmail) { + log(db, runId, target.id, "warn", `Sales Nav InMail is a premium feature — not available in this build. Skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + await ensureSalesNavEnriched(db, target, accountId); + if (!enforceSchedule(db, tr, runId, target.id, name, accountLimits)) return; + + const freshTarget = db.prepare("SELECT * FROM targets WHERE id = ?").get(target.id) as Target; + if (!freshTarget.sales_nav_url) { + log(db, runId, target.id, "warn", `${name} has no Sales Nav URL — cannot send InMail, skipping`); + trSkip(db, tr, "No Sales Nav URL for InMail"); + return; + } + + let inmailBody = ""; + let inmailSubject = ""; + if (step.ai_enabled) { + if (!premium?.ai) { + log(db, runId, target.id, "warn", `AI writer is a premium feature — not available in this build. Skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'openrouter'").get() as { api_key: string } | undefined; + const agentCfgForMsg = premium.ai.getAgentConfig(); + const resolvedMsgModel = step.ai_model || agentCfgForMsg.default_model; + if (!integration?.api_key || !resolvedMsgModel) { + log(db, runId, target.id, "warn", `AI enabled on InMail step but OpenRouter key or model missing — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const contactData = premium.ai.getContactWithCompany(target.id); + if (!contactData) { + log(db, runId, target.id, "warn", `Could not load contact data for AI InMail — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + log(db, runId, target.id, "info", `Generating AI InMail for ${name} with ${resolvedMsgModel}`); + const msgPosition = step.message_position ?? 1; + let previousMessageContext: { followupNumber: number; previousMessage: string } | undefined; + if (msgPosition > 1 && tr.last_linkedin_message) { + previousMessageContext = { followupNumber: msgPosition - 1, previousMessage: tr.last_linkedin_message }; + } + const result = await premium.ai.writeSalesInMail({ + apiKey: decryptSecret(integration.api_key)!, + model: resolvedMsgModel, + stepType: "sales_inmail", + stepPrompt: step.ai_prompt ?? "", + maxWords: step.ai_max_words ?? undefined, + language: step.ai_language ?? undefined, + campaignPrompt: campaignPrompt ?? undefined, + contact: contactData.contact, + company: contactData.company, + agentConfig: agentCfgForMsg, + previousMessageContext, + runId, + targetId: target.id, + stepId: step.id, + }); + inmailBody = result.body; + inmailSubject = result.subject; + } else { + const multiTemplateIds = (db.prepare("SELECT template_id FROM workflow_step_templates WHERE step_id = ?").all(step.id) as Array<{ template_id: string }>).map(r => r.template_id); + if (multiTemplateIds.length > 0) { + const randomId = multiTemplateIds[Math.floor(Math.random() * multiTemplateIds.length)]; + const tmpl = db.prepare("SELECT * FROM templates WHERE id = ?").get(randomId) as Template | undefined; + if (tmpl) inmailBody = renderTemplate(tmpl.body, freshTarget); + } else if (step.template_id) { + const tmpl = db.prepare("SELECT * FROM templates WHERE id = ?").get(step.template_id) as Template | undefined; + if (tmpl) inmailBody = renderTemplate(tmpl.body, freshTarget); + } + if (!inmailBody && step.message_body) inmailBody = renderTemplate(step.message_body, freshTarget); + inmailSubject = renderTemplate(step.email_subject ?? "", freshTarget).trim(); + } + if (!inmailBody) { + log(db, runId, target.id, "warn", `No body for InMail step — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + if (!inmailSubject) { + log(db, runId, target.id, "warn", `No subject for InMail step (required) — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + + db.prepare("UPDATE run_profile_tracks SET last_step_at = datetime('now') WHERE id = ?").run(tr.id); + log(db, runId, target.id, "info", `Sending InMail to ${name}`); + const page = await getSessionPage(accountId); + try { + await premium.inmail.sendInMail(page, freshTarget.sales_nav_url, inmailSubject, inmailBody); + } finally { + await page.close(); + } + await saveSessionState(accountId); + db.prepare("UPDATE targets SET inmail_sent_at = ?, message_sent_at = COALESCE(message_sent_at, ?) WHERE id = ?").run(nowIso(), nowIso(), target.id); + trRecordContext(db, tr, { linkedinMessage: inmailBody }); + trAdvance(db, tr, steps); + log(db, runId, target.id, "info", `InMail sent to ${name}`); + + } else if (step.step_type === "email") { + await ensureApolloEnriched(db, target, runId); + + if (!emailAccountId || !emailAccountLimits) { + log(db, runId, target.id, "warn", `Email step skipped — no email account configured on this run`); + trAdvance(db, tr, steps); + return; + } + + if (!enforceSchedule(db, tr, runId, target.id, name, emailAccountLimits)) return; + + const freshTarget = db.prepare("SELECT * FROM targets WHERE id = ?").get(target.id) as Target; + if (!freshTarget.email) { + // No email even after Apollo enrichment — skip only this email track + log(db, runId, target.id, "warn", `${name} has no email address — skipping email track`); + trSkip(db, tr, "No email address found"); + return; + } + if (freshTarget.email_status === "invalid") { + log(db, runId, target.id, "warn", `${name} has an invalid email address — unenrolling email track`); + trSkip(db, tr, "Email bounced — invalid address"); + return; + } + if (freshTarget.company_id) { + const company = db.prepare("SELECT email_domain_invalid FROM companies WHERE id = ?").get(freshTarget.company_id) as { email_domain_invalid: number } | undefined; + if (company?.email_domain_invalid) { + log(db, runId, target.id, "warn", `${name}'s company email domain is flagged invalid — unenrolling email track`); + trSkip(db, tr, "Email domain invalid — company flagged"); + return; + } + } + + let emailSubject = ""; + let emailBody = ""; + if (step.ai_enabled) { + if (!premium?.ai) { + log(db, runId, target.id, "warn", `AI writer is a premium feature — not available in this build. Skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'openrouter'").get() as { api_key: string } | undefined; + const agentCfgForEmail = premium.ai.getAgentConfig(); + const resolvedEmailModel = step.ai_model || agentCfgForEmail.default_model; + if (!integration?.api_key || !resolvedEmailModel) { + log(db, runId, target.id, "warn", `AI enabled on email step but OpenRouter key or model missing — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + const contactData = premium.ai.getContactWithCompany(target.id); + if (!contactData) { + log(db, runId, target.id, "warn", `Could not load contact data for AI email — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + log(db, runId, target.id, "info", `Generating AI email for ${name} with ${resolvedEmailModel}`); + const emailPosition = step.email_position ?? 1; + let followupContext: { followupNumber: number; previousSubject: string; previousBody: string } | undefined; + if (emailPosition > 1 && (tr.last_email_subject || tr.last_email_body)) { + followupContext = { + followupNumber: emailPosition - 1, + previousSubject: tr.last_email_subject ?? "", + previousBody: tr.last_email_body ?? "", + }; + } + const result = await premium.ai.writeEmail({ + apiKey: decryptSecret(integration.api_key)!, + model: resolvedEmailModel, + stepType: "email", + stepPrompt: step.ai_prompt ?? "", + maxWords: step.ai_max_words ?? undefined, + language: step.ai_language ?? undefined, + campaignPrompt: campaignPrompt ?? undefined, + contact: contactData.contact, + company: contactData.company, + agentConfig: agentCfgForEmail, + followupContext, + replyContext: tr.pending_reply_context ?? undefined, + runId, + targetId: target.id, + stepId: step.id, + }); + emailSubject = result.subject; + emailBody = result.body; + // One-shot: consume the OOO reply context so later follow-ups don't re-acknowledge it + if (tr.pending_reply_context) { + db.prepare("UPDATE run_profile_tracks SET pending_reply_context = NULL WHERE id = ?").run(tr.id); + } + } else { + emailSubject = renderTemplate(step.email_subject ?? "", freshTarget); + emailBody = renderTemplate(step.email_body ?? "", freshTarget); + } + + if (!emailBody) { + log(db, runId, target.id, "warn", `No email body for email step — skipping ${name}`); + trAdvance(db, tr, steps); + return; + } + + const emailAccount = db.prepare("SELECT * FROM email_accounts WHERE id = ?").get(emailAccountId) as { + id: string; from_email: string; from_name: string | null; reply_to: string | null; + smtp_host: string; smtp_port: number; smtp_secure: number; + username: string; password: string; signature: string | null; + } | undefined; + + if (!emailAccount) { + log(db, runId, target.id, "error", `Email account ${emailAccountId} not found`); + trFail(db, tr, "Email account missing"); + return; + } + + // Last-line-of-defense: re-check the daily limit for this email account against ground-truth + // (matched by run_profiles.email_account_id, the actual sender). If any prior gate is buggy, + // this catches the overshoot and reschedules instead of sending. + const sentTodayActual = (db.prepare( + `SELECT COUNT(*) as c FROM logs l + WHERE l.message LIKE 'Email sent%' + AND date(l.created_at) = date('now') + AND EXISTS ( + SELECT 1 FROM run_profiles rp + WHERE rp.run_id = l.run_id AND rp.target_id = l.target_id + AND rp.email_account_id = ? + )` + ).get(emailAccountId) as { c: number }).c; + const hardLimit = effectiveEmailLimit(emailAccountLimits); + if (sentTodayActual >= hardLimit) { + log(db, runId, target.id, "warn", `Daily limit guard tripped for ${emailAccountId} (${sentTodayActual}/${hardLimit}) — rescheduling ${name} to tomorrow`); + trReschedule(db, tr, rescheduleToTomorrow(emailAccountLimits)); + return; + } + + // Step-level signature takes precedence; null means fall back to email account default + const sig = (step.email_signature !== null ? step.email_signature : emailAccount.signature)?.trim(); + const finalEmailBody = sig ? `${emailBody}\n\n--\n${sig}` : emailBody; + db.prepare("UPDATE run_profile_tracks SET last_step_at = datetime('now') WHERE id = ?").run(tr.id); + log(db, runId, target.id, "info", `Sending email to ${name} <${freshTarget.email}>`); + await sendEmail({ ...emailAccount, password: decryptSecret(emailAccount.password)! }, freshTarget.email, emailSubject, finalEmailBody); + trRecordContext(db, tr, { emailSubject, emailBody }); + trAdvance(db, tr, steps); + log(db, runId, target.id, "info", `Email sent to ${name}`); + } + + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (err instanceof WeeklyLimitError) { + log(db, runId, target.id, "error", `Weekly connection limit reached — pausing run`); + db.prepare("UPDATE runs SET status = 'paused' WHERE id = ?").run(runId); + return; + } + if (err instanceof AlreadyConnectedError) { + log(db, runId, target.id, "info", `${name} already connected — advancing`); + db.prepare("UPDATE targets SET degree = 1, connected_at = COALESCE(connected_at, ?) WHERE id = ?").run(nowIso(), target.id); + trAdvance(db, tr, steps); + return; + } + if (err instanceof PendingInviteError) { + log(db, runId, target.id, "info", `${name} invite already pending — will recheck`); + if (!target.connection_requested_at) db.prepare("UPDATE targets SET connection_requested_at = ? WHERE id = ?").run(nowIso(), target.id); + trWait(db, tr, CONNECTION_RECHECK_HOURS); + return; + } + if (msg.includes("No InMail credits left")) { + inmailCreditsExhaustedOn[accountId] = todayLocalDate(); + const slot = rescheduleToTomorrow(accountLimits); + log(db, runId, target.id, "warn", `No InMail credits left on this account — pausing InMail sends until tomorrow, rescheduled ${name} to ${slot}`); + trReschedule(db, tr, slot); + return; + } + log(db, runId, target.id, "error", `Error on ${name}: ${msg}`); + trFail(db, tr, msg); + } +} + +// ─── global loop ───────────────────────────────────────────────────────────── + +const g = global as typeof global & { __linkiGlobalRunnerStarted?: boolean }; + +export function ensureGlobalRunnerStarted(): void { + if (g.__linkiGlobalRunnerStarted) return; + g.__linkiGlobalRunnerStarted = true; + globalLoop().catch(err => console.error("[runner] Global loop crashed:", err)); +} + +async function globalLoop(): Promise { + console.log("[runner] Global loop started"); + const db = getDb(); + + while (true) { + try { + await tick(db); + } catch (err) { + console.error("[runner] Tick error:", err instanceof Error ? err.message : err); + } + try { + const { processScheduledImports } = await import("@/lib/import-jobs"); + await processScheduledImports(db); + } catch (err) { + console.error("[runner] Import scheduler error:", err instanceof Error ? err.message : err); + } + await sleep(POLL_INTERVAL_MS); + } +} + +async function tick(db: ReturnType): Promise { + const activeRuns = db.prepare(` + SELECT r.id as run_id, r.workflow_id, r.account_id, r.email_account_id, + a.daily_connection_limit, a.daily_message_limit, a.daily_inmail_limit, + a.active_hours_start, a.active_hours_end, a.timezone, a.working_days + FROM runs r + JOIN accounts a ON a.id = r.account_id + WHERE r.status = 'running' AND a.is_authenticated = 1 + `).all() as Array<{ run_id: string; workflow_id: string; account_id: string; email_account_id: string | null } & AccountLimits>; + + if (activeRuns.length === 0) return; + + console.log(`[runner] Tick — ${activeRuns.length} active run(s)`); + + const seenAccounts = new Set(); + for (const run of activeRuns) { + if (seenAccounts.has(run.account_id)) continue; + seenAccounts.add(run.account_id); + } + + // Daily sync: stamp accepted connections from invitation manager (once per 23h per account) + for (const accountId of seenAccounts) { + if (shouldSyncAccepted(accountId)) { + try { + console.log(`[runner] Starting accepted-connections sync for account ${accountId}`); + const stamped = await syncAcceptedConnections(accountId); + if (stamped > 0) { + for (const r of activeRuns.filter(x => x.account_id === accountId)) { + log(db, r.run_id, null, "info", `Accepted-connections sync: ${stamped} contact${stamped === 1 ? "" : "s"} marked as connected`); + } + } + console.log(`[runner] Accepted-connections sync complete — ${stamped} stamped`); + } catch (e) { + console.warn("[runner] Accepted-connections sync error:", e instanceof Error ? e.message : e); + } + } + } + + // LinkedIn inbox reply detection (messaging GraphQL) — once per 15min per + // account. Sets targets.last_replied_at so the runner auto-unenrolls repliers. + // LinkedIn reply detection is a premium feature (AI classifier layer) — no-op without ee/. + for (const accountId of seenAccounts) { + if (premium?.replies?.shouldSyncInbox(accountId)) { + try { + console.log(`[runner] Starting LinkedIn inbox sync for account ${accountId}`); + const replies = await premium.replies.syncAccountInbox(accountId); + console.log(`[runner] LinkedIn inbox sync complete — ${replies} new repl${replies === 1 ? "y" : "ies"}`); + if (replies > 0) { + for (const r of activeRuns.filter(x => x.account_id === accountId)) { + log(db, r.run_id, null, "info", `LinkedIn inbox sync: ${replies} new repl${replies === 1 ? "y" : "ies"} detected`); + } + } + } catch (e) { + console.warn("[runner] LinkedIn inbox sync error:", e instanceof Error ? e.message : e); + } + } + } + + // Sync email IMAP inboxes for each unique email account in active run profiles + const activeRunIds = activeRuns.map(r => r.run_id); + const activeEmailAccountIds: string[] = activeRunIds.length > 0 + ? [...new Set( + (db.prepare( + `SELECT DISTINCT rp.email_account_id FROM run_profiles rp + JOIN run_profile_tracks rt ON rt.run_profile_id = rp.id + WHERE rp.run_id IN (${activeRunIds.map(() => "?").join(",")}) + AND rp.email_account_id IS NOT NULL + AND rt.state NOT IN ('completed', 'failed', 'skipped')` + ).all(...activeRunIds) as { email_account_id: string }[]).map(r => r.email_account_id) + )] + : []; + + const seenEmailAccounts = new Set(); + for (const emailAccId of activeEmailAccountIds) { + if (seenEmailAccounts.has(emailAccId)) continue; + seenEmailAccounts.add(emailAccId); + if (shouldSyncEmailInbox(emailAccId)) { + try { + console.log(`[runner] Starting IMAP sync for email account ${emailAccId}`); + const { replies, bounces } = await syncEmailInbox(emailAccId); + console.log(`[runner] IMAP sync complete — ${replies} replies, ${bounces} bounces`); + for (const runId of activeRunIds) { + if (replies > 0) log(db, runId, null, "info", `Email inbox sync: ${replies} new repl${replies === 1 ? "y" : "ies"} detected`); + if (bounces > 0) log(db, runId, null, "warn", `Email inbox sync: ${bounces} bounce${bounces === 1 ? "" : "s"} detected — contacts marked invalid and unenrolled`); + } + } catch (e) { + console.warn("[runner] Email inbox sync error:", e instanceof Error ? e.message : e); + } + // Space out back-to-back IMAP syncs — a 20-account burst with zero gap + // between them held the host busy for minutes straight (Jul 2026 incident). + await sleep(2000); + } + } + + // Auto-complete runs where ALL track-runs across all profiles are terminal + for (const run of activeRuns) { + const remaining = (db.prepare( + `SELECT COUNT(*) as c FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + WHERE rp.run_id = ? AND rt.state NOT IN ('completed', 'failed', 'skipped')` + ).get(run.run_id) as { c: number }).c; + if (remaining === 0) { + db.prepare("UPDATE runs SET status = 'completed', completed_at = datetime('now') WHERE id = ?").run(run.run_id); + log(db, run.run_id, null, "info", "All profiles processed — run completed"); + } + } + + // Re-load active runs after potential completions + const stillActive = db.prepare(` + SELECT r.id as run_id, r.workflow_id, r.account_id, r.email_account_id, + a.daily_connection_limit, a.daily_message_limit, a.daily_inmail_limit, + a.active_hours_start, a.active_hours_end, a.timezone, a.working_days + FROM runs r + JOIN accounts a ON a.id = r.account_id + WHERE r.status = 'running' + `).all() as Array<{ run_id: string; workflow_id: string; account_id: string; email_account_id: string | null } & AccountLimits>; + + if (stillActive.length === 0) return; + + const accountLimitsMap = new Map(); + for (const run of stillActive) { + if (!accountLimitsMap.has(run.account_id)) accountLimitsMap.set(run.account_id, run); + } + + // Build email account limits map + const stillActiveRunIds = stillActive.map(r => r.run_id); + const emailAccountIds: string[] = stillActiveRunIds.length > 0 + ? [...new Set( + (db.prepare( + `SELECT DISTINCT rp.email_account_id FROM run_profiles rp + WHERE rp.run_id IN (${stillActiveRunIds.map(() => "?").join(",")}) + AND rp.email_account_id IS NOT NULL` + ).all(...stillActiveRunIds) as { email_account_id: string }[]).map(r => r.email_account_id) + )] + : []; + const emailAccountLimitsMap = new Map(); + for (const emailAccountId of emailAccountIds) { + const ea = db.prepare("SELECT daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, ramp_up_enabled, ramp_start_date FROM email_accounts WHERE id = ?").get(emailAccountId) as EmailAccountLimits | undefined; + if (ea) emailAccountLimitsMap.set(emailAccountId, ea); + } + + // Count actions already done today per LinkedIn account — messages and InMail are + // counted separately so a busy message quota never starves InMail sends (and vice versa). + const connectsSentToday = new Map(); + const messagesSentToday = new Map(); + const inmailsSentToday = new Map(); + for (const [accountId] of accountLimitsMap) { + const c = (db.prepare( + `SELECT COUNT(*) as c FROM logs WHERE run_id IN (SELECT id FROM runs WHERE account_id = ?) + AND message LIKE 'Connection request sent%' AND date(created_at) = date('now')` + ).get(accountId) as { c: number }).c; + const m = (db.prepare( + `SELECT COUNT(*) as c FROM logs WHERE run_id IN (SELECT id FROM runs WHERE account_id = ?) + AND message LIKE 'Message sent%' AND date(created_at) = date('now')` + ).get(accountId) as { c: number }).c; + const im = (db.prepare( + `SELECT COUNT(*) as c FROM logs WHERE run_id IN (SELECT id FROM runs WHERE account_id = ?) + AND message LIKE 'InMail sent%' AND date(created_at) = date('now')` + ).get(accountId) as { c: number }).c; + connectsSentToday.set(accountId, c); + messagesSentToday.set(accountId, m); + inmailsSentToday.set(accountId, im); + } + + // Count emails sent today per email account — match by run_profiles.email_account_id + // (the actual sending account), not runs.email_account_id (which may differ when accounts rotate) + const emailsSentToday = new Map(); + for (const emailAccountId of emailAccountIds) { + const e = (db.prepare( + `SELECT COUNT(*) as c FROM logs l + WHERE l.message LIKE 'Email sent%' + AND date(l.created_at) = date('now') + AND EXISTS ( + SELECT 1 FROM run_profiles rp + WHERE rp.run_id = l.run_id AND rp.target_id = l.target_id + AND rp.email_account_id = ? + )` + ).get(emailAccountId) as { c: number }).c; + emailsSentToday.set(emailAccountId, e); + } + + // Steps cache: (workflow_id, track) → steps filtered by that track + const stepsCache = new Map(); + const getSteps = (workflowId: string, track: string): WorkflowStep[] => { + const key = `${workflowId}|${track}`; + if (!stepsCache.has(key)) { + stepsCache.set(key, db.prepare( + "SELECT * FROM workflow_steps WHERE workflow_id = ? AND track = ? ORDER BY step_order" + ).all(workflowId, track) as WorkflowStep[]); + } + return stepsCache.get(key)!; + }; + + // Workflow prompt cache: workflow_id → campaign prompt string (or null) + const workflowPromptCache = new Map(); + const getWorkflowPrompt = (workflowId: string): string | null => { + if (!workflowPromptCache.has(workflowId)) { + const row = db.prepare("SELECT prompt FROM workflows WHERE id = ?").get(workflowId) as { prompt: string | null } | undefined; + workflowPromptCache.set(workflowId, row?.prompt ?? null); + } + return workflowPromptCache.get(workflowId) ?? null; + }; + + // Collect ALL due track-runs across all active runs, oldest-due first + const runIds = stillActive.map(r => r.run_id); + const placeholders = runIds.map(() => "?").join(","); + const dueTrackRuns = db.prepare( + `SELECT rt.id, rt.run_profile_id, rt.track, rt.state, rt.current_step, rt.next_step_at, + rt.error_message, rt.last_email_subject, rt.last_email_body, rt.last_linkedin_message, + rt.pending_reply_context, + rp.run_id, rp.target_id, rp.email_account_id, + r.account_id, r.workflow_id, + t.connection_requested_at + FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + JOIN runs r ON r.id = rp.run_id + JOIN targets t ON t.id = rp.target_id + WHERE rp.run_id IN (${placeholders}) + AND rt.state = 'in_progress' + AND (rt.next_step_at IS NULL OR datetime(rt.next_step_at) <= datetime('now')) + ORDER BY rt.next_step_at ASC` + ).all(...runIds) as TrackRun[]; + + // Enroll new pending track-runs — track remaining slots per account across runs. + // Enrollment (pending -> in_progress) happens exactly once per track-run, on its + // FIRST linkedin-track step, so the budget it draws from must match that step's + // type — a workflow can open on "connect" (e.g. connect -> message) or on + // "sales_inmail" (e.g. an InMail-first campaign), and each type has its own daily cap. + const connectSlotsRemaining = new Map(); + const inmailSlotsRemaining = new Map(); + const firstLinkedinStepCache = new Map(); + const getFirstLinkedinStepType = (workflowId: string): string | undefined => { + if (!firstLinkedinStepCache.has(workflowId)) { + const row = db.prepare( + "SELECT step_type FROM workflow_steps WHERE workflow_id = ? AND track = 'linkedin' ORDER BY step_order LIMIT 1" + ).get(workflowId) as { step_type: string } | undefined; + firstLinkedinStepCache.set(workflowId, row?.step_type); + } + return firstLinkedinStepCache.get(workflowId); + }; + const enrolledEmailPairs = new Set(); + for (const run of stillActive) { + const limits = accountLimitsMap.get(run.account_id)!; + const firstStepType = getFirstLinkedinStepType(run.workflow_id); + const isInmailFirst = firstStepType === "sales_inmail"; + const slotsRemaining = isInmailFirst ? inmailSlotsRemaining : connectSlotsRemaining; + + // LinkedIn track enrollment — each run gets its own enrollment, but all runs + // for the same account share the daily slot budget for that action type + if (!slotsRemaining.has(run.account_id)) { + const dailyLimit = isInmailFirst ? (limits.daily_inmail_limit ?? 15) : (limits.daily_connection_limit ?? 20); + const sentToday = isInmailFirst + ? (inmailsSentToday.get(run.account_id) ?? 0) + : (connectsSentToday.get(run.account_id) ?? 0); + const actionsLeft = Math.max(0, dailyLimit - sentToday); + const firstStepTypeSql = isInmailFirst ? "'sales_inmail'" : "'connect'"; + const scheduledToday = (db.prepare( + `SELECT COUNT(*) as c FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + JOIN runs r ON r.id = rp.run_id + JOIN workflow_steps ws ON ws.workflow_id = r.workflow_id AND ws.track = 'linkedin' AND ws.step_order = 1 + WHERE r.account_id = ? AND rt.track = 'linkedin' AND rt.state = 'in_progress' + AND ws.step_type = ${firstStepTypeSql} + AND date(datetime(rt.next_step_at)) = date('now')` + ).get(run.account_id) as { c: number }).c; + slotsRemaining.set(run.account_id, Math.max(0, actionsLeft - scheduledToday)); + } + const slotsLeft = slotsRemaining.get(run.account_id)!; + if (slotsLeft > 0) { + const toEnroll = Math.min(slotsLeft, 5); + const pending = db.prepare( + `SELECT rt.id, rt.run_profile_id, rt.track FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + WHERE rp.run_id = ? AND rt.track = 'linkedin' AND rt.state = 'pending' + ORDER BY rt.id LIMIT ?` + ).all(run.run_id, toEnroll) as Array<{ id: string; run_profile_id: string; track: string }>; + spreadEnrollBatch(db, run.run_id, pending, limits, "linkedin"); + slotsRemaining.set(run.account_id, slotsLeft - pending.length); + } + + // Email track enrollment — iterate per actual sending account used by this run's profiles + // (run_profiles.email_account_id may differ from runs.email_account_id when accounts are rotated) + const runEmailAccountIds = (db.prepare( + `SELECT DISTINCT rp.email_account_id FROM run_profiles rp + WHERE rp.run_id = ? AND rp.email_account_id IS NOT NULL` + ).all(run.run_id) as { email_account_id: string }[]).map(r => r.email_account_id); + + for (const emailAccId of runEmailAccountIds) { + const emailKey = `${run.run_id}|${emailAccId}|email`; + if (!enrolledEmailPairs.has(emailKey)) { + enrolledEmailPairs.add(emailKey); + const emailLimits = emailAccountLimitsMap.get(emailAccId); + if (emailLimits) { + const effectiveLimit = effectiveEmailLimit(emailLimits); + const emailsLeft = Math.max(0, effectiveLimit - (emailsSentToday.get(emailAccId) ?? 0)); + const emailScheduledToday = (db.prepare( + `SELECT COUNT(*) as c FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + WHERE rp.email_account_id = ? AND rt.track = 'email' AND rt.state = 'in_progress' + AND date(datetime(rt.next_step_at)) = date('now')` + ).get(emailAccId) as { c: number }).c; + const emailSlotsLeft = Math.max(0, emailsLeft - emailScheduledToday); + if (emailSlotsLeft > 0) { + const pendingEmail = db.prepare( + `SELECT rt.id, rt.run_profile_id, rt.track FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + WHERE rp.run_id = ? AND rp.email_account_id = ? AND rt.track = 'email' AND rt.state = 'pending' + ORDER BY rt.id LIMIT ?` + ).all(run.run_id, emailAccId, Math.min(emailSlotsLeft, 5)) as Array<{ id: string; run_profile_id: string; track: string }>; + spreadEnrollBatch(db, run.run_id, pendingEmail, emailLimits, "email"); + } + } + } + } + } + + if (dueTrackRuns.length === 0) return; + + // Apply daily limits — separate due track-runs into execute vs reschedule + const toExecute: TrackRun[] = []; + const toReschedule: TrackRun[] = []; + + const connectsPlanned = new Map(Array.from(accountLimitsMap.keys()).map(id => [id, 0])); + const messagesPlanned = new Map(Array.from(accountLimitsMap.keys()).map(id => [id, 0])); + const inmailsPlanned = new Map(Array.from(accountLimitsMap.keys()).map(id => [id, 0])); + const emailsPlanned = new Map(emailAccountIds.map(id => [id, 0])); + + for (const tr of dueTrackRuns) { + const steps = getSteps(tr.workflow_id, tr.track); + const stepIndex = tr.current_step; + if (stepIndex >= steps.length) { toExecute.push(tr); continue; } + const step = steps[stepIndex]; + const limits = accountLimitsMap.get(tr.account_id)!; + + if (step.step_type === "connect") { + // A connect step is "due" both when it's about to send a NEW request and when + // it's just rechecking an already-sent one for acceptance (see the `degree === 1` + // check in executeStep). Only the former spends a daily connect slot — the recheck + // is a free DB read and must never be blocked by the cap, or an accepted connection + // can never hand off to the next step (it'd be rescheduled behind new sends forever). + if (tr.connection_requested_at) { + toExecute.push(tr); + continue; + } + const sentToday = connectsSentToday.get(tr.account_id) ?? 0; + const planned = connectsPlanned.get(tr.account_id) ?? 0; + if (sentToday + planned >= (limits.daily_connection_limit ?? 20)) { + toReschedule.push(tr); + } else { + connectsPlanned.set(tr.account_id, planned + 1); + toExecute.push(tr); + } + } else if (step.step_type === "message") { + const sentToday = messagesSentToday.get(tr.account_id) ?? 0; + const planned = messagesPlanned.get(tr.account_id) ?? 0; + if (sentToday + planned >= (limits.daily_message_limit ?? 50)) { + toReschedule.push(tr); + } else { + messagesPlanned.set(tr.account_id, planned + 1); + toExecute.push(tr); + } + } else if (step.step_type === "sales_inmail") { + const sentToday = inmailsSentToday.get(tr.account_id) ?? 0; + const planned = inmailsPlanned.get(tr.account_id) ?? 0; + if (inmailCreditsExhaustedToday(tr.account_id) || sentToday + planned >= (limits.daily_inmail_limit ?? 15)) { + toReschedule.push(tr); + } else { + inmailsPlanned.set(tr.account_id, planned + 1); + toExecute.push(tr); + } + } else if (step.step_type === "email") { + const profileEmailAccountId = tr.email_account_id; + if (!profileEmailAccountId) { + toExecute.push(tr); + } else { + const emailLimits = emailAccountLimitsMap.get(profileEmailAccountId); + const sentToday = emailsSentToday.get(profileEmailAccountId) ?? 0; + const planned = emailsPlanned.get(profileEmailAccountId) ?? 0; + const effectiveLimit = emailLimits ? effectiveEmailLimit(emailLimits) : 50; + if (sentToday + planned >= effectiveLimit) { + toReschedule.push(tr); + } else { + emailsPlanned.set(profileEmailAccountId, planned + 1); + toExecute.push(tr); + } + } + } else { + // visit, delay — no limit + toExecute.push(tr); + } + } + + // Reschedule overflow to tomorrow (use LinkedIn account schedule for reschedule) + for (const tr of toReschedule) { + const limits = accountLimitsMap.get(tr.account_id)!; + const slot = rescheduleToTomorrow(limits); + db.prepare("UPDATE run_profile_tracks SET next_step_at = ? WHERE id = ?").run(slot, tr.id); + log(db, tr.run_id, tr.target_id, "info", `Daily limit reached — rescheduled to ${slot}`); + } + + // Execute what's left + for (const tr of toExecute) { + const steps = getSteps(tr.workflow_id, tr.track); + const limits = accountLimitsMap.get(tr.account_id)!; + const emailAccountId = tr.email_account_id ?? null; + const emailLimits = emailAccountId ? (emailAccountLimitsMap.get(emailAccountId) ?? null) : null; + + const runStatus = db.prepare("SELECT status FROM runs WHERE id = ?").get(tr.run_id) as { status: string } | undefined; + if (!runStatus || runStatus.status !== "running") continue; + + const target = db.prepare("SELECT * FROM targets WHERE id = ?").get(tr.target_id) as Target; + await executeStep(db, tr.run_id, tr, target, steps, tr.account_id, limits, emailAccountId, emailLimits, getWorkflowPrompt(tr.workflow_id)); + await randomDelay(PROFILE_DELAY_MIN, PROFILE_DELAY_MAX); + } +} + +function spreadEnrollBatch( + db: ReturnType, + runId: string, + pending: Array<{ id: string; run_profile_id: string; track: string }>, + limits: ScheduleConfig, + track: string +) { + const batchSize = pending.length; + if (batchSize === 0) return; + const start = limits.active_hours_start ?? 9; + const end = limits.active_hours_end ?? 18; + const { hour, minute } = getLocalParts(limits.timezone || "UTC"); + const nowFrac = hour + minute / 60; + const windowMs = (end - start) * 3600_000; + const bucketMs = windowMs / batchSize; + const dayStartMs = new Date().setHours(start, 0, 0, 0); + + for (let i = 0; i < pending.length; i++) { + const row = pending[i]; + const claimed = db.prepare( + "UPDATE run_profile_tracks SET state = 'in_progress' WHERE id = ? AND state = 'pending'" + ).run(row.id); + if (claimed.changes === 0) continue; + const slot = (() => { + if (nowFrac >= end - 0.25) return rescheduleToTomorrow(limits); + const bucketStart = dayStartMs + i * bucketMs; + const bucketEnd = bucketStart + bucketMs; + return new Date(bucketStart + Math.random() * (bucketEnd - bucketStart)).toISOString(); + })(); + db.prepare("UPDATE run_profile_tracks SET next_step_at = ? WHERE id = ?").run(slot, row.id); + const tgt = db.prepare("SELECT full_name, linkedin_url FROM targets WHERE id = (SELECT target_id FROM run_profiles WHERE id = ?)").get(row.run_profile_id) as { full_name: string | null; linkedin_url: string } | undefined; + log(db, runId, null, "info", `[${track}] Scheduled ${tgt?.full_name ?? tgt?.linkedin_url ?? row.run_profile_id} within active window`); + } +} + +// ─── public API ────────────────────────────────────────────────────────────── + +export function startRun(runId: string): void { + const db = getDb(); + db.prepare("UPDATE runs SET status = 'running', started_at = COALESCE(started_at, datetime('now')) WHERE id = ?").run(runId); + console.log(`[runner] Run ${runId} marked running — global loop will pick it up`); +} diff --git a/lib/linkedin/scraper.ts b/lib/linkedin/scraper.ts new file mode 100644 index 0000000..2bf00dd --- /dev/null +++ b/lib/linkedin/scraper.ts @@ -0,0 +1,453 @@ +/** + * Sales Navigator list scraper using the internal Sales API. + * Uses Playwright's browser context request (inherits browser TLS fingerprint). + * + * Response format discovery (from capture scripts): + * - ctx.request returns normalized JSON: { data: { metadata: { totalDisplayCount: "106" } }, included: [...] } + * - Profiles are in `included` filtered by entityUrn containing "salesProfile" + * - Browser-intercepted first page returns flat: { elements: [...], paging: { total } } + * - Query format: parentheses/commas unencoded, colons in URN encoded as %3A + * + * IMPORTANT — vanityName is gone from salesApiPeopleSearch (dropped by LinkedIn ~March 2026): + * The list API no longer returns vanityName. To get the real /in/ URL we call + * salesApiProfiles per batch of 25 after scraping, using flagshipProfileUrl. + * See docs/linkedin-api-learnings.md for the full investigation. + */ +import type { BrowserContext, Page } from "playwright"; + +export interface ScrapedProfile { + salesNavUrn: string; + salesNavUrl: string; + linkedinUrl: string | null; // regular /in/ URL + fullName: string | null; + firstName: string | null; + lastName: string | null; + title: string | null; + company: string | null; + location: string | null; + degree: number | null; + // Extended fields + objectUrn: string | null; // urn:li:member:XXXX — stable LinkedIn member ID + summary: string | null; // About / bio text + openLink: boolean; // can message without connecting + companyIndustry: string | null; + companyLocation: string | null; // company HQ location + tenureMonths: number | null; // months in current role + spotlightBadges: string | null; // JSON array of badge displayValues +} + +interface SalesProfile { + entityUrn: string; + objectUrn?: string; + firstName?: string; + lastName?: string; + fullName?: string; + vanityName?: string; + geoRegion?: string; + degree?: number; + summary?: string; + openLink?: boolean; + premium?: boolean; + pendingInvitation?: boolean; + currentPositions?: Array<{ + title?: string; + companyName?: string; + companyUrn?: string; + current?: boolean; + description?: string; + startedOn?: { year?: number; month?: number }; + tenureAtPosition?: { numYears?: number; numMonths?: number }; + tenureAtCompany?: { numYears?: number; numMonths?: number }; + companyUrnResolutionResult?: { + name?: string; + industry?: string; + location?: string; + entityUrn?: string; + }; + }>; + leadAssociatedAccount?: { name?: string } | null; + spotlightBadges?: Array<{ displayValue?: string; id?: string }>; +} + +// Flat response intercepted from browser +interface FlatResponse { + elements?: SalesProfile[]; + paging?: { total: number; count: number; start: number }; +} + +// salesApiProfiles single-profile response +interface ProfileDetailResponse { + entityUrn?: string; + flagshipProfileUrl?: string; + fullName?: string; +} + +function extractListId(url: string): string | null { + const match = url.match(/\/sales\/lists\/people\/(\d+)/); + return match ? match[1] : null; +} + +function extractSavedSearchId(url: string): string | null { + const match = url.match(/[?&]savedSearchId=(\d+)/); + return match ? match[1] : null; +} + +function urnToSalesNavUrl(urn: string): string { + const match = urn.match(/\(([^)]+)\)/); + if (!match) return ""; + return `https://www.linkedin.com/sales/lead/${match[1]}`; +} + +/** + * Parse profileId, authType, authToken out of a salesProfile URN. + * urn:li:fs_salesProfile:(ACwAAEIY-4YB25mKP6R5AKfkFjhO9isSbvsVlag,NAME_SEARCH,22wq) + */ +function parseUrn(entityUrn: string): { profileId: string; authType: string; authToken: string } | null { + const match = entityUrn.match(/\(([^,]+),([^,]+),([^)]+)\)/); + if (!match) return null; + return { profileId: match[1], authType: match[2], authToken: match[3] }; +} + +function profileToResult(el: SalesProfile, linkedinUrl: string | null): ScrapedProfile { + const currentPos = el.currentPositions?.find((p) => p.current) ?? el.currentPositions?.[0]; + const company = currentPos?.companyUrnResolutionResult ?? null; + + // Tenure in months at current position + let tenureMonths: number | null = null; + const t = currentPos?.tenureAtPosition; + if (t) tenureMonths = (t.numYears ?? 0) * 12 + (t.numMonths ?? 0); + + // Spotlight badge labels as a compact JSON array e.g. ["Changed jobs", "Mentioned in news"] + const badges = (el.spotlightBadges ?? []) + .map((b) => b.displayValue) + .filter(Boolean) as string[]; + + return { + salesNavUrn: el.entityUrn, + salesNavUrl: urnToSalesNavUrl(el.entityUrn), + linkedinUrl, + fullName: el.fullName ?? null, + firstName: el.firstName ?? null, + lastName: el.lastName ?? null, + title: currentPos?.title ?? null, + company: el.leadAssociatedAccount?.name ?? company?.name ?? currentPos?.companyName ?? null, + location: el.geoRegion ?? null, + degree: el.degree ?? null, + objectUrn: el.objectUrn ?? null, + summary: el.summary ?? null, + openLink: el.openLink ?? false, + companyIndustry: company?.industry ?? null, + companyLocation: company?.location ?? null, + tenureMonths, + spotlightBadges: badges.length > 0 ? JSON.stringify(badges) : null, + }; +} + +/** + * Enrich profiles with their real /in/ URL via salesApiProfiles (flagshipProfileUrl). + * + * Uses page.evaluate fetch with JSESSIONID csrf-token. On 429 (rate limit), + * backs off for 30s and retries. Delay between calls: 2s normally, 30s after 429. + * + * For 100 profiles at 2s/call = ~3.5 minutes. Happens once at import time. + */ +async function enrichWithFlagshipUrls( + page: Page, + profiles: SalesProfile[], + onProgress?: (p: ImportProgress) => void +): Promise> { + const urlMap = new Map(); // entityUrn → flagshipProfileUrl + + const cookies = await page.context().cookies("https://www.linkedin.com"); + const jsessionid = cookies.find(c => c.name === "JSESSIONID")?.value?.replace(/"/g, "") ?? ""; + if (!jsessionid) { + console.log("[scraper] JSESSIONID not found — skipping flagshipProfileUrl enrichment"); + return urlMap; + } + + let done = 0; + for (const el of profiles) { + const parts = parseUrn(el.entityUrn); + if (!parts) continue; + + const { profileId, authType, authToken } = parts; + const apiUrl = `https://www.linkedin.com/sales-api/salesApiProfiles/(profileId:${profileId},authType:${authType},authToken:${authToken})?decoration=%28entityUrn%2CflagshipProfileUrl%29`; + + let attempts = 0; + while (attempts < 3) { + const evaluatePromise = page.evaluate(async ({ url, csrfToken }: { url: string; csrfToken: string }) => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + const resp = await fetch(url, { + credentials: "include", + signal: controller.signal, + headers: { + "csrf-token": csrfToken, + "x-restli-protocol-version": "2.0.0", + "accept": "application/json", + }, + }); + clearTimeout(timer); + return { status: resp.status, body: await resp.text() }; + } catch (e: unknown) { + return { status: -1, body: (e as Error).message }; + } + }, { url: apiUrl, csrfToken: jsessionid }); + + const timeoutPromise = new Promise<{ status: number; body: string }>((resolve) => + setTimeout(() => resolve({ status: -1, body: 'timeout' }), 15000) + ); + const result = await Promise.race([evaluatePromise, timeoutPromise]); + + if (result.status === 200) { + try { + const data = JSON.parse(result.body) as ProfileDetailResponse; + if (data.flagshipProfileUrl) { + const normalized = data.flagshipProfileUrl.endsWith("/") + ? data.flagshipProfileUrl + : data.flagshipProfileUrl + "/"; + urlMap.set(el.entityUrn, normalized); + } + } catch { /* ignore parse errors */ } + break; + } else if (result.status === 429) { + attempts++; + console.log(`[scraper] 429 rate limit — waiting 30s before retry (attempt ${attempts}/3)`); + await page.waitForTimeout(30000); + } else { + const reason = result.body === 'timeout' ? 'timeout' : result.status; + console.log(`[scraper] enrichment ${reason} for ${el.fullName ?? el.entityUrn.substring(0, 30)} — skipping`); + break; + } + } + + // 2s between calls — slow enough to stay under rate limits + await page.waitForTimeout(2000); + done++; + console.log(`[scraper] flagship URLs: ${done}/${profiles.length} resolved`); + onProgress?.({ phase: 'enriching', count: done, total: profiles.length }); + } + + console.log(`[scraper] enrichment done: ${urlMap.size}/${profiles.length} profiles resolved`); + return urlMap; +} + +export interface ImportProgress { + phase: 'scraping' | 'enriching' | 'visiting'; + page?: number; + totalPages?: number; + count: number; + total: number; +} + +export interface ScrapeOptions { + /** 1-based page to begin this window at (for batched imports). Default 1. */ + startPage?: number; + /** Max pages to fetch in THIS window, starting at startPage. Default 50. */ + maxPages?: number; + onProgress?: (p: ImportProgress) => void; + /** Polled between pages; return true to stop early (cancel / deleted list). */ + isCanceled?: () => boolean | Promise; +} + +export interface WindowedScrapeResult { + profiles: ScrapedProfile[]; + /** Last page actually fetched in this window. */ + lastPage: number; + /** Total profiles available in the whole search. */ + knownTotal: number; + /** True if this window reached the end of the search (nothing left to batch). */ + exhausted: boolean; +} + +export async function scrapeNavigatorList( + ctx: BrowserContext, + salesNavUrl: string, + opts: ScrapeOptions = {} +): Promise { + const { startPage = 1, maxPages = 50, onProgress, isCanceled } = opts; + const listId = extractListId(salesNavUrl); + if (!listId) throw new Error(`Invalid Sales Navigator URL: ${salesNavUrl}`); + + const allElements: SalesProfile[] = []; + const seen = new Set(); + const PAGE_SIZE = 25; + const buildUrl = (n: number) => + `https://www.linkedin.com/sales/lists/people/${listId}?${n > 1 ? `page=${n}&` : ""}sortCriteria=CREATED_TIME&sortOrder=DESCENDING`; + + const page = await ctx.newPage(); + let knownTotal = 0; + let intercepted: FlatResponse | null = null; + + const waitForIntercept = async (url: string, waitMs: number): Promise => { + intercepted = null; + page.removeAllListeners("response"); + page.on("response", async (response) => { + if (intercepted) return; + if (response.url().includes("salesApiPeopleSearch") && response.status() === 200) { + try { intercepted = await response.json() as FlatResponse; } catch { /* ignore */ } + } + }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(waitMs); + return intercepted; + }; + + // First page of the window + const firstData = await waitForIntercept(buildUrl(startPage), 15000); + if (!firstData) { + const finalUrl = page.url(); + console.error(`[scraper] no intercept after 15s. Final URL: ${finalUrl}`); + await page.close(); + throw new Error("No data intercepted from Sales Nav — session may need re-authentication"); + } + + knownTotal = firstData.paging?.total ?? 0; + for (const el of firstData.elements ?? []) { + if (!el.entityUrn || seen.has(el.entityUrn)) continue; + seen.add(el.entityUrn); + allElements.push(el); + } + console.log(`[scraper] page ${startPage}: ${allElements.length} elements, total=${knownTotal}`); + + const totalPages = Math.ceil(knownTotal / PAGE_SIZE); + const endPage = Math.min(totalPages, startPage + maxPages - 1); + let lastPage = startPage; + onProgress?.({ phase: 'scraping', page: startPage, totalPages: endPage, count: allElements.length, total: knownTotal }); + + for (let pageNum = startPage + 1; pageNum <= endPage; pageNum++) { + if (isCanceled && (await isCanceled())) break; + const delayMs = 60000 + Math.random() * 60000; + console.log(`[scraper] waiting ${Math.round(delayMs / 1000)}s before page ${pageNum}...`); + await page.waitForTimeout(delayMs); + if (isCanceled && (await isCanceled())) break; + + let pageData = await waitForIntercept(buildUrl(pageNum), 15000); + if (!pageData || (pageData.elements?.length ?? 0) === 0) { + console.log(`[scraper] page ${pageNum} empty on first try — retrying with 15s wait`); + pageData = await waitForIntercept(buildUrl(pageNum), 15000); + } + if (pageData) { + for (const el of pageData.elements ?? []) { + if (!el.entityUrn || seen.has(el.entityUrn)) continue; + seen.add(el.entityUrn); + allElements.push(el); + } + } + lastPage = pageNum; + console.log(`[scraper] page ${pageNum}/${endPage}: ${allElements.length} (total ${knownTotal})`); + onProgress?.({ phase: 'scraping', page: pageNum, totalPages: endPage, count: allElements.length, total: knownTotal }); + } + + await page.close(); + return { + profiles: allElements.map(el => profileToResult(el, null)), + lastPage, + knownTotal, + exhausted: lastPage >= totalPages, + }; +} + +export async function scrapeSavedSearch( + ctx: BrowserContext, + savedSearchUrl: string, + opts: ScrapeOptions = {} +): Promise { + const { startPage = 1, maxPages = 50, onProgress, isCanceled } = opts; + const savedSearchId = extractSavedSearchId(savedSearchUrl); + if (!savedSearchId) throw new Error(`Invalid Sales Navigator saved search URL: ${savedSearchUrl}`); + + const allElements: SalesProfile[] = []; + const seen = new Set(); + const PAGE_SIZE = 25; + const buildUrl = (n: number) => + `https://www.linkedin.com/sales/search/people?savedSearchId=${savedSearchId}${n > 1 ? `&page=${n}` : ""}`; + + const page = await ctx.newPage(); + let knownTotal = 0; + + const waitForIntercept = async (url: string, waitMs: number): Promise => { + let intercepted: FlatResponse | null = null; + page.removeAllListeners("response"); + page.on("response", async (response) => { + if (intercepted) return; + if (response.url().includes("salesApiLeadSearch") && response.status() === 200) { + try { intercepted = await response.json() as FlatResponse; } catch { /* ignore */ } + } + }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(waitMs); + return intercepted; + }; + + // First page of the window + const firstData = await waitForIntercept(buildUrl(startPage), 15000); + if (!firstData) { + await page.close(); + throw new Error("No data intercepted from saved search — session may need re-authentication"); + } + + knownTotal = firstData.paging?.total ?? 0; + for (const el of firstData.elements ?? []) { + if (!el.entityUrn || seen.has(el.entityUrn)) continue; + seen.add(el.entityUrn); + allElements.push(el); + } + console.log(`[scraper:saved-search] page ${startPage}: ${allElements.length} elements, total=${knownTotal}`); + + const totalPages = Math.ceil(knownTotal / PAGE_SIZE); + const endPage = Math.min(totalPages, startPage + maxPages - 1); + let lastPage = startPage; + onProgress?.({ phase: 'scraping', page: startPage, totalPages: endPage, count: allElements.length, total: knownTotal }); + + for (let pageNum = startPage + 1; pageNum <= endPage; pageNum++) { + if (isCanceled && (await isCanceled())) break; + const delayMs = 60000 + Math.random() * 60000; + console.log(`[scraper:saved-search] waiting ${Math.round(delayMs / 1000)}s before page ${pageNum}...`); + await page.waitForTimeout(delayMs); + if (isCanceled && (await isCanceled())) break; + + let pageData = await waitForIntercept(buildUrl(pageNum), 15000); + if (!pageData || (pageData.elements?.length ?? 0) === 0) { + console.log(`[scraper:saved-search] page ${pageNum} empty on first try — retrying with 15s wait`); + pageData = await waitForIntercept(buildUrl(pageNum), 15000); + } + if (pageData) { + for (const el of pageData.elements ?? []) { + if (!el.entityUrn || seen.has(el.entityUrn)) continue; + seen.add(el.entityUrn); + allElements.push(el); + } + } + lastPage = pageNum; + console.log(`[scraper:saved-search] page ${pageNum}/${endPage}: ${allElements.length} (total ${knownTotal})`); + onProgress?.({ phase: 'scraping', page: pageNum, totalPages: endPage, count: allElements.length, total: knownTotal }); + } + + await page.close(); + return { + profiles: allElements.map(el => profileToResult(el, null)), + lastPage, + knownTotal, + exhausted: lastPage >= totalPages, + }; +} + +/** + * Dispatcher — accepts either a lead list URL or a saved search URL. + * Callers don't need to know which type they're dealing with. + */ +export async function scrapeNavigatorUrl( + ctx: BrowserContext, + url: string, + opts: ScrapeOptions = {} +): Promise { + if (extractSavedSearchId(url)) { + return scrapeSavedSearch(ctx, url, opts); + } + if (extractListId(url)) { + return scrapeNavigatorList(ctx, url, opts); + } + throw new Error(`Unrecognized Sales Navigator URL. Expected a list URL (/sales/lists/people/...) or saved search URL (?savedSearchId=...)`); +} diff --git a/lib/linkedin/session.ts b/lib/linkedin/session.ts new file mode 100644 index 0000000..b462468 --- /dev/null +++ b/lib/linkedin/session.ts @@ -0,0 +1,519 @@ +import { chromium } from "playwright-extra"; +import type { Browser, BrowserContext, Page } from "playwright"; +import StealthPlugin from "puppeteer-extra-plugin-stealth"; +import { getDb } from "@/lib/db"; +import { encryptSecret, decryptSecret } from "@/lib/crypto"; + +chromium.use(StealthPlugin()); + +let browser: Browser | null = null; +const contexts: Map = new Map(); + +const HEADLESS = process.env.HEADLESS !== "false"; +const CHROMIUM_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +const LAUNCH_ARGS = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", +]; + +/** + * Shared browser-context fingerprint. Login and runtime MUST use the identical + * options so the LinkedIn session is BORN under the exact fingerprint it will + * later be used with — a mismatch (or a drift) triggers a forced re-auth. + */ +function contextOptions(storageState?: object) { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageState: storageState as any, + viewport: { width: 1920, height: 1080 }, + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + locale: "en-US", + timezoneId: "America/New_York", + permissions: ["clipboard-read", "clipboard-write"] as ("clipboard-read" | "clipboard-write")[], + }; +} + +async function getBrowser(headless = HEADLESS): Promise { + // B1: if the cached browser is disconnected, CLOSE it before relaunching. + // Without this, a dead-but-not-reaped chromium process tree is orphaned on + // every relaunch (the leak behind the Jun 2026 zombie pile-up). + if (browser && !browser.isConnected()) { + try { await browser.close(); } catch { /* already gone */ } + browser = null; + } + if (!browser) { + browser = await chromium.launch({ + headless, + executablePath: CHROMIUM_PATH, + args: LAUNCH_ARGS, + }); + } + return browser; +} + +async function getOrCreateContext(accountId: string): Promise { + const db = getDb(); + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(accountId) as + | { cookies_json: string | null; email: string } + | undefined; + + if (!account) throw new Error(`Account ${accountId} not found`); + + if (!contexts.has(accountId)) { + const b = await getBrowser(); + + let storageState: object | undefined; + if (account.cookies_json) { + try { + storageState = JSON.parse(decryptSecret(account.cookies_json)!); + } catch { + // Invalid storage state — will need re-auth + } + } + + const ctx = await b.newContext(contextOptions(storageState)); + + // Auto-evict from map when context closes for any reason (crash, session expiry, etc.) + ctx.on("close", () => { if (contexts.get(accountId) === ctx) contexts.delete(accountId); }); + + contexts.set(accountId, ctx); + } + + return contexts.get(accountId)!; +} + +/** Returns the BrowserContext for an account (for API calls via ctx.request) */ +export async function getSessionContext(accountId: string): Promise { + try { + return await getOrCreateContext(accountId); + } catch { + // First attempt failed — evict and retry once with a fresh context + contexts.delete(accountId); + return getOrCreateContext(accountId); + } +} + +// B6 (Jul 2026 CPU-spike incident): closing a Playwright page doesn't mean the +// underlying Chromium renderer OS process has actually exited — under CPU +// contention on the 2-vCPU prod box, teardown was observed lagging 60-90s +// behind page.close(). If the runner loop (or a concurrent MCP/API call) +// opens its next page immediately after, two renderer processes end up alive +// at once, which is enough to peg both cores and starve sibling containers' +// healthchecks (NocoDB/Chatwoot flapping). Fix: serialize ALL page opens +// app-wide through one queue, and hold the queue for a teardown buffer after +// each page.close() before letting the next one through. PAGE_MAX_HOLD_MS is +// a safety valve so a caller that forgets to close its page can't wedge the +// whole app's browser access forever. +const PAGE_TEARDOWN_GAP_MS = 3000; +const PAGE_MAX_HOLD_MS = 120_000; +let pageQueueTail: Promise = Promise.resolve(); + +/** Returns a new Page from the account's browser context */ +export async function getSessionPage(accountId: string): Promise { + let releaseTurn!: () => void; + const myTurn = new Promise(r => { releaseTurn = r; }); + const previousTail = pageQueueTail; + pageQueueTail = myTurn; + await previousTail; + + let released = false; + const release = () => { + if (released) return; + released = true; + releaseTurn(); + }; + const safetyTimer = setTimeout(release, PAGE_MAX_HOLD_MS); + + let page: Page; + try { + const ctx = await getOrCreateContext(accountId); + try { + page = await ctx.newPage(); + } catch { + // B2: context was dead — CLOSE it before recreating so its underlying + // browser process isn't left orphaned, then retry once with a fresh one. + try { await ctx.close(); } catch { /* already gone */ } + contexts.delete(accountId); + const freshCtx = await getOrCreateContext(accountId); + page = await freshCtx.newPage(); + } + } catch (err) { + clearTimeout(safetyTimer); + release(); + throw err; + } + + const originalClose = page.close.bind(page); + page.close = (async (options?: Parameters[0]) => { + try { + return await originalClose(options); + } finally { + clearTimeout(safetyTimer); + setTimeout(release, PAGE_TEARDOWN_GAP_MS); + } + }) as Page["close"]; + + return page; +} + +export async function saveSessionState(accountId: string): Promise { + const ctx = contexts.get(accountId); + if (!ctx) return; + const db = getDb(); + const state = await ctx.storageState(); + db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run( + encryptSecret(JSON.stringify(state)), + accountId + ); +} + +export async function closeSession(accountId: string): Promise { + const ctx = contexts.get(accountId); + if (ctx) { + await ctx.close(); + contexts.delete(accountId); + } +} + +/** + * B4: flag an account as logged out / needing re-auth. Clears is_authenticated + * so the runner stops working a dead session (no more 30s-timeout fail-loop), + * and drops the live context. The user re-authenticates from Settings. + */ +export async function markNeedsReauth(accountId: string): Promise { + const db = getDb(); + db.prepare("UPDATE accounts SET is_authenticated = 0 WHERE id = ?").run(accountId); + try { await closeSession(accountId); } catch { /* ignore */ } + console.warn(`[session] account ${accountId} flagged needs-reauth (session logged out)`); +} + +/** + * Opens a visible browser, navigates to LinkedIn login, and waits for the user + * to complete login manually. Returns when the user reaches /feed. + * Saves the full storage state to DB and marks account as authenticated. + */ +export async function authenticateAccount(accountId: string): Promise { + const db = getDb(); + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(accountId) as + | { email: string } + | undefined; + if (!account) throw new Error(`Account ${accountId} not found`); + + // Close any existing context for this account — start fresh + await closeSession(accountId); + + // Always launch a VISIBLE browser for manual login + const visibleBrowser = await chromium.launch({ + headless: false, + executablePath: CHROMIUM_PATH, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + ], + }); + + try { + const ctx = await visibleBrowser.newContext({ + viewport: { width: 1440, height: 900 }, + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + locale: "en-US", + timezoneId: "America/New_York", + }); + + const page = await ctx.newPage(); + await page.goto("https://www.linkedin.com/login"); + + // Pre-fill email to save the user a step + try { + await page.waitForSelector("input#username", { timeout: 5000 }); + await page.fill("input#username", account.email); + } catch { + // Input not found — page may have redirected already + } + + // Wait up to 3 minutes for the user to complete login and reach /feed + await page.waitForURL("**/feed/**", { timeout: 180_000 }); + + // Save full storage state (cookies + localStorage) to DB + const state = await ctx.storageState(); + db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run( + encryptSecret(JSON.stringify(state)), + accountId + ); + + await ctx.close(); + } finally { + await visibleBrowser.close(); + } +} + +// ─── Server-side headless login ─────────────────────────────────────────────── +// Logs in directly on the server (no screen) so the session is born under the +// SAME pinned Chromium fingerprint the runner uses, and so ALL cookies — incl. +// httpOnly ones like li_ep_auth_context (the Sales Nav seat cookie that +// document.cookie cannot read) — are captured. A login from a datacenter IP +// almost always triggers an email/SMS PIN, so it's a two-step flow: start +// (email+password) → maybe a challenge → verify (code). + +export type LoginResult = + | { status: "authenticated" } + | { status: "challenge"; kind: "otp" | "app" | "captcha" | "unknown"; message: string } + | { status: "error"; message: string }; + +type PendingLogin = { ctx: BrowserContext; page: Page; createdAt: number }; +const pendingLogins: Map = new Map(); +const PENDING_TTL_MS = 10 * 60_000; + +// PIN/verification-code input. Named ids first, then type-based fallbacks for +// LinkedIn's React checkpoint pages (which use dynamic ids). Safe: the login +// page itself has no tel/one-time-code input to misfire on. +const PIN_SELECTOR = + "input[name='pin'], #input__email_verification_pin, input[autocomplete='one-time-code']:visible, input[type='tel']:visible"; + +async function clearPendingLogin(accountId: string): Promise { + const p = pendingLogins.get(accountId); + if (p) { + pendingLogins.delete(accountId); + try { await p.ctx.close(); } catch { /* already gone */ } + } +} + +function sweepPendingLogins(): void { + const now = Date.now(); + for (const [id, p] of pendingLogins) { + if (now - p.createdAt > PENDING_TTL_MS) void clearPendingLogin(id); + } +} + +/** + * Warm the freshly-authenticated session by loading Sales Navigator once, THEN + * persist. The bare login POST lands on /feed and only mints the ~11 core + * LinkedIn cookies — it does NOT yet include the Sales Nav SEAT cookie + * (li_ep_auth_context) nor the secondary auth tokens (li_a, liap) and + * localStorage. Those are only issued once the browser actually enters Sales + * Navigator. Without the seat cookie every Sales Nav API call returns nothing + * ("no intercept after 15s" → import fails → account wrongly flagged + * needs-reauth). So we navigate to /sales/ and wait for it to settle before + * calling storageState(), capturing the FULL session the runner needs. + * Best-effort: if the account has no Sales Nav seat the nav simply doesn't add + * the seat cookie — the rest of the (regular-LinkedIn) session is still saved. + */ +async function persistLogin(accountId: string, ctx: BrowserContext, page?: Page): Promise { + if (page) { + try { + await page.goto("https://www.linkedin.com/sales/home", { waitUntil: "domcontentloaded", timeout: 30_000 }); + // Let Sales Nav's bootstrap requests fire so li_ep_auth_context is set. + await page.waitForTimeout(4_000); + } catch { + // Non-fatal — a missing seat / slow load must not fail the whole login. + } + } + const db = getDb(); + const state = await ctx.storageState(); + db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run( + encryptSecret(JSON.stringify(state)), + accountId + ); + // Drop any stale runtime context so the runner reloads the fresh cookies. + await closeSession(accountId); +} + +/** + * Inspect the page after a login/verify submit and classify the outcome. + * LinkedIn varies its challenge per attempt — email/SMS code OR device (app) + * approval — so we detect both: a visible code input = otp; a checkpoint page + * with no code input and no captcha = device approval. + */ +async function classifyLoginState(page: Page): Promise { + const start = Date.now(); + const deadline = start + 20_000; + while (Date.now() < deadline) { + const url = page.url(); + if (/\/feed\//.test(url) || /linkedin\.com\/sales\//.test(url)) { + return { status: "authenticated" }; + } + + // Email/SMS PIN entry + const pin = page.locator(PIN_SELECTOR).first(); + if ((await pin.count()) > 0 && (await pin.isVisible().catch(() => false))) { + return { + status: "challenge", + kind: "otp", + message: "LinkedIn sent you a verification code (email or SMS). Enter it below.", + }; + } + + if (/checkpoint\/challenge/.test(url)) { + // CAPTCHA (Arkose / FunCaptcha) — cannot be solved headlessly + const cap = page.locator("iframe[src*='arkoselabs'], iframe[title*='captcha'], #captcha-internal"); + if ((await cap.count().catch(() => 0)) > 0) { + return { + status: "challenge", + kind: "captcha", + message: "LinkedIn requires a CAPTCHA, which can't be solved on the server. Use cookie paste instead.", + }; + } + // Device/app approval: a settled checkpoint with no code input and no captcha + if (Date.now() - start > 4_000) { + return { + status: "challenge", + kind: "app", + message: "LinkedIn sent a sign-in request to your LinkedIn mobile app. Approve it there, then click Continue.", + }; + } + } + + // Wrong credentials + const wrongPw = await page + .getByText(/that.?s not the right password|please enter a valid|couldn.?t find a linkedin account/i) + .count() + .catch(() => 0); + if (wrongPw > 0) return { status: "error", message: "Wrong email or password." }; + + await page.waitForTimeout(800); + } + + if (/checkpoint/.test(page.url())) { + return { + status: "challenge", + kind: "unknown", + message: "LinkedIn presented a security checkpoint. If you got a code enter it; if it's an app request, approve it and click Continue.", + }; + } + return { status: "error", message: `Login did not complete. Current page: ${page.url()}` }; +} + +export async function startHeadlessLogin( + accountId: string, + email: string, + password: string +): Promise { + sweepPendingLogins(); + await clearPendingLogin(accountId); + + const b = await getBrowser(true); + const ctx = await b.newContext(contextOptions()); + const page = await ctx.newPage(); + try { + await page.goto("https://www.linkedin.com/login", { waitUntil: "domcontentloaded", timeout: 30_000 }); + // LinkedIn's React login page uses dynamic ids — target by type+visibility + // (old #username/#password kept as a fallback for the legacy layout). + const emailInput = page.locator("input#username, input[type='email']:visible").first(); + await emailInput.waitFor({ state: "visible", timeout: 20_000 }); + await emailInput.fill(email); + const passwordInput = page.locator("input#password, input[type='password']:visible").first(); + await passwordInput.fill(password); + await Promise.all([ + page.waitForLoadState("domcontentloaded").catch(() => {}), + passwordInput.press("Enter"), + ]); + + const result = await classifyLoginState(page); + console.log(`[login] start account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`); + if (result.status === "authenticated") { + await persistLogin(accountId, ctx, page); + await ctx.close(); + return result; + } + if (result.status === "challenge" && result.kind !== "captcha") { + pendingLogins.set(accountId, { ctx, page, createdAt: Date.now() }); + return result; + } + await ctx.close(); + return result; + } catch (e) { + console.log(`[login] start account=${accountId} ERROR ${(e as Error).message} url=${page.url()}`); + try { await ctx.close(); } catch { /* ignore */ } + return { status: "error", message: (e as Error).message }; + } +} + +export async function submitLoginChallenge(accountId: string, code: string): Promise { + const p = pendingLogins.get(accountId); + if (!p) return { status: "error", message: "No login in progress (it may have timed out — start again)." }; + + const { ctx, page } = p; + try { + const pin = page.locator(PIN_SELECTOR).first(); + await pin.waitFor({ state: "visible", timeout: 15_000 }); + await pin.fill(code); + await Promise.all([ + page.waitForLoadState("domcontentloaded").catch(() => {}), + pin.press("Enter"), + ]); + + const result = await classifyLoginState(page); + console.log(`[login] verify account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`); + if (result.status === "authenticated") { + await persistLogin(accountId, ctx, page); + await clearPendingLogin(accountId); + return result; + } + if (result.status === "challenge" && result.kind !== "captcha") { + p.createdAt = Date.now(); // keep the session alive for another step + return result; + } + await clearPendingLogin(accountId); + return result.status === "error" + ? result + : { status: "error", message: "Code rejected or login failed." }; + } catch (e) { + await clearPendingLogin(accountId); + return { status: "error", message: (e as Error).message }; + } +} + +/** + * Wait for a device/app-approval challenge to clear. Called after the user + * approves the sign-in in their LinkedIn mobile app — the checkpoint page then + * auto-advances to the feed. Also dismisses a possible "remember this browser?" + * interstitial. If still pending, returns the challenge so the user can retry. + */ +export async function awaitLoginApproval(accountId: string): Promise { + const p = pendingLogins.get(accountId); + if (!p) return { status: "error", message: "No login in progress (it may have timed out — start again)." }; + + const { ctx, page } = p; + p.createdAt = Date.now(); + try { + const reachedFeed = await page + .waitForURL(/\/feed\/|linkedin\.com\/sales\//, { timeout: 50_000 }) + .then(() => true) + .catch(() => false); + + if (!reachedFeed) { + // Possible post-approval interstitial (e.g. "remember this browser?") + const btn = page + .locator("button[type=submit]:visible, button:has-text('Yes'):visible, button:has-text('Ja'):visible") + .first(); + if ((await btn.count().catch(() => 0)) > 0) { + await btn.click().catch(() => {}); + await page.waitForURL(/\/feed\/|linkedin\.com\/sales\//, { timeout: 20_000 }).catch(() => {}); + } + } + + const result = await classifyLoginState(page); + console.log(`[login] await account=${accountId} -> ${result.status}${"kind" in result ? "/" + result.kind : ""} url=${page.url()}`); + if (result.status === "authenticated") { + await persistLogin(accountId, ctx, page); + await clearPendingLogin(accountId); + return result; + } + if (result.status === "challenge" && result.kind !== "captcha") { + p.createdAt = Date.now(); + return result; + } + await clearPendingLogin(accountId); + return result; + } catch (e) { + await clearPendingLogin(accountId); + return { status: "error", message: (e as Error).message }; + } +} diff --git a/lib/linkedin/sync-accepted.ts b/lib/linkedin/sync-accepted.ts new file mode 100644 index 0000000..7c9dd6d --- /dev/null +++ b/lib/linkedin/sync-accepted.ts @@ -0,0 +1,256 @@ +import type { Page } from "playwright"; +import { getDb } from "@/lib/db"; +import { getSessionPage, saveSessionState, markNeedsReauth } from "@/lib/linkedin/session"; + +/** + * Accepted-connection sync via the authoritative Voyager connections API. + * + * Replaces the old absence-inference (scroll the SENT list, treat a vanished + * invite as "accepted"). That was measurably wrong: of 1,600 contacts it had + * marked degree=1, only 325 were genuinely connected — 1,275 were phantoms + * (invites that expired/withdrew/were ignored, not accepted). See docs §19. + * + * Sources — all proved against prod (Jul 2026), NO scrolling: + * - DATA: GET /voyager/api/relationships/dash/connections + * ?decorationId=…ConnectionListWithProfile-16&q=search + * &sortType=RECENTLY_ADDED&start=N&count=100 + * → data["*elements"] (newest-first) + included[]: Connection { createdAt(ms), + * connectedMember } and Profile { publicIdentifier (vanity), entityUrn }. + * The API paginates to the end (30 pages for ~2947) in ~60s. It does NOT + * expose a grand total. + * - TOTAL (checksum): the connections PAGE header " connections"

. Read + * once per full pass via a single navigation (NO scroll). A full pass is + * "verified complete" only when unique-pulled == declared total (matched ±0 + * in prod). Presence in the list = 100% proof of a 1st-degree connection. + * + * Behaviour: + * - First run (boundary NULL) → FULL pass: page to the end, stamp degree=1 + + * connected_at from createdAt for every matched contact. If the pass is + * checksum-verified complete, ALSO un-mark phantom degree=1 contacts whose + * vanity is absent from the authoritative list (resets degree/connected_at). + * - Later runs → incremental: page from start=0 and STOP at the first + * connection older than the stored boundary (minus a 24h overlap margin). + * Incremental/incomplete passes are ADD-ONLY — they never un-mark (a partial + * pull must not wipe real accepts). Un-marking happens ONLY on a verified + * full pass. + * - Reuses the runner's shared browser (getSessionPage) — never a 2nd browser. + */ + +const ACCEPTED_SYNC_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8h — 3x per day +const PAGE_SIZE = 100; +const MAX_PAGES = 60; // safety cap (60 * 100 = 6000) +const OVERLAP_MARGIN_MS = 24 * 60 * 60 * 1000; // re-check a day of overlap (idempotent) +const DECORATION = "com.linkedin.voyager.dash.deco.web.mynetwork.ConnectionListWithProfile-16"; + +export function shouldSyncAccepted(accountId: string): boolean { + const db = getDb(); + const row = db.prepare("SELECT accepted_sync_at FROM accounts WHERE id = ?").get(accountId) as + | { accepted_sync_at: string | null } + | undefined; + if (!row?.accepted_sync_at) return true; + return Date.now() - new Date(row.accepted_sync_at).getTime() >= ACCEPTED_SYNC_INTERVAL_MS; +} + +interface ApiConnection { + vanity: string | null; + createdAt: number; // epoch ms +} + +export async function syncAcceptedConnections(accountId: string): Promise { + const db = getDb(); + const page = await getSessionPage(accountId); + let stamped = 0; + + try { + const boundaryRow = db.prepare("SELECT connections_synced_through_ms FROM accounts WHERE id = ?").get(accountId) as + | { connections_synced_through_ms: number | null } + | undefined; + const boundary = boundaryRow?.connections_synced_through_ms ?? null; + const isFullPass = boundary === null; + const stopBefore = boundary === null ? null : boundary - OVERLAP_MARGIN_MS; + + // Read the declared total from the connections PAGE header (single nav, NO + // scroll). This is also our login-wall check and the completeness checksum. + await page.goto("https://www.linkedin.com/mynetwork/invite-connect/connections/", { + waitUntil: "domcontentloaded", + timeout: 35000, + }); + await page.waitForTimeout(3500 + Math.random() * 1500); + if (/\/login|\/authwall|\/checkpoint|\/uas\//.test(page.url())) { + console.warn(`[sync-accepted] Session looks logged out (${page.url()}) — skipping`); + return 0; + } + const declaredTotal = await page.evaluate(() => { + const m = document.body.innerText.match(/([\d.,]+)\s+connections?/i); + return m ? parseInt(m[1].replace(/[.,]/g, ""), 10) : null; + }); + + const findByVanity = db.prepare( + `SELECT id, full_name, connected_at, degree FROM targets + WHERE linkedin_url LIKE ? AND connection_requested_at IS NOT NULL` + ); + const stampAccepted = db.prepare( + "UPDATE targets SET degree = 1, connected_at = COALESCE(connected_at, ?) WHERE id = ?" + ); + + const seenVanities = new Set(); // full-pass phantom check + let uniquePulled = 0; + let newestSeen: number | null = null; + let reachedBoundary = false; + + for (let pageIdx = 0; pageIdx < MAX_PAGES; pageIdx++) { + const conns = await fetchConnectionsPage(page, pageIdx * PAGE_SIZE, PAGE_SIZE); + if (conns === null) { + console.warn(`[sync-accepted] connections API failed at start=${pageIdx * PAGE_SIZE} — stopping`); + break; + } + if (conns.length === 0) break; // end of list + + for (const c of conns) { + uniquePulled++; + if (c.vanity) seenVanities.add(c.vanity); + if (newestSeen === null || c.createdAt > newestSeen) newestSeen = c.createdAt; + + // Incremental early-exit (list is newest-first). + if (stopBefore !== null && c.createdAt < stopBefore) { + reachedBoundary = true; + break; + } + if (!c.vanity) continue; + + for (const m of findByVanity.all(`%/in/${c.vanity}/%`) as Array<{ + id: string; full_name: string | null; connected_at: string | null; degree: number | null; + }>) { + if (m.degree === 1 && m.connected_at) continue; // already correct + stampAccepted.run(msToSqlite(c.createdAt), m.id); + console.log(`[sync-accepted] Accepted: ${m.full_name ?? c.vanity}`); + stamped++; + } + } + + if (reachedBoundary) break; + await page.waitForTimeout(900 + Math.random() * 700); // gentle, API-only + } + + // Completeness checksum: a full pass is trustworthy only if what we pulled + // matches LinkedIn's own declared total (±5 slack for live churn). + const verifiedComplete = + isFullPass && declaredTotal !== null && Math.abs(uniquePulled - declaredTotal) <= 5; + + // Correction: ONLY on a verified-complete full pass, un-mark phantom + // degree=1 contacts (present nowhere in the authoritative list). Never on an + // incremental/incomplete pass — a partial pull must not wipe real accepts. + let unmarked = 0; + if (verifiedComplete) { + const deg1 = db.prepare( + "SELECT id, full_name, linkedin_url FROM targets WHERE degree = 1 AND linkedin_url LIKE '%/in/%'" + ).all() as Array<{ id: string; full_name: string | null; linkedin_url: string }>; + const unmark = db.prepare("UPDATE targets SET degree = NULL, connected_at = NULL WHERE id = ?"); + const tx = db.transaction((rows: typeof deg1) => { + for (const t of rows) { + const mm = t.linkedin_url.match(/\/in\/([^/?#]+)/); + const v = mm ? decodeURIComponent(mm[1]).toLowerCase() : null; + if (!v || !seenVanities.has(v)) { + unmark.run(t.id); + unmarked++; + } + } + }); + tx(deg1); + console.log(`[sync-accepted] Verified full pass (pulled ${uniquePulled} == declared ${declaredTotal}). Un-marked ${unmarked} phantom degree=1.`); + } else if (isFullPass) { + console.warn(`[sync-accepted] Full pass NOT verified complete (pulled ${uniquePulled}, declared ${declaredTotal}) — add-only, no un-marking.`); + } + + // Advance the boundary to the newest connection seen this run. + if (newestSeen !== null) { + db.prepare("UPDATE accounts SET connections_synced_through_ms = ? WHERE id = ?").run(newestSeen, accountId); + } + // Store the declared total for visibility (Settings shows LinkedIn's count). + if (declaredTotal !== null) { + db.prepare("UPDATE accounts SET li_connections = ? WHERE id = ?").run(declaredTotal, accountId); + } + console.log(`[sync-accepted] Stamped ${stamped} accepted, un-marked ${unmarked} phantom (boundary=${newestSeen}).`); + } finally { + // B5 safety: only persist the session if still on a valid page. + let url = ""; + try { url = page.url(); } catch { /* gone */ } + try { await page.close(); } catch { /* ignore */ } + if (/\/login|\/authwall|\/checkpoint|\/uas\//.test(url)) { + console.warn(`[sync-accepted] Ended on a wall (${url}) — not persisting; flagging re-auth`); + try { await markNeedsReauth(accountId); } catch { /* ignore */ } + } else { + try { await saveSessionState(accountId); } catch { /* ignore */ } + } + db.prepare("UPDATE accounts SET accepted_sync_at = datetime('now') WHERE id = ?").run(accountId); + } + + return stamped; +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +function msToSqlite(ms: number): string { + return new Date(ms).toISOString().replace("T", " ").slice(0, 19); +} + +async function fetchConnectionsPage(page: Page, start: number, count: number): Promise { + return page.evaluate( + async ({ start, count, decoration }): Promise => { + const cookies = document.cookie.split("; ").reduce((a: Record, c) => { + const i = c.indexOf("="); + if (i > 0) a[c.slice(0, i)] = c.slice(i + 1); + return a; + }, {}); + const csrf = (cookies["JSESSIONID"] || "").replace(/"/g, ""); + const url = + `https://www.linkedin.com/voyager/api/relationships/dash/connections` + + `?decorationId=${decoration}&count=${count}&q=search&sortType=RECENTLY_ADDED&start=${start}`; + let json: { + included?: Array<{ + $type?: string; + entityUrn?: string; + createdAt?: number; + connectedMember?: string; + publicIdentifier?: string; + }>; + }; + try { + const r = await fetch(url, { + headers: { + "csrf-token": csrf, + "accept": "application/vnd.linkedin.normalized+json+2.1", + "x-restli-protocol-version": "2.0.0", + "x-li-lang": "en_US", + }, + credentials: "include", + }); + if (!r.ok) return null; + json = await r.json(); + } catch { + return null; + } + + const included = json.included || []; + const vanityByUrn: Record = {}; + for (const x of included) { + if ((x.$type || "").includes("identity.profile.Profile") && x.entityUrn && x.publicIdentifier) { + vanityByUrn[x.entityUrn] = x.publicIdentifier; + } + } + const out: ApiConnection[] = []; + for (const x of included) { + if ((x.$type || "").includes("relationships.Connection") && typeof x.createdAt === "number") { + const memberUrn = x.connectedMember || null; + out.push({ + createdAt: x.createdAt, + vanity: memberUrn ? vanityByUrn[memberUrn] ?? null : null, + }); + } + } + out.sort((a, b) => b.createdAt - a.createdAt); + return out; + }, + { start, count, decoration: DECORATION } + ); +} diff --git a/lib/linkedin/visit.ts b/lib/linkedin/visit.ts new file mode 100644 index 0000000..67e7d7f --- /dev/null +++ b/lib/linkedin/visit.ts @@ -0,0 +1,48 @@ +import type { Page } from "playwright"; + +/** + * Visits a LinkedIn profile page. This registers as a profile view on LinkedIn. + * Navigates and waits, then reports whether the page shows a 1st-degree badge — + * lets the runner backfill degree=1 for contacts that were already connected + * before Linki ever sent them a connection request (e.g. manually added leads). + * + * Primary-degree signal: presence of the profile's primary "Message" link + * (a[href*="/messaging/compose"]) — only shown to 1st-degree connections, reads + * an href attribute rather than a CSS class or translated text, so it survives + * both LinkedIn's periodic class-name hashing and non-English UI languages. + * + * MUST be scoped to the visited person's own intro/top card — a page-wide + * search also matches "Message" links belonging to OTHER people rendered + * elsewhere on the page (sidebar modules like "People also viewed" / suggested + * connections). For a non-connected target, THEIR page has no such link, but + * the sidebar still does — a page-wide `.first()` silently grabbed a random + * unrelated 1st-degree connection's link, wrongly marked the target degree=1, + * and stored that stranger's messaging URN, causing messages to go to the + * wrong person entirely (incident: Jul 2026, see CLAUDE.md/memory). The top + * card is identified structurally as the section containing the page's own + *

name heading, which is robust to LinkedIn's class-name hashing. + * + * Falls back to the old text-scrape (/\b1st\b/ within that same top card) + * when the link isn't found, in case the current account's profile layout + * doesn't render it as a plain link (e.g. buried behind a click/menu). + * + * The same link's href carries the messaging URN (urn:li:fsd_profile:ACoAA...) + * needed to message this person directly later without a name-search typeahead + * — see lib/linkedin/message.ts. Returned as messagingUrn when found. + */ +export async function visitProfile(page: Page, linkedinUrl: string): Promise<{ isFirstDegree: boolean; messagingUrn: string | null }> { + await page.goto(linkedinUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(3000 + Math.random() * 2000); + + const topCard = page.locator("main section").filter({ has: page.locator("h1") }).first(); + + const messageLink = topCard.locator('a[href*="/messaging/compose"]').first(); + const messageHref = (await messageLink.count()) > 0 ? await messageLink.getAttribute("href").catch(() => null) : null; + const urnMatch = messageHref?.match(/profileUrn=([^&]+)/); + const messagingUrn = urnMatch ? decodeURIComponent(urnMatch[1]) : null; + + if (messageHref) return { isFirstDegree: true, messagingUrn }; + + const pageText = await topCard.innerText().catch(() => ""); + return { isFirstDegree: /\b1st\b/.test(pageText), messagingUrn: null }; +} diff --git a/lib/premium.ts b/lib/premium.ts new file mode 100644 index 0000000..1c43d15 --- /dev/null +++ b/lib/premium.ts @@ -0,0 +1,68 @@ +// lib/premium.ts — the ONE bridge from open-core code to commercial (ee/) features. +// +// This file lives in open-core and is present in BOTH the private and public builds. +// It loads ee/ if the folder exists, otherwise degrades to `null`. Open-core code must +// call premium features ONLY through the `premium` object exported here — never with a +// direct `import ... from "@/ee/..."` anywhere else. That one rule is what makes the +// public build (ee/ stripped) compile and run with premium cleanly absent. +// +// Full strategy + rules: docs/OPEN_CORE.md +// +// IMPORTANT: the surface type below is declared HERE, in open-core, on purpose. If we +// imported the type from @/ee, the public build (where @/ee does not exist) would fail +// to typecheck. ee/index.ts must SATISFY this type instead. Extend this as features land. + +// Minimal structural shapes for what OPEN-CORE consumes from premium features. These are +// declared here (not imported from @/ee) so the public build typechecks with ee/ stripped. +// They are intentionally loose at the boundary — ee/ owns the rich domain types and must +// SATISFY these. Only add a field here when open-core actually reads it. Kept deliberately +// permissive (no index signatures) so the richer ee/ return types remain assignable. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface AiSurface { + // Returns at least { default_model } — ee's AgentConfig is a superset. + getAgentConfig(): { default_model: string | null }; + // Bundle is passed straight back into the writers; open-core never inspects its innards. + getContactWithCompany(targetId: string): { contact: any; company: any } | null; + // Writers take a large param object (ee owns its full type); open-core builds it inline. + writeEmail(params: any): Promise<{ subject: string; body: string }>; + writeLinkedInMessage(params: any): Promise<{ body: string }>; + writeSalesInMail(params: any): Promise<{ subject: string; body: string }>; +} + +export interface RepliesSurface { + // AI classify + auto-followup for one stored reply (email or LinkedIn). + classifyAndDispatch(replyId: string): Promise; + // LinkedIn inbox sync (reply detection), driven by the runner loop. + shouldSyncInbox(accountId: string): boolean; + syncAccountInbox(accountId: string): Promise; +} + +export interface InMailSurface { + // Sends a Sales Nav InMail via a live browser page (playwright Page type is open-core). + sendInMail(page: any, salesNavUrl: string, subject: string, body: string): Promise; +} + +export interface PremiumSurface { + ai?: AiSurface; + replies?: RepliesSurface; + inmail?: InMailSurface; + [key: string]: unknown; +} + +function loadPremium(): PremiumSurface | null { + try { + // Loaded by real path. The ee/ folder is imported IN PLACE (not copied) so its own + // relative imports resolve normally. In the public build this require throws → null. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("@/ee"); + return (mod?.default ?? mod) as PremiumSurface; + } catch { + return null; // public build: ee/ has been stripped + } +} + +export const premium: PremiumSurface | null = loadPremium(); + +/** True in the private/commercial build, false in the public open-source build. */ +export const hasPremium: boolean = premium !== null; diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..75cfe80 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,53 @@ +// Minimal shape both a real NextApiRequest and NextAuth's authorize() RequestInternal +// satisfy — socket is optional since RequestInternal doesn't expose one. +type IpSource = { + headers?: { [key: string]: string | string[] | undefined }; + socket?: { remoteAddress?: string }; +}; + +// In-memory sliding-window rate limiter, per IP. No Redis/external service (self-hosted, +// single process — see CLAUDE.md). Resets on server restart; acceptable for a single-user +// tool defending against brute force on login/signup, not a distributed-attack mitigation. +const hits = new Map(); + +// Periodically drop stale keys so this doesn't grow unbounded over a long-running process. +const SWEEP_INTERVAL_MS = 10 * 60 * 1000; +let lastSweep = Date.now(); + +export function isRateLimited(req: IpSource, key: string, limit: number, windowMs: number): boolean { + const ip = clientIp(req); + const mapKey = `${key}:${ip}`; + const now = Date.now(); + + sweepIfDue(now, windowMs); + + const timestamps = (hits.get(mapKey) ?? []).filter(t => now - t < windowMs); + if (timestamps.length >= limit) { + hits.set(mapKey, timestamps); + return true; + } + + timestamps.push(now); + hits.set(mapKey, timestamps); + return false; +} + +function sweepIfDue(now: number, windowMs: number) { + if (now - lastSweep < SWEEP_INTERVAL_MS) return; + lastSweep = now; + for (const [mapKey, timestamps] of hits) { + const fresh = timestamps.filter(t => now - t < windowMs); + if (fresh.length === 0) hits.delete(mapKey); + else hits.set(mapKey, fresh); + } +} + +function clientIp(req: IpSource): string { + const xRealIp = req.headers?.["x-real-ip"]; + if (typeof xRealIp === "string" && xRealIp) return xRealIp; + + const xForwardedFor = req.headers?.["x-forwarded-for"]; + if (typeof xForwardedFor === "string" && xForwardedFor) return xForwardedFor.split(",")[0].trim(); + + return req.socket?.remoteAddress || "unknown"; +} diff --git a/lib/tour.ts b/lib/tour.ts new file mode 100644 index 0000000..efad6d0 --- /dev/null +++ b/lib/tour.ts @@ -0,0 +1,302 @@ +// lib/tour.ts — product tour engine (driver.js). Open-core: available in both builds. +// Each page gets its own short, independent tour keyed by a "page" id. A tour is shown +// once automatically (tracked server-side via /api/tour) and can be replayed manually +// from Settings → General ("Restart tour"), which never touches the "seen" state. +"use client"; + +import { driver, type DriveStep } from "driver.js"; +import "driver.js/dist/driver.css"; + +export type TourPage = + | "dashboard" + | "lists" + | "contacts" + | "companies" + | "workflows" + | "inbox" + | "settings"; + +// Steps whose `element` selector isn't in the DOM are silently skipped by driver.js — +// relied on here so a step referencing a premium-only anchor (Todos nav, InMail, MCP) +// just no-ops cleanly in the open-core build instead of needing its own guard. +const TOURS: Record = { + dashboard: [ + { + element: '[data-tour="nav-dashboard"]', + popover: { + title: "Welcome to Linki", + description: "This is your outreach dashboard. Everything in Linki runs on one background process at a human pace — nothing fires the instant you click a button, so don't expect instant numbers here either.", + }, + }, + { + element: '[data-tour="dashboard-filters"]', + popover: { + title: "LinkedIn and email, side by side", + description: "The two channels are tracked completely separately — a campaign can run both at once, and this page reports them independently rather than blended.", + }, + }, + { + element: '[data-tour="dashboard-funnel"]', + popover: { + title: "Your outreach funnel", + description: "See exactly where leads drop off — from total targets down to replies. This is the \"is my campaign actually working\" view.", + }, + }, + { + element: '[data-tour="dashboard-chart"]', + popover: { + title: "Activity over time", + description: "Visits, connections, messages and emails per day. Click a series label to toggle it off, or change the day range to reshape the whole page.", + }, + }, + { + element: '[data-tour="nav-settings"]', + popover: { + title: "First: connect a LinkedIn account", + description: "Nothing else works until an account is authenticated in Settings → LinkedIn — that's the one true prerequisite before importing or running anything.", + }, + }, + { + element: '[data-tour="nav-lists"]', + popover: { + title: "Then: import a list", + description: "Paste a Sales Navigator search URL — that's how leads get into Linki.", + }, + }, + { + element: '[data-tour="nav-workflows"]', + popover: { + title: "Then: build a campaign", + description: "Chain steps like visit, connect, message and email into a sequence, pick the list and account, and launch.", + }, + }, + ], + + lists: [ + { + element: '[data-tour="lists-new"]', + popover: { + title: "Create a list", + description: "Paste a LinkedIn Sales Navigator search URL to scrape leads automatically.", + }, + }, + { + element: '[data-tour="lists-new"]', + popover: { + title: "Imports run in the background", + description: "A Sales Navigator import doesn't happen instantly — it's queued and scraped in small batches with human-like delays, specifically to avoid LinkedIn flagging the account.", + }, + }, + { + element: '[data-tour="lists-jobs"]', + popover: { + title: "Import jobs", + description: "There's a shared daily import cap across every list (configurable in Settings → General) — a large list automatically spills into the next day. Track progress and cancel batches here.", + }, + }, + { + element: '[data-tour="lists-new"]', + popover: { + title: "A fresh import is intentionally thin", + description: "Newly imported leads only have basic data (name, title, company). Full profile and email enrichment happen later, automatically, right before a campaign actually needs them — you don't need to enrich everything up front.", + }, + }, + ], + + contacts: [ + { + element: '[data-tour="nav-contacts"]', + popover: { + title: "Every lead, in one place", + description: "This is the single, deduplicated view of everyone you've ever imported — the same person can belong to several lists, and shows up here just once regardless.", + }, + }, + { + element: '[data-tour="contacts-filters"]', + popover: { + title: "Search and filter", + description: "Filter by list, title, connection status, seniority, or email verification to build a targeted sub-audience without re-importing anything.", + }, + }, + { + element: '[data-tour="contacts-select"]', + popover: { + title: "Bulk-select, then add to a list", + description: "Select multiple contacts and add them all to a list at once — an easy way to turn a filtered view (e.g. everyone who replied) into a new targeted list. Contacts already in that list are skipped.", + }, + }, + ], + + companies: [ + { + element: '[data-tour="nav-companies"]', + popover: { + title: "Companies", + description: "Enriched organization records — industry, size, tech stack, revenue — that populate automatically as contacts get enriched. You can also add or edit one by hand.", + }, + }, + ], + + workflows: [ + { + element: '[data-tour="workflows-new"]', + popover: { + title: "Build a campaign", + description: "A campaign chains steps — visit, connect, message, email — into a repeatable sequence, then runs it against a list of contacts through one LinkedIn account.", + }, + }, + { + element: '[data-tour="workflows-new"]', + popover: { + title: "LinkedIn and email run in parallel", + description: "LinkedIn steps and email steps are two independent tracks for the same contact. A reply on either channel stops that person's outreach on both — you don't need to manage them separately.", + }, + }, + { + element: '[data-tour="workflows-new"]', + popover: { + title: "A LinkedIn account is always required", + description: "Even an email-only campaign needs one authenticated LinkedIn account selected — set that up first in Settings if you haven't.", + }, + }, + { + element: '[data-tour="workflows-new"]', + popover: { + title: "One active run at a time", + description: "A campaign can only have one run going at once. To reach more people later, use \"Add contacts\" on the running campaign instead of starting a second run.", + }, + }, + { + element: '[data-tour="workflows-new"]', + popover: { + title: "It runs at a human pace", + description: "Launching doesn't message everyone instantly — the runner works through the list gradually in the background, respecting daily limits and working hours per account.", + }, + }, + ], + + inbox: [ + { + element: '[data-tour="nav-inbox"]', + popover: { + title: "Every reply, one place", + description: "Replies from LinkedIn and email land here automatically — no need to check either inbox by hand. A reply also automatically stops that contact's outreach.", + }, + }, + { + element: '[data-tour="nav-inbox"]', + popover: { + title: "Not every reply means interest", + description: "Reply counts can be misleadingly high — many are auto-responders (out-of-office, \"contact my colleague\") rather than genuine interest. Open one to see the full thread and judge for yourself.", + }, + }, + ], + + settings: [ + { + element: '[data-tour="settings-tab-linkedin"]', + popover: { + title: "Connect a LinkedIn account first", + description: "An account must be authenticated here before it can be used in any campaign — this is the single most important setup step in Linki.", + }, + }, + { + element: '[data-tour="settings-tab-email"]', + popover: { + title: "Email accounts, with ramp-up", + description: "Configure SMTP/IMAP for email steps. A new account can ramp up sending volume gradually instead of blasting at full volume from day one, which helps it avoid getting flagged as spam.", + }, + }, + { + element: '[data-tour="settings-tab-integrations"]', + popover: { + title: "Apollo enrichment", + description: "Add an Apollo API key here to enrich leads with verified emails, seniority, and company data.", + }, + }, + { + element: '[data-tour="settings-tab-general"]', + popover: { + title: "Connect an AI agent", + description: "The General tab has a copyable MCP URL to hook up Claude Code, Cursor, or any MCP-compatible agent to manage Linki on your behalf — plus the daily import cap and this tour picker.", + }, + }, + ], +}; + +function markSeen(page: TourPage) { + fetch("/api/tour", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ page }), + }).catch(() => {}); +} + +function buildAndRun(page: TourPage, persist: boolean) { + const steps = TOURS[page]; + if (!steps || steps.length === 0) return; + + const d = driver({ + showProgress: true, + overlayColor: "#000", + overlayOpacity: 0.65, + stagePadding: 6, + stageRadius: 8, + popoverOffset: 12, + allowClose: true, + popoverClass: "linki-tour-popover", + nextBtnText: "Next", + prevBtnText: "Back", + doneBtnText: "Done", + steps, + onDestroyed: () => { + if (persist) markSeen(page); + }, + }); + + d.drive(); +} + +export function startPageTour(page: TourPage) { + buildAndRun(page, true); +} + +export function replayPageTour(page: TourPage) { + buildAndRun(page, false); +} + +export async function getSeenTours(): Promise> { + try { + const res = await fetch("/api/tour"); + if (!res.ok) return new Set(); + const data = await res.json(); + return new Set((data.seen ?? []) as TourPage[]); + } catch { + return new Set(); + } +} + +export const ALL_TOUR_PAGES: TourPage[] = ["dashboard", "lists", "contacts", "companies", "workflows", "inbox", "settings"]; + +export const TOUR_PAGE_LABELS: Record = { + dashboard: "Dashboard", + lists: "Lists", + contacts: "Contacts", + companies: "Companies", + workflows: "Campaigns", + inbox: "Inbox", + settings: "Settings", +}; + +// Maps a pathname to its tour page id, or null if the current page has no tour +// (e.g. a detail route like /lists/[id] — the index page's tour covers the entry point). +export function pathToTourPage(pathname: string): TourPage | null { + if (pathname === "/") return "dashboard"; + if (pathname === "/lists") return "lists"; + if (pathname === "/contacts") return "contacts"; + if (pathname === "/companies") return "companies"; + if (pathname === "/workflows") return "workflows"; + if (pathname === "/inbox") return "inbox"; + if (pathname === "/settings") return "settings"; + return null; +} diff --git a/lib/update-check.ts b/lib/update-check.ts new file mode 100644 index 0000000..3df3a0e --- /dev/null +++ b/lib/update-check.ts @@ -0,0 +1,88 @@ +/** + * Update checker — polls Docker Hub tags to detect if a newer version of the + * image has been published. Runs once on startup, then every 12 hours. + * + * State is kept in memory (reset on restart, which is fine — it re-checks immediately). + */ + +const DOCKER_HUB_TAGS_URL = + "https://hub.docker.com/v2/repositories/moaljumaa/linki/tags?page_size=50&ordering=last_updated"; + +const POLL_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours + +export interface UpdateState { + current: string; + latest: string | null; + updateAvailable: boolean; + checkedAt: string | null; +} + +const state: UpdateState = { + current: process.env.APP_VERSION ?? "dev", + latest: null, + updateAvailable: false, + checkedAt: null, +}; + +export function getUpdateState(): UpdateState { + return { ...state }; +} + +/** Parse semver string like "1.2.3" → [1, 2, 3]. Returns null if not semver. */ +function parseSemver(v: string): [number, number, number] | null { + const m = v.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])]; +} + +/** Returns true if b is strictly greater than a */ +function isNewer(a: string, b: string): boolean { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return false; + if (pb[0] !== pa[0]) return pb[0] > pa[0]; + if (pb[1] !== pa[1]) return pb[1] > pa[1]; + return pb[2] > pa[2]; +} + +async function checkForUpdate() { + try { + const res = await fetch(DOCKER_HUB_TAGS_URL); + if (!res.ok) return; + + const data = await res.json() as { results?: { name: string }[] }; + const tags: string[] = (data.results ?? []) + .map((t) => t.name) + .filter((n) => parseSemver(n) !== null); + + if (tags.length === 0) return; + + // Find highest semver tag + const latest = tags.reduce((best, tag) => { + return isNewer(best, tag) ? tag : best; + }, tags[0]); + + state.latest = latest; + state.updateAvailable = state.current !== "dev" && isNewer(state.current, latest); + state.checkedAt = new Date().toISOString(); + + if (state.updateAvailable) { + console.log(`[update-check] New version available: ${latest} (running ${state.current})`); + } + } catch { + // Non-fatal — silently ignore network errors + } +} + +const g = global as typeof global & { __updateCheckScheduled?: boolean }; + +export function scheduleUpdateCheck() { + if (g.__updateCheckScheduled) return; + g.__updateCheckScheduled = true; + + // Run immediately on startup (non-blocking) + checkForUpdate(); + + // Then every 12 hours + setInterval(checkForUpdate, POLL_INTERVAL_MS); +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..b34c569 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,37 @@ +import type { NextConfig } from "next"; +import { existsSync } from "fs"; +import { join } from "path"; + +// Open-core: the hosted MCP + its OAuth routes are a commercial (ee/) feature. When ee/ +// is stripped (public build), those routes don't exist, so the well-known rewrites below +// must NOT be emitted — otherwise they'd point at 404s. Gate them on ee/ being present. +const hasEE = existsSync(join(__dirname, "ee")); + +const nextConfig: NextConfig = { + reactStrictMode: true, + serverExternalPackages: ["better-sqlite3", "playwright", "playwright-extra", "puppeteer-extra-plugin-stealth"], + + // OAuth discovery for the hosted MCP server must live at /.well-known/* (RFC 8414 / 9728). + // Map those well-known paths to the pages-router API routes that serve the metadata. + // Present only in the commercial build (see hasEE above). + async rewrites() { + if (!hasEE) return []; + return [ + { + source: "/.well-known/oauth-authorization-server", + destination: "/api/oauth/metadata-authorization-server", + }, + { + source: "/.well-known/oauth-protected-resource", + destination: "/api/oauth/metadata-protected-resource", + }, + { + // Clients probe the resource-scoped variant too. + source: "/.well-known/oauth-protected-resource/api/mcp", + destination: "/api/oauth/metadata-protected-resource", + }, + ]; + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b28f3ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10659 @@ +{ + "name": "linki", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linki", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/bcryptjs": "^2.4.6", + "@types/mailparser": "^3.4.6", + "@types/nodemailer": "^8.0.0", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", + "daisyui": "^5.5.19", + "driver.js": "^1.6.0", + "imap-simple": "^5.1.0", + "jsonwebtoken": "^9.0.3", + "mailparser": "^3.9.8", + "next": "16.1.6", + "next-auth": "^4.24.13", + "nodemailer": "^7.0.13", + "openai": "^6.34.0", + "papaparse": "^5.5.3", + "playwright": "^1.58.2", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-icons": "^5.6.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", + "@types/imap-simple": "^4.2.10", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "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", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "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" + } + }, + "node_modules/@babel/core": { + "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", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "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", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "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", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "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" + } + }, + "node_modules/@babel/helper-module-imports": { + "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", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "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", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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" + } + }, + "node_modules/@babel/helper-validator-option": { + "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" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "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", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "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", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "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", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "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", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "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", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "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/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", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/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==", + "license": "MIT", + "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/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@modelcontextprotocol/sdk/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==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/imap": { + "version": "0.8.43", + "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.43.tgz", + "integrity": "sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/imap-simple": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/@types/imap-simple/-/imap-simple-4.2.10.tgz", + "integrity": "sha512-ZLITvpfY4+oPtH3z7axYRGJqOB/9M6cb4ye8eEydOlgXZuJFX0z9l+hxzXgrFDnWKwxH39ZPCT4bGIFUKSJ52g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/imap": "*", + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "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": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, + "node_modules/@zone-eu/mailsplit/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@zone-eu/mailsplit/node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "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/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/driver.js": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.6.0.tgz", + "integrity": "sha512-gryo9QS7AZhz8J0bmdf42dwaTzcd1BgSJLnaSM7cPJbu8bTW8wIEuiGDy4MoCvB4Sr8wA9g5o2G9EFNVYZGeVQ==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "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" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "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" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "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" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "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-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "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", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "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" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "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", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "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==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "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" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imap": { + "version": "0.8.19", + "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", + "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==", + "dependencies": { + "readable-stream": "1.1.x", + "utf7": ">=1.0.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/imap-simple": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-5.1.0.tgz", + "integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==", + "license": "MIT", + "dependencies": { + "iconv-lite": "~0.4.13", + "imap": "^0.8.18", + "nodeify": "^1.0.0", + "quoted-printable": "^1.0.0", + "utf8": "^2.1.1", + "uuencode": "0.0.4" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/imap/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/imap/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/imap/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz", + "integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "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": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "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" + } + }, + "node_modules/magic-string": { + "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" + } + }, + "node_modules/mailparser": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz", + "integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", + "linkify-it": "5.0.0", + "nodemailer": "8.0.5", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodeify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz", + "integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==", + "license": "MIT", + "dependencies": { + "is-promise": "~1.0.0", + "promise": "~1.3.0" + } + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", + "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "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" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright-extra": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", + "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "playwright": "*", + "playwright-core": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "playwright-core": { + "optional": true + } + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.28.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", + "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz", + "integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==", + "license": "MIT", + "dependencies": { + "is-promise": "~1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quoted-printable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz", + "integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==", + "license": "MIT", + "dependencies": { + "utf8": "^2.1.0" + }, + "bin": { + "quoted-printable": "bin/quoted-printable" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "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" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "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", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf7": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", + "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==", + "dependencies": { + "semver": "~5.3.0" + } + }, + "node_modules/utf7/node_modules/semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/utf8": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", + "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuencode": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz", + "integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA==" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e879777 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "linki", + "version": "0.1.0", + "private": true, + "scripts": { + "predev": "node scripts/mirror-ee.js", + "dev": "next dev", + "prebuild": "node scripts/mirror-ee.js", + "build": "next build", + "start": "next start", + "lint": "eslint", + "mirror:ee": "node scripts/mirror-ee.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/bcryptjs": "^2.4.6", + "@types/mailparser": "^3.4.6", + "@types/nodemailer": "^8.0.0", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", + "daisyui": "^5.5.19", + "driver.js": "^1.6.0", + "imap-simple": "^5.1.0", + "jsonwebtoken": "^9.0.3", + "mailparser": "^3.9.8", + "next": "16.1.6", + "next-auth": "^4.24.13", + "nodemailer": "^7.0.13", + "openai": "^6.34.0", + "papaparse": "^5.5.3", + "playwright": "^1.58.2", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-icons": "^5.6.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", + "@types/imap-simple": "^4.2.10", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 0000000..dd6838e --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,46 @@ +import "@/styles/globals.css"; +import type { AppProps } from "next/app"; +import { SessionProvider, useSession } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import Layout from "@/components/layout/Layout"; +import { Toaster } from "sonner"; + +const PUBLIC_PATHS = ["/login"]; + +function AuthGuard({ children }: { children: React.ReactNode }) { + const { data: session, status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (status === "loading") return; + if (!session && !PUBLIC_PATHS.includes(router.pathname)) { + router.replace("/login"); + } + }, [session, status, router]); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (!session && !PUBLIC_PATHS.includes(router.pathname)) return null; + + return <>{children}; +} + +export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) { + return ( + + + + + + + + + ); +} diff --git a/pages/_document.tsx b/pages/_document.tsx new file mode 100644 index 0000000..220c379 --- /dev/null +++ b/pages/_document.tsx @@ -0,0 +1,17 @@ +import { Html, Head, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + + + + + + +
+ + + + ); +} diff --git a/pages/api/accounts/[id]/authenticate.ts b/pages/api/accounts/[id]/authenticate.ts new file mode 100644 index 0000000..c3d6a41 --- /dev/null +++ b/pages/api/accounts/[id]/authenticate.ts @@ -0,0 +1,50 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { encryptSecret } from "@/lib/crypto"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const id = req.query.id as string; + + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(id); + if (!account) return res.status(404).json({ error: "Account not found" }); + + const { li_at, document_cookie } = req.body as { li_at?: string; document_cookie?: string }; + if (!li_at) return res.status(400).json({ error: "li_at cookie is required" }); + + // Parse document.cookie string into cookie objects + const extraCookies: { name: string; value: string; domain: string; path: string }[] = []; + if (document_cookie) { + for (const part of document_cookie.split(";")) { + const eqIdx = part.indexOf("="); + if (eqIdx === -1) continue; + const name = part.slice(0, eqIdx).trim(); + const value = part.slice(eqIdx + 1).trim(); + if (name && value) { + extraCookies.push({ name, value, domain: ".linkedin.com", path: "/" }); + } + } + } + + // Build Playwright-compatible storageState + const storageState = { + cookies: [ + { name: "li_at", value: li_at.trim(), domain: ".linkedin.com", path: "/", httpOnly: true, secure: true, sameSite: "None" as const }, + ...extraCookies.filter((c) => c.name !== "li_at"), + ], + origins: [], + }; + + db.prepare("UPDATE accounts SET cookies_json = ?, is_authenticated = 1 WHERE id = ?").run( + encryptSecret(JSON.stringify(storageState)), + id + ); + + // Evict the cached browser context so next import uses the new cookies + const { closeSession } = await import("@/lib/linkedin/session"); + await closeSession(id); + + return res.json({ ok: true }); +} diff --git a/pages/api/accounts/[id]/index.ts b/pages/api/accounts/[id]/index.ts new file mode 100644 index 0000000..62e6757 --- /dev/null +++ b/pages/api/accounts/[id]/index.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Excludes cookies_json — the frontend never uses the raw session blob, only +// is_authenticated, so there's no reason to ship it (even encrypted) to the client. +const ACCOUNT_COLUMNS = `id, name, email, is_authenticated, daily_connection_limit, daily_message_limit, daily_inmail_limit, + active_hours_start, active_hours_end, timezone, working_days, created_at, + inbox_synced_at, accepted_sync_at, li_connections, li_pending, li_profile_views, + li_stats_synced_at, connections_synced_through_ms`; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const account = db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`).get(id); + if (!account) return res.status(404).json({ error: "Not found" }); + return res.json(account); + } + + if (req.method === "PUT") { + const { name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit, active_hours_start, active_hours_end, timezone, working_days } = req.body; + db.prepare( + `UPDATE accounts SET + name = COALESCE(?, name), + email = COALESCE(?, email), + daily_connection_limit = COALESCE(?, daily_connection_limit), + daily_message_limit = COALESCE(?, daily_message_limit), + daily_inmail_limit = COALESCE(?, daily_inmail_limit), + active_hours_start = COALESCE(?, active_hours_start), + active_hours_end = COALESCE(?, active_hours_end), + timezone = COALESCE(?, timezone), + working_days = COALESCE(?, working_days) + WHERE id = ?` + ).run(name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit, active_hours_start, active_hours_end, timezone, working_days, id); + return res.json(db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`).get(id)); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM accounts WHERE id = ?").run(id); + return res.status(204).end(); + } + + res.setHeader("Allow", ["GET", "PUT", "DELETE"]); + res.status(405).end(); +} diff --git a/pages/api/accounts/[id]/li-stats.ts b/pages/api/accounts/[id]/li-stats.ts new file mode 100644 index 0000000..5673f09 --- /dev/null +++ b/pages/api/accounts/[id]/li-stats.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getSessionPage, saveSessionState } from "@/lib/linkedin/session"; +import { scrapeLinkedInStats } from "@/lib/linkedin/li-stats"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const accountId = req.query.id as string; + const db = getDb(); + const account = db.prepare("SELECT id, is_authenticated FROM accounts WHERE id = ?").get(accountId) as + | { id: string; is_authenticated: number } + | undefined; + + if (!account) return res.status(404).json({ error: "Account not found" }); + if (!account.is_authenticated) return res.status(400).json({ error: "Account not authenticated" }); + + let page; + try { + page = await getSessionPage(accountId); + const stats = await scrapeLinkedInStats(page); + await saveSessionState(accountId); + db.prepare(` + UPDATE accounts SET + li_connections = ?, li_pending = ?, li_profile_views = ?, + li_stats_synced_at = datetime('now') + WHERE id = ? + `).run(stats.connections, stats.pending, stats.profile_views, accountId); + return res.json(stats); + } catch (err) { + console.error("[li-stats]", err); + return res.status(500).json({ error: err instanceof Error ? err.message : "Scrape failed" }); + } finally { + await page?.close().catch(() => {}); + } +} diff --git a/pages/api/accounts/[id]/login.ts b/pages/api/accounts/[id]/login.ts new file mode 100644 index 0000000..e8bcf35 --- /dev/null +++ b/pages/api/accounts/[id]/login.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { startHeadlessLogin, submitLoginChallenge, awaitLoginApproval } from "@/lib/linkedin/session"; + +/** + * Server-side headless LinkedIn login. + * POST { step: "start", email, password } → begins login (returns authenticated | challenge | error) + * POST { step: "verify", code } → submits the email/SMS verification code + * POST { step: "await" } → waits for a device/app approval to clear + * + * The session is born under the same pinned Chromium fingerprint the runner uses + * and captures all cookies (incl. httpOnly li_ep_auth_context). + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const id = req.query.id as string; + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(id) as + | { email: string } + | undefined; + if (!account) return res.status(404).json({ error: "Account not found" }); + + const { step } = req.body as { step?: string }; + + try { + if (step === "verify") { + const { code } = req.body as { code?: string }; + if (!code?.trim()) return res.status(400).json({ error: "code is required" }); + return res.json(await submitLoginChallenge(id, code.trim())); + } + + if (step === "await") { + return res.json(await awaitLoginApproval(id)); + } + + const { email, password } = req.body as { email?: string; password?: string }; + const loginEmail = (email || account.email || "").trim(); + if (!loginEmail || !password) { + return res.status(400).json({ error: "email and password are required" }); + } + return res.json(await startHeadlessLogin(id, loginEmail, password)); + } catch (e) { + return res.status(500).json({ error: (e as Error).message }); + } +} diff --git a/pages/api/accounts/[id]/sync-accepted.ts b/pages/api/accounts/[id]/sync-accepted.ts new file mode 100644 index 0000000..490d42a --- /dev/null +++ b/pages/api/accounts/[id]/sync-accepted.ts @@ -0,0 +1,72 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getSessionPage, saveSessionState } from "@/lib/linkedin/session"; +import { scrapePendingInvitationVanityNames } from "@/lib/linkedin/pending-invitations"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const accountId = req.query.id as string; + const db = getDb(); + + const account = db.prepare("SELECT id, is_authenticated FROM accounts WHERE id = ?").get(accountId) as + | { id: string; is_authenticated: number } + | undefined; + + if (!account) return res.status(404).json({ error: "Account not found" }); + if (!account.is_authenticated) return res.status(400).json({ error: "Account not authenticated" }); + + let page; + try { + page = await getSessionPage(accountId); + const stillPending = await scrapePendingInvitationVanityNames(page); + await saveSessionState(accountId); + + // Find all targets that we sent a request to but haven't marked as connected + const waiting = db.prepare(` + SELECT id, linkedin_url, full_name + FROM targets + WHERE connection_requested_at IS NOT NULL + AND (degree IS NULL OR degree != 1) + AND connected_at IS NULL + `).all() as { id: string; linkedin_url: string; full_name: string | null }[]; + + const now = new Date().toISOString(); + const accepted: string[] = []; + const skipped: string[] = []; + + const markAccepted = db.prepare( + "UPDATE targets SET degree = 1, connected_at = ? WHERE id = ?" + ); + + db.transaction(() => { + for (const target of waiting) { + const match = target.linkedin_url?.match(/\/in\/([^/?#]+)/); + if (!match) continue; // no /in/ URL — can't check + const vanity = match[1].toLowerCase(); + + if (!stillPending.has(vanity)) { + // Not in the pending list anymore → accepted (or expired, but treat as accepted) + markAccepted.run(now, target.id); + accepted.push(target.full_name ?? vanity); + } else { + skipped.push(target.full_name ?? vanity); + } + } + })(); + + return res.json({ + pending_on_linkedin: stillPending.size, + waiting_in_db: waiting.length, + newly_accepted: accepted.length, + still_pending: skipped.length, + accepted_names: accepted, + }); + + } catch (err) { + console.error("[sync-accepted]", err); + return res.status(500).json({ error: err instanceof Error ? err.message : "Sync failed" }); + } finally { + await page?.close().catch(() => {}); + } +} diff --git a/pages/api/accounts/index.ts b/pages/api/accounts/index.ts new file mode 100644 index 0000000..77a2a3a --- /dev/null +++ b/pages/api/accounts/index.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + // Excludes cookies_json — the frontend never uses the raw session blob, only + // is_authenticated, so there's no reason to ship it (even encrypted) to the client. + const ACCOUNT_COLUMNS = `a.id, a.name, a.email, a.is_authenticated, a.daily_connection_limit, a.daily_message_limit, a.daily_inmail_limit, + a.active_hours_start, a.active_hours_end, a.timezone, a.working_days, a.created_at, + a.inbox_synced_at, a.accepted_sync_at, a.li_connections, a.li_pending, a.li_profile_views, + a.li_stats_synced_at, a.connections_synced_through_ms`; + + if (req.method === "GET") { + const accounts = db.prepare(` + SELECT ${ACCOUNT_COLUMNS}, + (SELECT COUNT(*) FROM runs r WHERE r.account_id = a.id AND r.status IN ('running', 'paused')) AS active_run_count + FROM accounts a ORDER BY a.created_at DESC + `).all(); + return res.json(accounts); + } + + if (req.method === "POST") { + const { name, email, daily_connection_limit = 20, daily_message_limit = 50, daily_inmail_limit = 15 } = req.body; + if (!name || !email) return res.status(400).json({ error: "name and email required" }); + try { + const id = randomUUID(); + db + .prepare( + "INSERT INTO accounts (id, name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit) VALUES (?, ?, ?, ?, ?, ?)" + ) + .run(id, name, email, daily_connection_limit, daily_message_limit, daily_inmail_limit); + const account = db.prepare(`SELECT ${ACCOUNT_COLUMNS} FROM accounts a WHERE a.id = ?`).get(id); + return res.status(201).json(account); + } catch { + return res.status(409).json({ error: "Email already exists" }); + } + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(); +} diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts new file mode 100644 index 0000000..0c87db3 --- /dev/null +++ b/pages/api/auth/[...nextauth].ts @@ -0,0 +1,48 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import bcrypt from "bcryptjs"; +import { getDb } from "@/lib/db"; +import { isRateLimited } from "@/lib/rate-limit"; + +type UserRow = { id: string; email: string; password_hash: string }; + +export const authOptions: NextAuthOptions = { + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials, req) { + if (!credentials?.email || !credentials?.password) return null; + + // Throttle login attempts per IP — this is the password brute-force surface. + if (isRateLimited(req, "login", 10, 15 * 60 * 1000)) { + throw new Error("Too many attempts. Try again later."); + } + + const db = getDb(); + const user = db + .prepare("SELECT id, email, password_hash FROM users WHERE email = ?") + .get(credentials.email) as UserRow | undefined; + + if (!user) return null; + + const valid = await bcrypt.compare(credentials.password, user.password_hash); + if (!valid) return null; + + return { id: user.id, email: user.email }; + }, + }), + ], + pages: { + signIn: "/login", + }, + session: { + strategy: "jwt", + }, + secret: process.env.NEXTAUTH_SECRET, +}; + +export default NextAuth(authOptions); diff --git a/pages/api/auth/change-password.ts b/pages/api/auth/change-password.ts new file mode 100644 index 0000000..9119049 --- /dev/null +++ b/pages/api/auth/change-password.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "./[...nextauth]"; +import bcrypt from "bcryptjs"; +import { getDb } from "@/lib/db"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const session = await getServerSession(req, res, authOptions); + if (!session?.user?.email) return res.status(401).json({ error: "Not authenticated" }); + + const { currentPassword, newPassword } = req.body as { + currentPassword?: string; + newPassword?: string; + }; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: "Current and new password are required." }); + } + if (newPassword.length < 8) { + return res.status(400).json({ error: "New password must be at least 8 characters." }); + } + + const db = getDb(); + const user = db + .prepare("SELECT id, password_hash FROM users WHERE email = ?") + .get(session.user.email) as { id: string; password_hash: string } | undefined; + + if (!user) return res.status(404).json({ error: "User not found." }); + + const valid = await bcrypt.compare(currentPassword, user.password_hash); + if (!valid) return res.status(400).json({ error: "Current password is incorrect." }); + + const hash = await bcrypt.hash(newPassword, 10); + db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id); + + return res.status(200).json({ ok: true }); +} diff --git a/pages/api/auth/signup.ts b/pages/api/auth/signup.ts new file mode 100644 index 0000000..c328fcb --- /dev/null +++ b/pages/api/auth/signup.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import bcrypt from "bcryptjs"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; +import { isRateLimited } from "@/lib/rate-limit"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + // Invite code + password are both guessable secrets — throttle attempts per IP. + if (isRateLimited(req, "signup", 10, 15 * 60 * 1000)) { + return res.status(429).json({ error: "Too many attempts. Try again later." }); + } + + const { email, password, inviteCode } = req.body as { + email?: string; + password?: string; + inviteCode?: string; + }; + + if (!email || !password || !inviteCode) { + return res.status(400).json({ error: "Email, password, and invite code are required." }); + } + + const authPassword = process.env.AUTH_PASSWORD; + if (!authPassword) { + return res.status(500).json({ error: "AUTH_PASSWORD is not configured on this server." }); + } + + if (inviteCode !== authPassword) { + return res.status(403).json({ error: "Invalid invite code." }); + } + + if (password.length < 8) { + return res.status(400).json({ error: "Password must be at least 8 characters." }); + } + + const db = getDb(); + const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); + if (existing) { + return res.status(409).json({ error: "An account with this email already exists." }); + } + + const hash = await bcrypt.hash(password, 10); + db.prepare("INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)").run(randomUUID(), email, hash); + + return res.status(201).json({ ok: true }); +} diff --git a/pages/api/companies/[id].ts b/pages/api/companies/[id].ts new file mode 100644 index 0000000..2b4a6b9 --- /dev/null +++ b/pages/api/companies/[id].ts @@ -0,0 +1,45 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const company = db.prepare("SELECT * FROM companies WHERE id = ?").get(id); + if (!company) return res.status(404).json({ error: "not found" }); + const contacts = db + .prepare("SELECT id, full_name, title, email, linkedin_url FROM targets WHERE company_id = ? ORDER BY full_name") + .all(id); + return res.json({ ...company as object, contacts }); + } + + if (req.method === "PUT") { + const { name, domain, industry, location, linkedin_url, website, notes } = req.body; + db.prepare(` + UPDATE companies SET + name = COALESCE(?, name), + domain = ?, + industry = ?, + location = ?, + linkedin_url = ?, + website = ?, + notes = ? + WHERE id = ? + `).run( + name ?? null, domain ?? null, industry ?? null, + location ?? null, linkedin_url ?? null, website ?? null, notes ?? null, + id + ); + return res.json(db.prepare("SELECT * FROM companies WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + // Unlink contacts first, then delete company + db.prepare("UPDATE targets SET company_id = NULL WHERE company_id = ?").run(id); + db.prepare("DELETE FROM companies WHERE id = ?").run(id); + return res.json({ ok: true }); + } + + res.status(405).end(); +} diff --git a/pages/api/companies/index.ts b/pages/api/companies/index.ts new file mode 100644 index 0000000..8768c2d --- /dev/null +++ b/pages/api/companies/index.ts @@ -0,0 +1,57 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + // Paging + light default fields — the full enrichment blobs (description, keywords, + // technology_names, etc.) are heavy; only return them when full=1 is requested. + const full = req.query.full === "1" || req.query.full === "true"; + const search = (req.query.search as string | undefined)?.trim(); + // Page by default (trimmed fields). full=1 with no explicit limit returns the whole set + // (the companies UI relies on this for its own client-side search/filter). + const explicitPaging = req.query.limit !== undefined || req.query.page !== undefined; + const hasPaging = explicitPaging || !full; + const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500); + const offset = (Number(req.query.page) || 0) * limit; + + const where = search ? "WHERE c.name LIKE ?" : ""; + const whereArgs: unknown[] = search ? [`%${search}%`] : []; + + const select = full + ? "c.*" + : "c.id, c.name, c.domain, c.industry, c.location, c.website, c.employee_count, c.created_at"; + + const total = (db.prepare(`SELECT COUNT(*) as c FROM companies c ${where}`).get(...whereArgs) as { c: number }).c; + + const pageClause = hasPaging ? " LIMIT ? OFFSET ?" : ""; + const pageArgs = hasPaging ? [limit, offset] : []; + + const companies = db.prepare(` + SELECT ${select}, COUNT(t.id) as contact_count + FROM companies c + LEFT JOIN targets t ON t.company_id = c.id + ${where} + GROUP BY c.id + ORDER BY c.name COLLATE NOCASE${pageClause} + `).all(...whereArgs, ...pageArgs); + + return res.json({ companies, total }); + } + + if (req.method === "POST") { + const { name, domain, industry, location, linkedin_url, website, notes } = req.body; + if (!name) return res.status(400).json({ error: "name required" }); + const id = randomUUID(); + db.prepare(` + INSERT INTO companies (id, name, domain, industry, location, linkedin_url, website, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, name, domain ?? null, industry ?? null, location ?? null, linkedin_url ?? null, website ?? null, notes ?? null); + return res.status(201).json(db.prepare("SELECT * FROM companies WHERE id = ?").get(id)); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(); +} diff --git a/pages/api/dashboard/agent-stats.ts b/pages/api/dashboard/agent-stats.ts new file mode 100644 index 0000000..aacb567 --- /dev/null +++ b/pages/api/dashboard/agent-stats.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + + try { + const db = getDb(); + const days = Math.min(Math.max(Number(req.query.days) || 7, 7), 90); + + const daily = db.prepare(` + SELECT + date(created_at) AS day, + SUM(cost_usd) AS cost_usd, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens + FROM agent_sessions + WHERE created_at >= datetime('now', '-${days} days') + GROUP BY date(created_at) + ORDER BY day ASC + `).all() as { day: string; cost_usd: number; input_tokens: number; output_tokens: number }[]; + + // Fill missing days with zeros + const filled: typeof daily = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + const found = daily.find(r => r.day === key); + filled.push(found ?? { day: key, cost_usd: 0, input_tokens: 0, output_tokens: 0 }); + } + + res.json({ daily: filled }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to load agent stats" }); + } +} diff --git a/pages/api/dashboard/stats.ts b/pages/api/dashboard/stats.ts new file mode 100644 index 0000000..b36eaad --- /dev/null +++ b/pages/api/dashboard/stats.ts @@ -0,0 +1,170 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + + try { + const db = getDb(); + + const listId = req.query.list_id as string | undefined; + const workflowId = req.query.workflow_id as string | undefined; + const days = Math.min(Math.max(Number(req.query.days) || 7, 7), 90); + + // Fetch lists and workflows for filter dropdowns (always unfiltered) + const lists = db.prepare("SELECT id, name FROM lists ORDER BY name").all() as { id: string; name: string }[]; + const workflows = db.prepare("SELECT id, name FROM workflows ORDER BY name").all() as { id: string; name: string }[]; + + // Today's summary (always global — not scoped to filter) + const today = db.prepare(` + SELECT + COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits_today, + COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections_today, + COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages_today, + COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails_today + FROM logs + WHERE date(created_at) = date('now') + `).get() as Record; + + if (!workflowId && !listId) { + // ── Unfiltered: use targets fields (fast, global) ────────────────────── + const ACTIVE = `id IN (SELECT DISTINCT target_id FROM list_targets)`; + + const totals = db.prepare(` + SELECT + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE}) AS total_targets, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND connection_requested_at IS NOT NULL) AS connections_requested, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND connected_at IS NOT NULL) AS connected, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND message_sent_at IS NOT NULL) AS messages_sent, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND inmail_sent_at IS NOT NULL) AS inmails_sent, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND last_replied_at IS NOT NULL) AS replies_received, + (SELECT COUNT(*) FROM runs WHERE status = 'running') AS active_runs, + (SELECT COUNT(*) FROM lists) AS total_lists, + (SELECT COUNT(*) FROM workflows) AS total_workflows, + (SELECT COUNT(*) FROM logs WHERE message LIKE 'Email sent%') AS emails_sent, + (SELECT COUNT(*) FROM targets WHERE ${ACTIVE} AND email_replied_at IS NOT NULL) AS email_replies + `).get() as Record; + + const activity = db.prepare(` + SELECT + date(created_at) AS day, + COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits, + COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections, + COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages, + COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails, + COUNT(CASE WHEN message LIKE 'Email sent%' THEN 1 END) AS emails + FROM logs + WHERE created_at >= datetime('now', '-${days} days') + GROUP BY date(created_at) + ORDER BY day ASC + `).all() as { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[]; + + const filled = fillDays(activity, days); + return res.json({ totals, today, activity: filled, lists, workflows }); + } + + // ── Filtered by workflow or list: use logs as source of truth ───────────── + // Build the scoped runs subquery + its single argument + let runsSubquery: string; + let runsArg: string; + if (workflowId) { + runsSubquery = `SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running','paused','completed')`; + runsArg = workflowId; + } else { + runsSubquery = `SELECT id FROM runs WHERE list_id = ? AND status IN ('running','paused','completed')`; + runsArg = listId!; + } + + // Targets in scope = distinct targets that appeared in scoped runs + const SCOPED_TARGETS = `SELECT DISTINCT target_id FROM run_profiles WHERE run_id IN (${runsSubquery})`; + + const totals = db.prepare(` + SELECT + (SELECT COUNT(*) FROM (${SCOPED_TARGETS})) AS total_targets, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${runsSubquery}) + AND message LIKE 'Connection request sent%') AS connections_requested, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${runsSubquery}) + AND l.message LIKE 'Connection request sent%' + AND t.connected_at IS NOT NULL) AS connected, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${runsSubquery}) + AND message LIKE 'Message sent%') AS messages_sent, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${runsSubquery}) + AND message LIKE 'InMail sent%') AS inmails_sent, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${runsSubquery}) + AND (l.message LIKE 'Message sent%' OR l.message LIKE 'InMail sent%') + AND t.last_replied_at IS NOT NULL) AS replies_received, + + (SELECT COUNT(*) FROM runs WHERE status = 'running') AS active_runs, + (SELECT COUNT(*) FROM lists) AS total_lists, + (SELECT COUNT(*) FROM workflows) AS total_workflows, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${runsSubquery}) + AND message LIKE 'Email sent%') AS emails_sent, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${runsSubquery}) + AND l.message LIKE 'Email sent%' + AND t.email_replied_at IS NOT NULL) AS email_replies + `).get( + runsArg, // SCOPED_TARGETS + runsArg, // connections_requested + runsArg, // connected + runsArg, // messages_sent + runsArg, // inmails_sent + runsArg, // replies_received + runsArg, // emails_sent + runsArg, // email_replies + ) as Record; + + const activity = db.prepare(` + SELECT + date(created_at) AS day, + COUNT(CASE WHEN message LIKE 'Visited%' THEN 1 END) AS visits, + COUNT(CASE WHEN message LIKE 'Connection request sent%' THEN 1 END) AS connections, + COUNT(CASE WHEN message LIKE 'Message sent%' THEN 1 END) AS messages, + COUNT(CASE WHEN message LIKE 'InMail sent%' THEN 1 END) AS inmails, + COUNT(CASE WHEN message LIKE 'Email sent%' THEN 1 END) AS emails + FROM logs + WHERE created_at >= datetime('now', '-${days} days') + AND run_id IN (${runsSubquery}) + GROUP BY date(created_at) + ORDER BY day ASC + `).all(runsArg) as { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[]; + + const filled = fillDays(activity, days); + return res.json({ totals, today, activity: filled, lists, workflows }); + + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to load dashboard stats" }); + } +} + +function fillDays( + activity: { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[], + days: number +) { + const filled: typeof activity = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + const found = activity.find(r => r.day === key); + filled.push(found ?? { day: key, visits: 0, connections: 0, messages: 0, inmails: 0, emails: 0 }); + } + return filled; +} diff --git a/pages/api/email-accounts/[id]/index.ts b/pages/api/email-accounts/[id]/index.ts new file mode 100644 index 0000000..06bdce0 --- /dev/null +++ b/pages/api/email-accounts/[id]/index.ts @@ -0,0 +1,78 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { encryptSecret } from "@/lib/crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const account = db + .prepare("SELECT id, name, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, imap_host, imap_port, username, imap_username, daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, is_verified, signature, ramp_up_enabled, ramp_start_date, created_at FROM email_accounts WHERE id = ?") + .get(id); + if (!account) return res.status(404).json({ error: "not found" }); + return res.json(account); + } + + if (req.method === "PUT") { + const { + name, from_email, from_name, reply_to, + smtp_host, smtp_port, smtp_secure, + imap_host, imap_port, + username, password, + imap_username, imap_password, + daily_email_limit, + active_hours_start, active_hours_end, + timezone, working_days, + signature, + ramp_up_enabled, + ramp_start_date, + } = req.body; + + const rampEnabled = ramp_up_enabled != null ? (ramp_up_enabled ? 1 : 0) : null; + + // Build password SET clauses only when provided (don't wipe on partial update) + const pwClause = password ? "password = ?," : ""; + const imapPwClause = imap_password ? "imap_password = ?," : ""; + + const pwParams = password ? [encryptSecret(password)] : []; + const imapPwParams = imap_password ? [encryptSecret(imap_password)] : []; + + db.prepare(` + UPDATE email_accounts SET + name = COALESCE(?, name), from_email = COALESCE(?, from_email), + from_name = ?, reply_to = ?, smtp_host = COALESCE(?, smtp_host), + smtp_port = COALESCE(?, smtp_port), smtp_secure = COALESCE(?, smtp_secure), + imap_host = ?, imap_port = COALESCE(?, imap_port), + username = COALESCE(?, username), ${pwClause} + imap_username = ?, ${imapPwClause} + daily_email_limit = COALESCE(?, daily_email_limit), + active_hours_start = COALESCE(?, active_hours_start), + active_hours_end = COALESCE(?, active_hours_end), + timezone = COALESCE(?, timezone), working_days = COALESCE(?, working_days), + signature = ?, ramp_up_enabled = COALESCE(?, ramp_up_enabled), + ramp_start_date = COALESCE(?, ramp_start_date) + WHERE id = ? + `).run( + name ?? null, from_email ?? null, from_name ?? null, reply_to ?? null, + smtp_host ?? null, smtp_port ?? null, smtp_secure ?? null, + imap_host ?? null, imap_port ?? null, + username ?? null, ...pwParams, + imap_username ?? null, ...imapPwParams, + daily_email_limit ?? null, + active_hours_start ?? null, active_hours_end ?? null, + timezone ?? null, working_days ?? null, + signature ?? null, rampEnabled, ramp_start_date ?? null, + id + ); + + return res.json({ ok: true }); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM email_accounts WHERE id = ?").run(id); + return res.json({ ok: true }); + } + + res.status(405).end(); +} diff --git a/pages/api/email-accounts/[id]/send-test.ts b/pages/api/email-accounts/[id]/send-test.ts new file mode 100644 index 0000000..708828b --- /dev/null +++ b/pages/api/email-accounts/[id]/send-test.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { sendEmail } from "@/lib/email/sender"; +import { decryptSecret } from "@/lib/crypto"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const id = req.query.id as string; + const { to, subject, body } = req.body as { to?: string; subject?: string; body?: string }; + + if (!to) return res.status(400).json({ error: "to is required" }); + + const account = db + .prepare("SELECT * FROM email_accounts WHERE id = ?") + .get(id) as { + id: string; from_email: string; from_name: string | null; + smtp_host: string; smtp_port: number; smtp_secure: number; + username: string; password: string; signature: string | null; + } | undefined; + + if (!account) return res.status(404).json({ error: "not found" }); + + const fullBody = buildEmailBody(body ?? "", account.signature); + + try { + await sendEmail({ ...account, password: decryptSecret(account.password)! }, to, subject ?? "Test email from Linki", fullBody); + return res.json({ ok: true }); + } catch (err) { + return res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } +} + +function buildEmailBody(body: string, signature: string | null): string { + const sig = signature?.trim(); + if (!sig) return body; + return `${body}\n\n--\n${sig}`; +} diff --git a/pages/api/email-accounts/[id]/test.ts b/pages/api/email-accounts/[id]/test.ts new file mode 100644 index 0000000..2e2b2b0 --- /dev/null +++ b/pages/api/email-accounts/[id]/test.ts @@ -0,0 +1,68 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { testSmtpConnection, testImapConnection } from "@/lib/email/sender"; +import { decryptSecret } from "@/lib/crypto"; + +interface AccountRow { + from_email: string; + from_name: string | null; + smtp_host: string; + smtp_port: number; + smtp_secure: number; + imap_host: string | null; + imap_port: number; + username: string; + password: string; + imap_username: string | null; + imap_password: string | null; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const id = req.query.id as string; + + const row = db + .prepare("SELECT * FROM email_accounts WHERE id = ?") + .get(id) as AccountRow | undefined; + + if (!row) return res.status(404).json({ error: "not found" }); + + const account: AccountRow = { + ...row, + password: decryptSecret(row.password)!, + imap_password: decryptSecret(row.imap_password), + }; + + const smtpError = await testSmtpConnection(account); + + let imapError: string | null = null; + if (account.imap_host) { + imapError = await testImapConnection({ + imap_host: account.imap_host, + imap_port: account.imap_port ?? 993, + username: account.username, + password: account.password, + imap_username: account.imap_username, + imap_password: account.imap_password, + }); + } + + const ok = !smtpError && !imapError; + + if (ok) { + db.prepare("UPDATE email_accounts SET is_verified = 1 WHERE id = ?").run(id); + } + + return res.status(ok ? 200 : 400).json({ + ok, + smtp: smtpError ? { ok: false, error: smtpError } : { ok: true }, + imap: account.imap_host + ? (imapError ? { ok: false, error: imapError } : { ok: true }) + : null, + }); +} diff --git a/pages/api/email-accounts/index.ts b/pages/api/email-accounts/index.ts new file mode 100644 index 0000000..dd5822e --- /dev/null +++ b/pages/api/email-accounts/index.ts @@ -0,0 +1,74 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; +import { encryptSecret } from "@/lib/crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + // Never return passwords to the client + const accounts = db + .prepare(` + SELECT ea.id, ea.name, ea.from_email, ea.from_name, ea.reply_to, + ea.smtp_host, ea.smtp_port, ea.smtp_secure, + ea.imap_host, ea.imap_port, ea.username, ea.imap_username, + ea.daily_email_limit, ea.active_hours_start, ea.active_hours_end, + ea.timezone, ea.working_days, ea.is_verified, ea.signature, + ea.ramp_up_enabled, ea.ramp_start_date, ea.created_at, + (SELECT COUNT(DISTINCT rp.run_id) FROM run_profiles rp + JOIN runs r ON rp.run_id = r.id + WHERE rp.email_account_id = ea.id + AND r.status IN ('running', 'paused')) AS active_run_count + FROM email_accounts ea ORDER BY ea.created_at DESC + `) + .all(); + return res.json(accounts); + } + + if (req.method === "POST") { + const { + name, from_email, from_name, reply_to, + smtp_host, smtp_port = 587, smtp_secure = 0, + imap_host, imap_port = 993, + username, password, + imap_username, imap_password, + daily_email_limit = 50, + active_hours_start = 9, active_hours_end = 18, + timezone = "UTC", working_days = "1,2,3,4,5", + signature, + ramp_up_enabled = 1, + ramp_start_date, + } = req.body; + + if (!name || !from_email || !smtp_host || !username || !password) { + return res.status(400).json({ error: "name, from_email, smtp_host, username, password required" }); + } + + const id = randomUUID(); + const resolvedRampStart = ramp_start_date ?? new Date().toISOString().slice(0, 10); + db.prepare(` + INSERT INTO email_accounts + (id, name, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, + imap_host, imap_port, username, password, + imap_username, imap_password, + daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, signature, + ramp_up_enabled, ramp_start_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, name, from_email, from_name ?? null, reply_to ?? null, + smtp_host, smtp_port, smtp_secure, + imap_host ?? null, imap_port, + username, encryptSecret(password), + imap_username ?? null, imap_password ? encryptSecret(imap_password) : null, + daily_email_limit, active_hours_start, active_hours_end, timezone, working_days, + signature ?? null, + ramp_up_enabled ? 1 : 0, resolvedRampStart + ); + + return res.status(201).json({ id }); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(); +} diff --git a/pages/api/email-health.ts b/pages/api/email-health.ts new file mode 100644 index 0000000..4bcfa8f --- /dev/null +++ b/pages/api/email-health.ts @@ -0,0 +1,96 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + const db = getDb(); + + const accounts = db.prepare(` + SELECT id, name, from_email, daily_email_limit, ramp_up_enabled, ramp_start_date + FROM email_accounts + ORDER BY name + `).all() as { + id: string; name: string; from_email: string; + daily_email_limit: number; ramp_up_enabled: number; ramp_start_date: string | null; + }[]; + + // Sent per account per day for the last 7 days + const dailySends = db.prepare(` + SELECT rp.email_account_id, + date(l.created_at) as day, + COUNT(*) as sent + FROM logs l + JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id + WHERE l.message LIKE 'Email sent%' + AND l.created_at >= date('now', '-6 days') + GROUP BY rp.email_account_id, date(l.created_at) + `).all() as { email_account_id: string; day: string; sent: number }[]; + + // Last 50 email send log entries across all accounts + const recentLogs = db.prepare(` + SELECT l.created_at, l.message, rp.email_account_id + FROM logs l + JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id + WHERE l.message LIKE 'Email sent%' + ORDER BY l.created_at DESC + LIMIT 50 + `).all() as { created_at: string; message: string; email_account_id: string }[]; + + // Guard trips today + const guardTrips = db.prepare(` + SELECT l.created_at, l.message, rp.email_account_id + FROM logs l + LEFT JOIN run_profiles rp ON rp.run_id = l.run_id AND rp.target_id = l.target_id + WHERE (l.message LIKE '%Daily limit%' OR l.message LIKE '%limit guard%') + AND date(l.created_at) = date('now') + ORDER BY l.created_at DESC + LIMIT 50 + `).all() as { created_at: string; message: string; email_account_id: string | null }[]; + + function effectiveLimit(a: typeof accounts[0], date: Date) { + if (!a.ramp_up_enabled || !a.ramp_start_date) return a.daily_email_limit; + const start = new Date(a.ramp_start_date); + const daysActive = Math.floor((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + return Math.min(a.daily_email_limit, daysActive * 2); + } + + const now = new Date(); + // Build last 7 days list (UTC dates) + const days: string[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(d.getUTCDate() - i); + days.push(d.toISOString().slice(0, 10)); + } + + // Index sends: accountId -> day -> count + const sendsIndex: Record> = {}; + for (const row of dailySends) { + if (!sendsIndex[row.email_account_id]) sendsIndex[row.email_account_id] = {}; + sendsIndex[row.email_account_id][row.day] = row.sent; + } + + const today = now.toISOString().slice(0, 10); + + const result = accounts.map(a => { + const sentToday = sendsIndex[a.id]?.[today] ?? 0; + const limit = effectiveLimit(a, now); + return { + id: a.id, + name: a.name, + from_email: a.from_email, + daily_email_limit: a.daily_email_limit, + ramp_up_enabled: a.ramp_up_enabled, + ramp_start_date: a.ramp_start_date, + effective_limit_today: limit, + sent_today: sentToday, + days: days.map(day => ({ + day, + sent: sendsIndex[a.id]?.[day] ?? 0, + limit: effectiveLimit(a, new Date(day + "T12:00:00Z")), + })), + }; + }); + + res.json({ accounts: result, days, recentLogs, guardTrips }); +} diff --git a/pages/api/hello.ts b/pages/api/hello.ts new file mode 100644 index 0000000..ea77e8f --- /dev/null +++ b/pages/api/hello.ts @@ -0,0 +1,13 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; + +type Data = { + name: string; +}; + +export default function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + res.status(200).json({ name: "John Doe" }); +} diff --git a/pages/api/imports/[id]/cancel.ts b/pages/api/imports/[id]/cancel.ts new file mode 100644 index 0000000..6566050 --- /dev/null +++ b/pages/api/imports/[id]/cancel.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { cancelImport } from "@/lib/import-jobs"; + +/** POST — cancel an import batch. A running batch stops at its next page boundary. */ +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + const db = getDb(); + const id = req.query.id as string; + + const job = db.prepare("SELECT id, status FROM list_imports WHERE id = ?").get(id) as + | { id: string; status: string } + | undefined; + if (!job) return res.status(404).json({ error: "Import not found" }); + + cancelImport(db, id); + return res.json({ ok: true }); +} diff --git a/pages/api/imports/index.ts b/pages/api/imports/index.ts new file mode 100644 index 0000000..b469fd7 --- /dev/null +++ b/pages/api/imports/index.ts @@ -0,0 +1,32 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getDailyImportCap, importedToday } from "@/lib/import-jobs"; + +/** GET — all import jobs (active first), with list names, for the Jobs panel. */ +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + const db = getDb(); + + const jobs = db.prepare(` + SELECT li.id, li.list_id, l.name AS list_name, li.status, li.phase, + li.page, li.total_pages, li.count, li.total, li.imported, li.skipped, li.error, + li.scheduled_for, li.start_page, li.batch_index, li.started_at, li.finished_at + FROM list_imports li + LEFT JOIN lists l ON l.id = li.list_id + WHERE li.status IN ('running', 'scheduled') + OR li.finished_at >= datetime('now', '-2 days') + ORDER BY + CASE li.status WHEN 'running' THEN 0 WHEN 'scheduled' THEN 1 ELSE 2 END, + li.scheduled_for ASC, + li.started_at DESC + `).all(); + + return res.json({ + jobs, + dailyCap: getDailyImportCap(db), + importedToday: importedToday(db), + }); +} diff --git a/pages/api/inbox/[replyId]/cancel-followup.ts b/pages/api/inbox/[replyId]/cancel-followup.ts new file mode 100644 index 0000000..8e7bd11 --- /dev/null +++ b/pages/api/inbox/[replyId]/cancel-followup.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { randomUUID } from "crypto"; +import { getDb } from "@/lib/db"; + +/** + * Cancels a scheduled OOO follow-up: clears the email track's next_step_at + + * pending_reply_context and stamps the contact's email_replied_at, so the runner + * skips them on the next tick (treats the reply as bucket D retroactively). + */ +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const replyId = req.query.replyId as string; + if (!replyId) return res.status(400).json({ error: "replyId required" }); + + const reply = db + .prepare("SELECT target_id, run_id FROM email_replies WHERE id = ?") + .get(replyId) as { target_id: string; run_id: string | null } | undefined; + if (!reply) return res.status(404).json({ error: "reply_not_found" }); + + if (reply.run_id) { + db.prepare( + `UPDATE run_profile_tracks SET state = 'skipped', next_step_at = NULL, pending_reply_context = NULL, + error_message = 'Follow-up cancelled from inbox' + WHERE track = 'email' AND state IN ('pending', 'in_progress') + AND run_profile_id IN ( + SELECT id FROM run_profiles WHERE run_id = ? AND target_id = ? + )`, + ).run(reply.run_id, reply.target_id); + } + + db.prepare("UPDATE targets SET email_replied_at = datetime('now') WHERE id = ?").run(reply.target_id); + db.prepare( + "INSERT INTO activity_logs (id, target_id, type, body) VALUES (?, ?, 'email', 'Scheduled follow-up cancelled from inbox.')", + ).run(randomUUID(), reply.target_id); + + db.prepare( + "UPDATE email_replies SET dispatch_result_json = ? WHERE id = ?", + ).run(JSON.stringify({ kind: "cancelled", notes: "Follow-up cancelled from inbox" }), replyId); + + return res.json({ ok: true }); +} diff --git a/pages/api/inbox/index.ts b/pages/api/inbox/index.ts new file mode 100644 index 0000000..1f3a5c3 --- /dev/null +++ b/pages/api/inbox/index.ts @@ -0,0 +1,119 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export interface InboxReply { + id: string; + full_name: string | null; + linkedin_url: string | null; + email: string | null; + headline: string | null; + company: string | null; + channel: "email" | "linkedin" | "both"; + replied_at: string; + email_replied_at: string | null; + last_replied_at: string | null; + // run context + run_id: string | null; + workflow_id: string | null; + workflow_name: string | null; + // email account context + email_account_id: string | null; + email_account_name: string | null; + email_account_from: string | null; + // classifier / dispatcher context (from the most recent email_replies row) + reply_id: string | null; + reply_kind: string | null; + reply_summary: string | null; + reply_body: string | null; + classified_at: string | null; + classification_error: string | null; + dispatched_at: string | null; + dispatch_result_json: string | null; + manually_edited: number; +} + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const db = getDb(); + const channel = req.query.channel as string | undefined; // "email" | "linkedin" | undefined + + // A target shows in the inbox if it has a reply stamp OR a captured email_replies row. + // OOO-followup replies intentionally leave email_replied_at NULL, so we must include + // the email_replies source to keep scheduled follow-ups visible. + let channelFilter = + "AND (t.email_replied_at IS NOT NULL OR t.last_replied_at IS NOT NULL OR er.id IS NOT NULL)"; + if (channel === "email") channelFilter = "AND (t.email_replied_at IS NOT NULL OR er.id IS NOT NULL)"; + if (channel === "linkedin") channelFilter = "AND t.last_replied_at IS NOT NULL"; + + const rows = db.prepare(` + SELECT + t.id, + t.full_name, + t.linkedin_url, + t.email, + t.headline, + t.company, + t.email_replied_at, + t.last_replied_at, + CASE + WHEN (t.email_replied_at IS NOT NULL OR er.id IS NOT NULL) AND t.last_replied_at IS NOT NULL THEN 'both' + WHEN t.email_replied_at IS NOT NULL OR er.id IS NOT NULL THEN 'email' + ELSE 'linkedin' + END AS channel, + MAX( + COALESCE(t.email_replied_at, ''), + COALESCE(t.last_replied_at, ''), + COALESCE(er.received_at, '') + ) AS replied_at, + r.id AS run_id, + r.workflow_id, + w.name AS workflow_name, + ea.id AS email_account_id, + ea.name AS email_account_name, + ea.from_email AS email_account_from, + er.id AS reply_id, + er.classification_json AS classification_json, + er.body_text AS reply_body, + er.classified_at, + er.classification_error, + er.dispatched_at, + er.dispatch_result_json, + COALESCE(er.manually_edited, 0) AS manually_edited + FROM targets t + LEFT JOIN run_profiles rp ON rp.target_id = t.id + LEFT JOIN runs r ON r.id = rp.run_id AND r.status IN ('running', 'paused', 'completed') + LEFT JOIN workflows w ON w.id = r.workflow_id + LEFT JOIN email_accounts ea ON ea.id = rp.email_account_id + LEFT JOIN ( + SELECT er1.* FROM email_replies er1 + WHERE er1.received_at = ( + SELECT MAX(er2.received_at) FROM email_replies er2 WHERE er2.target_id = er1.target_id + ) + ) er ON er.target_id = t.id + WHERE 1=1 + ${channelFilter} + GROUP BY t.id + ORDER BY replied_at DESC + `).all() as Array; + + const replies: InboxReply[] = rows.map((row) => { + let reply_kind: string | null = null; + let reply_summary: string | null = null; + if (row.classification_json) { + try { + const cls = JSON.parse(row.classification_json) as { kind?: string; summary?: string }; + reply_kind = cls.kind ?? null; + reply_summary = cls.summary ?? null; + } catch { /* malformed — leave null */ } + } + const { classification_json: _omit, ...rest } = row; + void _omit; + return { ...rest, reply_kind, reply_summary }; + }); + + return res.json({ replies }); +} diff --git a/pages/api/inbox/reply.ts b/pages/api/inbox/reply.ts new file mode 100644 index 0000000..ca5546e --- /dev/null +++ b/pages/api/inbox/reply.ts @@ -0,0 +1,40 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { sendEmail } from "@/lib/email/sender"; +import { decryptSecret } from "@/lib/crypto"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const { emailAccountId, to, subject, body } = req.body as { + emailAccountId?: string; + to?: string; + subject?: string; + body?: string; + }; + + if (!emailAccountId || !to || !subject || !body) { + return res.status(400).json({ error: "emailAccountId, to, subject, and body are required" }); + } + + const db = getDb(); + const account = db.prepare( + "SELECT id, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, username, password FROM email_accounts WHERE id = ?" + ).get(emailAccountId) as { + id: string; from_email: string; from_name: string | null; reply_to: string | null; + smtp_host: string; smtp_port: number; smtp_secure: number; username: string; password: string; + } | undefined; + + if (!account) return res.status(404).json({ error: "Email account not found" }); + + try { + await sendEmail({ ...account, password: decryptSecret(account.password)! }, to, subject, body); + return res.json({ ok: true }); + } catch (err) { + console.error("[inbox/reply] send failed:", err); + return res.status(500).json({ error: err instanceof Error ? err.message : "Send failed" }); + } +} diff --git a/pages/api/inbox/thread.ts b/pages/api/inbox/thread.ts new file mode 100644 index 0000000..00bfac8 --- /dev/null +++ b/pages/api/inbox/thread.ts @@ -0,0 +1,143 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import Imap from "imap"; +import { simpleParser } from "mailparser"; +import { getDb } from "@/lib/db"; +import { decryptSecret } from "@/lib/crypto"; + +export interface EmailMessage { + uid: number; + from: string; + to: string; + subject: string; + date: string; + text: string; + html: string | null; + messageId: string | null; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const { targetId, emailAccountId } = req.query as { targetId?: string; emailAccountId?: string }; + if (!targetId || !emailAccountId) return res.status(400).json({ error: "targetId and emailAccountId required" }); + + const db = getDb(); + + const target = db.prepare("SELECT email FROM targets WHERE id = ?").get(targetId) as { email: string | null } | undefined; + if (!target?.email) return res.status(404).json({ error: "Target has no email" }); + + const account = db.prepare( + "SELECT imap_host, imap_port, username, password, imap_username, imap_password FROM email_accounts WHERE id = ?" + ).get(emailAccountId) as { + imap_host: string | null; imap_port: number | null; + username: string; password: string; + imap_username: string | null; imap_password: string | null; + } | undefined; + + if (!account?.imap_host) return res.status(404).json({ error: "Email account not found or missing IMAP config" }); + + const imapUser = account.imap_username ?? account.username; + const imapPass = decryptSecret(account.imap_password) ?? decryptSecret(account.password)!; + const targetEmail = target.email.toLowerCase(); + + try { + const messages = await fetchThread( + { host: account.imap_host, port: account.imap_port ?? 993, user: imapUser, password: imapPass }, + targetEmail + ); + return res.json({ messages }); + } catch (err) { + console.error("[inbox/thread] IMAP error:", err); + return res.status(500).json({ error: "IMAP fetch failed" }); + } +} + +interface ImapConfig { host: string; port: number; user: string; password: string; } + +async function fetchThread(cfg: ImapConfig, contactEmail: string): Promise { + return new Promise((resolve, reject) => { + const imap = new Imap({ + host: cfg.host, + port: cfg.port, + tls: true, + tlsOptions: { rejectUnauthorized: false }, + user: cfg.user, + password: cfg.password, + authTimeout: 10_000, + connTimeout: 12_000, + }); + + const done = (err?: Error) => { + try { imap.end(); } catch { /* ignore */ } + if (err) reject(err); + }; + + const messages: EmailMessage[] = []; + + imap.once("error", (err: Error) => done(err)); + imap.once("ready", () => { + imap.openBox("INBOX", true, (boxErr) => { + if (boxErr) { done(boxErr); return; } + + // Search for messages FROM or TO the contact + imap.search([["OR", ["FROM", contactEmail], ["TO", contactEmail]]], (searchErr, uids) => { + if (searchErr) { done(searchErr); return; } + if (uids.length === 0) { resolve([]); done(); return; } + + // Fetch the last 20 matching messages max + const toFetch = uids.slice(-20); + const fetch = imap.fetch(toFetch, { bodies: "", struct: false }); + const pending: Promise[] = []; + + fetch.on("message", (msg) => { + let uid = 0; + msg.on("attributes", (attrs) => { uid = attrs.uid; }); + + const p = new Promise((res2) => { + const chunks: Buffer[] = []; + let bodyEnded = false; + + msg.on("body", (stream) => { + stream.on("data", (c: Buffer) => chunks.push(c)); + stream.once("end", async () => { + bodyEnded = true; + try { + const parsed = await simpleParser(Buffer.concat(chunks)); + messages.push({ + uid, + from: parsed.from?.text ?? "", + to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map(a => a.text).join(", ") : parsed.to.text) : "", + subject: parsed.subject ?? "(no subject)", + date: parsed.date?.toISOString() ?? new Date().toISOString(), + text: parsed.text ?? "", + html: parsed.html || null, + messageId: parsed.messageId ?? null, + }); + } catch { /* skip malformed */ } + res2(); + }); + }); + + // Only use msg.end as fallback if no body was received + msg.once("end", () => { if (!bodyEnded) res2(); }); + }); + pending.push(p); + }); + + fetch.once("error", (fetchErr: Error) => done(fetchErr)); + fetch.once("end", async () => { + await Promise.allSettled(pending); + messages.sort((a, b) => a.date.localeCompare(b.date)); + resolve(messages); + done(); + }); + }); + }); + }); + + imap.connect(); + }); +} diff --git a/pages/api/integrations/index.ts b/pages/api/integrations/index.ts new file mode 100644 index 0000000..e2bc556 --- /dev/null +++ b/pages/api/integrations/index.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { encryptSecret, decryptSecret } from "@/lib/crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const rows = db.prepare("SELECT key, api_key, updated_at FROM integrations").all() as { + key: string; + api_key: string | null; + updated_at: string; + }[]; + const masked = rows.map((r) => { + const plain = decryptSecret(r.api_key); + return { + key: r.key, + updated_at: r.updated_at, + api_key_masked: plain ? "••••••••" + plain.slice(-4) : null, + configured: !!plain, + }; + }); + return res.json(masked); + } + + if (req.method === "POST") { + const { key, api_key } = req.body; + if (!key) return res.status(400).json({ error: "key required" }); + if (!api_key) return res.status(400).json({ error: "api_key required" }); + db.prepare(` + INSERT INTO integrations (key, api_key, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at + `).run(key, encryptSecret(api_key)); + return res.json({ ok: true }); + } + + if (req.method === "DELETE") { + const { key } = req.query; + if (!key) return res.status(400).json({ error: "key required" }); + db.prepare("DELETE FROM integrations WHERE key = ?").run(key); + return res.json({ ok: true }); + } + + res.setHeader("Allow", ["GET", "POST", "DELETE"]); + res.status(405).end(); +} diff --git a/pages/api/lists/[id]/add-members.ts b/pages/api/lists/[id]/add-members.ts new file mode 100644 index 0000000..073a901 --- /dev/null +++ b/pages/api/lists/[id]/add-members.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Add EXISTING contacts to a list by id (membership only — does not create +// contacts). Idempotent: already-member ids are skipped. This is the inverse of +// remove-members and the way to UNDO a removal (feed back removed_contact_ids). +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const list_id = req.query.id as string; + + const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(list_id); + if (!list) return res.status(404).json({ error: "List not found" }); + + const { contact_ids } = req.body as { contact_ids?: string[] }; + if (!Array.isArray(contact_ids) || contact_ids.length === 0) { + return res.status(400).json({ error: "contact_ids[] is required." }); + } + + // Only add ids that actually exist as contacts (ignore unknown ids rather + // than failing the whole batch). + const placeholders = contact_ids.map(() => "?").join(", "); + const existing = db + .prepare(`SELECT id, full_name, title FROM targets WHERE id IN (${placeholders})`) + .all(...contact_ids) as Array<{ id: string; full_name: string | null; title: string | null }>; + const existingIds = new Set(existing.map((r) => r.id)); + const unknown = contact_ids.filter((id) => !existingIds.has(id)); + + const insert = db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)"); + let added = 0; + const addTx = db.transaction((ids: string[]) => { + for (const id of ids) { + const info = insert.run(list_id, id); + if (info.changes > 0) added++; + } + }); + addTx([...existingIds]); + + const total = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c; + + return res.json({ + added, + already_members: existingIds.size - added, + unknown_contact_ids: unknown, + list_total: total, + contacts_added: existing, + }); +} diff --git a/pages/api/lists/[id]/analyze.ts b/pages/api/lists/[id]/analyze.ts new file mode 100644 index 0000000..7a522b2 --- /dev/null +++ b/pages/api/lists/[id]/analyze.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Title + location breakdown for a list — used to spot irrelevant contacts before cleaning. +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + + const db = getDb(); + const list_id = req.query.id as string; + + const list = db.prepare("SELECT id, name FROM lists WHERE id = ?").get(list_id); + if (!list) return res.status(404).json({ error: "List not found" }); + + const total = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c; + + const titles = db.prepare(` + SELECT t.title, COUNT(*) as count + FROM list_targets lt + JOIN targets t ON t.id = lt.target_id + WHERE lt.list_id = ? AND t.title IS NOT NULL + GROUP BY t.title + ORDER BY count DESC + `).all(list_id); + + const locations = db.prepare(` + SELECT t.location, COUNT(*) as count + FROM list_targets lt + JOIN targets t ON t.id = lt.target_id + WHERE lt.list_id = ? AND t.location IS NOT NULL + GROUP BY t.location + ORDER BY count DESC + `).all(list_id); + + return res.json({ list, total, titles, locations }); +} diff --git a/pages/api/lists/[id]/apollo-enrich.ts b/pages/api/lists/[id]/apollo-enrich.ts new file mode 100644 index 0000000..b101b67 --- /dev/null +++ b/pages/api/lists/[id]/apollo-enrich.ts @@ -0,0 +1,198 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { matchPerson } from "@/lib/apollo"; +import { randomUUID } from "crypto"; +import { decryptSecret } from "@/lib/crypto"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(listId); + if (!list) return res.status(404).json({ error: "List not found" }); + + const integration = db.prepare("SELECT api_key FROM integrations WHERE key = 'apollo'").get() as + | { api_key: string } + | undefined; + if (!integration?.api_key) { + return res.status(400).json({ error: "Apollo integration not configured" }); + } + const apiKey = decryptSecret(integration.api_key)!; + + // Optional: target_ids in body to enrich specific contacts only + const { target_ids } = req.body as { target_ids?: string[] }; + + let targets: { id: string; linkedin_url: string | null; sales_nav_url: string | null }[]; + if (target_ids && target_ids.length > 0) { + const placeholders = target_ids.map(() => "?").join(","); + targets = db.prepare(` + SELECT t.id, t.linkedin_url, t.sales_nav_url + FROM targets t + INNER JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ? AND t.id IN (${placeholders}) AND t.email IS NULL + AND (t.linkedin_url IS NOT NULL OR t.sales_nav_url IS NOT NULL) + `).all(listId, ...target_ids) as { id: string; linkedin_url: string | null; sales_nav_url: string | null }[]; + } else { + targets = db.prepare(` + SELECT t.id, t.linkedin_url, t.sales_nav_url + FROM targets t + INNER JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ? AND t.apollo_enriched_at IS NULL AND t.email IS NULL + AND (t.linkedin_url IS NOT NULL OR t.sales_nav_url IS NOT NULL) + `).all(listId) as { id: string; linkedin_url: string | null; sales_nav_url: string | null }[]; + } + + let enriched = 0; + let notFound = 0; + let skipped = 0; + + for (const target of targets) { + // Prefer real /in/ URL, fall back to sales_nav_url (Apollo accepts both) + const urlToUse = (target.linkedin_url && !target.linkedin_url.includes("/sales/lead/")) + ? target.linkedin_url + : target.sales_nav_url; + if (!urlToUse) { + skipped++; + continue; + } + + try { + const result = await matchPerson(urlToUse, apiKey); + + if (!result) { + notFound++; + // Mark as attempted so we don't retry endlessly + db.prepare("UPDATE targets SET apollo_enriched_at = datetime('now') WHERE id = ?").run(target.id); + continue; + } + + // Company association via domain + let companyId: string | null = null; + if (result.organization?.domain) { + const domain = result.organization.domain.replace(/^www\./, "").toLowerCase(); + const existing = db.prepare("SELECT id FROM companies WHERE domain = ?").get(domain) as + | { id: string } + | undefined; + const org = result.organization; + if (existing) { + companyId = existing.id; + // Update company with richer data if we have it + db.prepare(` + UPDATE companies SET + industry = COALESCE(industry, ?), + location = COALESCE(location, ?), + linkedin_url = COALESCE(linkedin_url, ?), + website = COALESCE(website, ?), + founded_year = COALESCE(founded_year, ?), + logo_url = COALESCE(logo_url, ?), + phone = COALESCE(phone, ?), + annual_revenue = COALESCE(annual_revenue, ?), + technology_names = COALESCE(technology_names, ?), + keywords = COALESCE(keywords, ?), + city = COALESCE(city, ?), + country = COALESCE(country, ?), + description = COALESCE(description, ?), + employee_count = COALESCE(employee_count, ?) + WHERE id = ? + `).run( + org.industry ?? null, + org.location ?? null, + org.linkedin_url ?? null, + org.website_url ?? null, + org.founded_year ?? null, + org.logo_url ?? null, + org.phone ?? null, + org.annual_revenue_printed ?? null, + org.technology_names ? JSON.stringify(org.technology_names) : null, + org.keywords ? JSON.stringify(org.keywords) : null, + org.city ?? null, + org.country ?? null, + org.short_description ?? null, + org.estimated_num_employees ?? null, + existing.id + ); + } else { + companyId = randomUUID(); + db.prepare(` + INSERT INTO companies (id, name, domain, industry, location, linkedin_url, website, founded_year, logo_url, phone, annual_revenue, technology_names, keywords, city, country, description, employee_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + companyId, + org.name ?? "", + domain, + org.industry ?? null, + org.location ?? null, + org.linkedin_url ?? null, + org.website_url ?? null, + org.founded_year ?? null, + org.logo_url ?? null, + org.phone ?? null, + org.annual_revenue_printed ?? null, + org.technology_names ? JSON.stringify(org.technology_names) : null, + org.keywords ? JSON.stringify(org.keywords) : null, + org.city ?? null, + org.country ?? null, + org.short_description ?? null, + org.estimated_num_employees ?? null + ); + } + } + + // Apollo returns a real /in/ URL — always write it, even if target already has a Sales Nav URL + const apolloLinkedinUrl = result.linkedin_url?.includes("/in/") ? result.linkedin_url : null; + + db.prepare(` + UPDATE targets SET + apollo_id = ?, + seniority = ?, + apollo_functions = ?, + apollo_departments = ?, + email = COALESCE(email, ?), + email_status = COALESCE(email_status, ?), + email_domain_catchall = ?, + city = COALESCE(city, ?), + country = COALESCE(country, ?), + time_zone = COALESCE(time_zone, ?), + headline = COALESCE(headline, ?), + positions_json = COALESCE(positions_json, ?), + company_id = COALESCE(company_id, ?), + linkedin_url = COALESCE(?, linkedin_url), + company_size = COALESCE(company_size, ?), + apollo_enriched_at = datetime('now') + WHERE id = ? + `).run( + result.apollo_id, + result.seniority ?? null, + result.functions ? JSON.stringify(result.functions) : null, + result.departments ? JSON.stringify(result.departments) : null, + result.email ?? null, + result.email_status ?? null, + result.email_domain_catchall ? 1 : 0, + result.city ?? null, + result.country ?? null, + result.time_zone ?? null, + result.headline ?? null, + result.positions_json ?? null, + companyId, + apolloLinkedinUrl, + result.organization?.estimated_num_employees ?? null, + target.id + ); + + enriched++; + } catch { + skipped++; + } + } + + return res.json({ enriched, notFound, skipped, total: targets.length }); +} + +export const config = { + api: { responseLimit: false }, +}; diff --git a/pages/api/lists/[id]/conflicts.ts b/pages/api/lists/[id]/conflicts.ts new file mode 100644 index 0000000..0de5d19 --- /dev/null +++ b/pages/api/lists/[id]/conflicts.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// GET /api/lists/[id]/conflicts +// Returns how many prospects in this list are already active in a running/paused workflow +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const total = (db.prepare( + "SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?" + ).get(listId) as { c: number }).c; + + const blocked = (db.prepare( + `SELECT COUNT(DISTINCT lt.target_id) as blocked + FROM list_targets lt + WHERE lt.list_id = ? + AND lt.target_id IN ( + SELECT rp.target_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.status IN ('running', 'paused') + AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped') + ) + )` + ).get(listId) as { blocked: number }).blocked; + + return res.json({ total, blocked }); +} diff --git a/pages/api/lists/[id]/csv-template.ts b/pages/api/lists/[id]/csv-template.ts new file mode 100644 index 0000000..1500379 --- /dev/null +++ b/pages/api/lists/[id]/csv-template.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { buildCsvTemplate } from "@/lib/csv-import"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const csv = buildCsvTemplate(); + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="linki-import-template.csv"`); + res.status(200).send(csv); +} diff --git a/pages/api/lists/[id]/enrich.ts b/pages/api/lists/[id]/enrich.ts new file mode 100644 index 0000000..e942e9d --- /dev/null +++ b/pages/api/lists/[id]/enrich.ts @@ -0,0 +1,51 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId); + if (!list) return res.status(404).json({ error: "List not found" }); + + const { account_id } = req.body; + if (!account_id) return res.status(400).json({ error: "account_id required" }); + + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as + | { cookies_json: string | null; is_authenticated: number } + | undefined; + if (!account) return res.status(400).json({ error: "Account not found" }); + if (!account.is_authenticated || !account.cookies_json) { + return res.status(400).json({ error: "Account not authenticated" }); + } + + const pending = db.prepare(` + SELECT COUNT(*) as c FROM targets t + JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ? AND t.sales_nav_url IS NOT NULL AND t.enriched_profile_at IS NULL + `).get(listId) as { c: number }; + + // Respond immediately — enrichment runs in background + res.json({ started: true, profiles: pending.c }); + + // Fire and forget — do not await + setImmediate(async () => { + try { + const { getSessionContext } = await import("@/lib/linkedin/session"); + const { enrichList } = await import("@/lib/linkedin/enrich"); + const ctx = await getSessionContext(account_id); + await enrichList(ctx, listId); + } catch (err) { + console.error("[enrich] background enrichment failed:", err instanceof Error ? err.message : err); + } + }); +} + +export const config = { + api: { responseLimit: false }, +}; diff --git a/pages/api/lists/[id]/import-csv.ts b/pages/api/lists/[id]/import-csv.ts new file mode 100644 index 0000000..d374c10 --- /dev/null +++ b/pages/api/lists/[id]/import-csv.ts @@ -0,0 +1,24 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { importCsv } from "@/lib/csv-import"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(listId) as { id: string } | undefined; + if (!list) return res.status(404).json({ error: "List not found" }); + + const { csv } = req.body as { csv?: string }; + if (!csv || typeof csv !== "string" || !csv.trim()) { + return res.status(400).json({ error: "csv content is required" }); + } + + const result = importCsv(db, listId, csv); + res.json(result); +} diff --git a/pages/api/lists/[id]/import-status.ts b/pages/api/lists/[id]/import-status.ts new file mode 100644 index 0000000..4163ec0 --- /dev/null +++ b/pages/api/lists/[id]/import-status.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getDailyImportCap } from "@/lib/import-jobs"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + const dailyCap = getDailyImportCap(db); + + // All batches for this list, newest first + const batches = db.prepare(` + SELECT id, status, phase, page, total_pages, count, total, imported, skipped, error, + scheduled_for, start_page, batch_index, started_at, finished_at + FROM list_imports + WHERE list_id = ? + ORDER BY batch_index ASC, started_at DESC + `).all(listId) as Array<{ + id: string; status: string; phase: string | null; page: number; total_pages: number; + count: number; total: number; imported: number; skipped: number; error: string | null; + scheduled_for: string | null; start_page: number; batch_index: number; + started_at: string; finished_at: string | null; + }>; + + if (batches.length === 0) return res.json({ status: "idle", dailyCap }); + + // The "current" batch = running > scheduled > most recent — drives the old single-job UI + const current = + batches.find((b) => b.status === "running") ?? + batches.find((b) => b.status === "scheduled") ?? + batches[batches.length - 1]; + + const total = batches.reduce((m, b) => Math.max(m, b.total || 0), 0); + const importedSoFar = batches.reduce((s, b) => s + (b.imported || 0), 0); + const remaining = total > 0 ? Math.max(0, total - importedSoFar) : 0; + + return res.json({ + ...current, + dailyCap, + batches, + // Plan summary: is this list being split across days? + plan: { total, importedSoFar, remaining, exceedsCap: total > dailyCap, dailyCap }, + }); +} diff --git a/pages/api/lists/[id]/import.ts b/pages/api/lists/[id]/import.ts new file mode 100644 index 0000000..022ced2 --- /dev/null +++ b/pages/api/lists/[id]/import.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { startImport, getDailyImportCap } from "@/lib/import-jobs"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId); + if (!list) return res.status(404).json({ error: "List not found" }); + + const { sales_nav_url, account_id, enrich } = req.body as { + sales_nav_url?: string; + account_id?: string; + enrich?: boolean; + }; + if (!sales_nav_url) return res.status(400).json({ error: "sales_nav_url required" }); + if (!account_id) return res.status(400).json({ error: "account_id required" }); + + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as + | { cookies_json: string | null; is_authenticated: number } + | undefined; + if (!account) return res.status(400).json({ error: "Account not found" }); + if (!account.is_authenticated || !account.cookies_json) { + return res.status(400).json({ error: "Account not authenticated. Please authenticate first." }); + } + + // Already an active import for this list? + const existing = db + .prepare("SELECT id FROM list_imports WHERE list_id = ? AND status IN ('running','scheduled')") + .get(listId); + if (existing) { + return res.status(409).json({ error: "An import is already queued or running for this list" }); + } + + // Save the Sales Nav URL on the list for reference + db.prepare("UPDATE lists SET sales_nav_url = ? WHERE id = ?").run(sales_nav_url, listId); + + // Queue the first batch — the runner scheduler picks it up (one import at a time) + const { importId } = startImport(db, { listId, accountId: account_id, salesNavUrl: sales_nav_url, enrich }); + + res.json({ started: true, importId, dailyCap: getDailyImportCap(db) }); +} diff --git a/pages/api/lists/[id]/index.ts b/pages/api/lists/[id]/index.ts new file mode 100644 index 0000000..e515cc5 --- /dev/null +++ b/pages/api/lists/[id]/index.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(id); + if (!list) return res.status(404).json({ error: "Not found" }); + const targets = db + .prepare( + `SELECT t.* FROM targets t + JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ? + ORDER BY t.created_at DESC` + ) + .all(id); + return res.json({ ...list, targets }); + } + + if (req.method === "PUT") { + const { name, description } = req.body; + db.prepare( + "UPDATE lists SET name = COALESCE(?, name), description = COALESCE(?, description) WHERE id = ?" + ).run(name, description, id); + return res.json(db.prepare("SELECT * FROM lists WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM runs WHERE list_id = ?").run(id); + db.prepare("DELETE FROM lists WHERE id = ?").run(id); + return res.status(204).end(); + } + + res.setHeader("Allow", ["GET", "PUT", "DELETE"]); + res.status(405).end(); +} diff --git a/pages/api/lists/[id]/move-targets.ts b/pages/api/lists/[id]/move-targets.ts new file mode 100644 index 0000000..a9d4b96 --- /dev/null +++ b/pages/api/lists/[id]/move-targets.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// POST /api/lists/[id]/move-targets +// body: { target_ids: string[], destination_list_id: string } +// Moves targets from source list to destination list (removes from source, adds to destination) +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const sourceListId = req.query.id as string; + const { target_ids, destination_list_id } = req.body as { + target_ids: string[]; + destination_list_id: string; + }; + + if (!Array.isArray(target_ids) || target_ids.length === 0) + return res.status(400).json({ error: "target_ids must be a non-empty array" }); + if (!destination_list_id) + return res.status(400).json({ error: "destination_list_id required" }); + if (destination_list_id === sourceListId) + return res.status(400).json({ error: "Source and destination list are the same" }); + + const dest = db.prepare("SELECT id FROM lists WHERE id = ?").get(destination_list_id); + if (!dest) return res.status(404).json({ error: "Destination list not found" }); + + const placeholders = target_ids.map(() => "?").join(","); + + db.transaction(() => { + db.prepare( + `DELETE FROM list_targets WHERE list_id = ? AND target_id IN (${placeholders})` + ).run(sourceListId, ...target_ids); + + for (const tid of target_ids) { + db.prepare( + `INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)` + ).run(destination_list_id, tid); + } + })(); + + return res.json({ moved: target_ids.length }); +} diff --git a/pages/api/lists/[id]/remove-members.ts b/pages/api/lists/[id]/remove-members.ts new file mode 100644 index 0000000..fd992c9 --- /dev/null +++ b/pages/api/lists/[id]/remove-members.ts @@ -0,0 +1,92 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Remove contacts from a list (membership only — never deletes the contact). Filters are OR'd: +// titles (exact), title_patterns (LIKE %p%), exclude_location_substrings (LIKE %l%). dry_run previews. +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const list_id = req.query.id as string; + + const list = db.prepare("SELECT id FROM lists WHERE id = ?").get(list_id); + if (!list) return res.status(404).json({ error: "List not found" }); + + const { contact_ids, titles, title_patterns, exclude_location_substrings, dry_run = true } = req.body as { + contact_ids?: string[]; + titles?: string[]; + title_patterns?: string[]; + exclude_location_substrings?: string[]; + dry_run?: boolean; + }; + + const conditions: string[] = []; + const params: unknown[] = [list_id]; + + // Precise, ID-scoped removal — safest path (no title collisions). When + // contact_ids is given it is OR'd in like any other filter, so a curated set + // (built manually or by an LLM pass) removes exactly those contacts. + if (contact_ids && contact_ids.length > 0) { + const placeholders = contact_ids.map(() => "?").join(", "); + conditions.push(`t.id IN (${placeholders})`); + params.push(...contact_ids); + } + if (titles && titles.length > 0) { + const placeholders = titles.map(() => "?").join(", "); + conditions.push(`t.title IN (${placeholders})`); + params.push(...titles); + } + if (title_patterns && title_patterns.length > 0) { + const patternClauses = title_patterns.map(() => "t.title LIKE ?").join(" OR "); + conditions.push(`(${patternClauses})`); + for (const p of title_patterns) params.push(`%${p}%`); + } + if (exclude_location_substrings && exclude_location_substrings.length > 0) { + const locClauses = exclude_location_substrings.map(() => "t.location LIKE ?").join(" OR "); + conditions.push(`(${locClauses})`); + for (const l of exclude_location_substrings) params.push(`%${l}%`); + } + + if (conditions.length === 0) { + return res.status(400).json({ error: "No filters provided — nothing to remove." }); + } + + const whereFilter = conditions.join(" OR "); + + const preview = db.prepare(` + SELECT t.id, t.full_name, t.title, t.location + FROM list_targets lt + JOIN targets t ON t.id = lt.target_id + WHERE lt.list_id = ? AND (${whereFilter}) + ORDER BY t.title + `).all(...params); + + if (dry_run) { + return res.json({ dry_run: true, would_remove: preview.length, contacts: preview }); + } + + db.prepare(` + DELETE FROM list_targets + WHERE list_id = ? + AND target_id IN ( + SELECT t.id FROM list_targets lt + JOIN targets t ON t.id = lt.target_id + WHERE lt.list_id = ? AND (${whereFilter}) + ) + `).run(list_id, list_id, ...params.slice(1)); + + const remaining = (db.prepare("SELECT COUNT(*) as c FROM list_targets WHERE list_id = ?").get(list_id) as { c: number }).c; + + // Surface the exact removed IDs so a removal is trivially reversible: + // feed removed_contact_ids straight into add_to_list to undo this batch. + const removedIds = (preview as Array<{ id: string }>).map((c) => c.id); + + return res.json({ + dry_run: false, + removed: preview.length, + remaining, + contacts_removed: preview, + removed_contact_ids: removedIds, + undo_hint: "To undo, call add_to_list with these removed_contact_ids.", + }); +} diff --git a/pages/api/lists/[id]/sync-status.ts b/pages/api/lists/[id]/sync-status.ts new file mode 100644 index 0000000..5cd61aa --- /dev/null +++ b/pages/api/lists/[id]/sync-status.ts @@ -0,0 +1,66 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// POST /api/lists/[id]/sync-status body: { account_id: number } +// Re-fetches the Sales Nav list and updates degree for non-connected targets. +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + + const list = db.prepare("SELECT * FROM lists WHERE id = ?").get(listId) as + | { sales_nav_url?: string } + | undefined; + if (!list) return res.status(404).json({ error: "List not found" }); + if (!list.sales_nav_url) return res.status(400).json({ error: "No Sales Navigator URL saved for this list — run an import first" }); + + const { account_id } = req.body as { account_id: string }; + if (!account_id) return res.status(400).json({ error: "account_id required" }); + + const account = db.prepare("SELECT * FROM accounts WHERE id = ?").get(account_id) as + | { cookies_json: string | null; is_authenticated: number } + | undefined; + if (!account?.is_authenticated) return res.status(400).json({ error: "Account not authenticated" }); + + const { getSessionContext } = await import("@/lib/linkedin/session"); + const { scrapeNavigatorList } = await import("@/lib/linkedin/scraper"); + + try { + const ctx = await getSessionContext(account_id); + const { profiles } = await scrapeNavigatorList(ctx, list.sales_nav_url, { maxPages: 300 }); + + const updateDegree = db.prepare("UPDATE targets SET degree = ? WHERE linkedin_url = ?"); + const markConnected = db.prepare( + `UPDATE targets SET degree = ?, connected_at = CASE + WHEN (degree IS NULL OR degree != 1) AND connected_at IS NULL THEN datetime('now') + ELSE connected_at + END + WHERE linkedin_url = ?` + ); + + let updated = 0; + db.transaction(() => { + for (const p of profiles) { + if (p.degree === 1) { + markConnected.run(p.degree, p.salesNavUrl); + } else { + updateDegree.run(p.degree, p.salesNavUrl); + } + updated++; + } + })(); + + return res.json({ updated, total: profiles.length }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ error: message }); + } +} + +export const config = { + api: { responseLimit: false }, +}; diff --git a/pages/api/lists/[id]/targets.ts b/pages/api/lists/[id]/targets.ts new file mode 100644 index 0000000..9cbe591 --- /dev/null +++ b/pages/api/lists/[id]/targets.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// DELETE /api/lists/[id]/targets body: { target_ids: number[] } +// Removes targets from the list (list_targets rows only, does not delete the target itself) +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "DELETE") { + res.setHeader("Allow", ["DELETE"]); + return res.status(405).end(); + } + + const db = getDb(); + const listId = req.query.id as string; + const { target_ids } = req.body as { target_ids: string[] }; + + if (!Array.isArray(target_ids) || target_ids.length === 0) { + return res.status(400).json({ error: "target_ids must be a non-empty array" }); + } + + const placeholders = target_ids.map(() => "?").join(","); + const result = db + .prepare(`DELETE FROM list_targets WHERE list_id = ? AND target_id IN (${placeholders})`) + .run(listId, ...target_ids); + + return res.json({ removed: result.changes }); +} diff --git a/pages/api/lists/index.ts b/pages/api/lists/index.ts new file mode 100644 index 0000000..2035cf0 --- /dev/null +++ b/pages/api/lists/index.ts @@ -0,0 +1,33 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const lists = db + .prepare( + `SELECT l.*, COUNT(lt.target_id) as target_count + FROM lists l + LEFT JOIN list_targets lt ON lt.list_id = l.id + GROUP BY l.id + ORDER BY l.created_at DESC` + ) + .all(); + return res.json(lists); + } + + if (req.method === "POST") { + const { name, description } = req.body; + if (!name) return res.status(400).json({ error: "name required" }); + const id = randomUUID(); + db + .prepare("INSERT INTO lists (id, name, description) VALUES (?, ?, ?)") + .run(id, name, description ?? null); + return res.status(201).json(db.prepare("SELECT * FROM lists WHERE id = ?").get(id)); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(); +} diff --git a/pages/api/premium-status.ts b/pages/api/premium-status.ts new file mode 100644 index 0000000..8980729 --- /dev/null +++ b/pages/api/premium-status.ts @@ -0,0 +1,9 @@ +// GET /api/premium-status — open-core endpoint the UI uses to decide whether to render +// premium features or an "Upgrade to Premium" affordance. Returns { hasPremium } which is +// true in the commercial build (ee/ present) and false in the public open-source build. +import type { NextApiRequest, NextApiResponse } from "next"; +import { hasPremium } from "@/lib/premium"; + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ hasPremium }); +} diff --git a/pages/api/runs/[id]/enroll.ts b/pages/api/runs/[id]/enroll.ts new file mode 100644 index 0000000..99d32df --- /dev/null +++ b/pages/api/runs/[id]/enroll.ts @@ -0,0 +1,124 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(); + } + + const db = getDb(); + const runId = req.query.id as string; + const { target_ids } = req.body as { target_ids?: string[] }; + + if (!Array.isArray(target_ids) || target_ids.length === 0) { + return res.status(400).json({ error: "target_ids required" }); + } + + const run = db + .prepare("SELECT id, workflow_id FROM runs WHERE id = ?") + .get(runId) as { id: string; workflow_id: string } | undefined; + if (!run) return res.status(404).json({ error: "run_not_found" }); + + // Tracks defined on this workflow + const workflowTracks = [...new Set( + (db.prepare("SELECT DISTINCT track FROM workflow_steps WHERE workflow_id = ?").all(run.workflow_id) as { track: string }[]).map((r) => r.track) + )]; + if (workflowTracks.length === 0) workflowTracks.push("linkedin"); + + // Existing email-account pool for this run (used as round-robin pool for new enrollments) + const emailAccountPool: string[] = (db + .prepare( + `SELECT DISTINCT email_account_id FROM run_profiles + WHERE run_id = ? AND email_account_id IS NOT NULL` + ) + .all(runId) as Array<{ email_account_id: string }>).map((r) => r.email_account_id); + + // Dedup: already enrolled in this workflow at all + const alreadyEnrolled = new Set( + (db + .prepare( + `SELECT DISTINCT rp.target_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ?` + ) + .all(run.workflow_id) as { target_id: string }[]).map((r) => r.target_id) + ); + + // Active elsewhere (in some other running/paused run with an in-progress track) + const activeElsewhere = new Set( + (db + .prepare( + `SELECT DISTINCT rp.target_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.status IN ('running', 'paused') + AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped') + )` + ) + .all() as { target_id: string }[]).map((r) => r.target_id) + ); + + let skipped_already_enrolled = 0; + let skipped_active_elsewhere = 0; + const eligible: string[] = []; + for (const tid of target_ids) { + if (alreadyEnrolled.has(tid)) { skipped_already_enrolled++; continue; } + if (activeElsewhere.has(tid)) { skipped_active_elsewhere++; continue; } + eligible.push(tid); + } + + if (eligible.length === 0) { + return res.json({ enrolled: 0, skipped_already_enrolled, skipped_active_elsewhere }); + } + + // Assign email accounts: company-grouped round-robin (same as run creation) + const emailAssignment = new Map(); + if (emailAccountPool.length > 0) { + const placeholders = eligible.map(() => "?").join(","); + const companyRows = db + .prepare(`SELECT id, company_id FROM targets WHERE id IN (${placeholders})`) + .all(...eligible) as { id: string; company_id: string | null }[]; + const companyAccountMap = new Map(); + let cursor = 0; + for (const row of companyRows) { + if (row.company_id) { + if (!companyAccountMap.has(row.company_id)) { + companyAccountMap.set(row.company_id, emailAccountPool[cursor % emailAccountPool.length]); + cursor++; + } + emailAssignment.set(row.id, companyAccountMap.get(row.company_id)!); + } else { + emailAssignment.set(row.id, emailAccountPool[cursor % emailAccountPool.length]); + cursor++; + } + } + } + + const insertProfile = db.prepare( + "INSERT INTO run_profiles (id, run_id, target_id, email_account_id) VALUES (?, ?, ?, ?)" + ); + const insertTrack = db.prepare( + "INSERT INTO run_profile_tracks (id, run_profile_id, track, state, current_step) VALUES (?, ?, ?, 'pending', 0)" + ); + const insertMany = db.transaction((ids: string[]) => { + for (const tid of ids) { + const assignedEmailAccountId = emailAssignment.get(tid) ?? null; + const rpId = randomUUID(); + insertProfile.run(rpId, runId, tid, assignedEmailAccountId); + for (const track of workflowTracks) { + if (track === "email" && !assignedEmailAccountId) continue; + insertTrack.run(randomUUID(), rpId, track); + } + } + }); + insertMany(eligible); + + return res.json({ + enrolled: eligible.length, + skipped_already_enrolled, + skipped_active_elsewhere, + }); +} diff --git a/pages/api/runs/[id]/index.ts b/pages/api/runs/[id]/index.ts new file mode 100644 index 0000000..cbdef3d --- /dev/null +++ b/pages/api/runs/[id]/index.ts @@ -0,0 +1,85 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const run = db + .prepare( + `SELECT r.*, + w.name as workflow_name, + l.name as list_name, + a.name as account_name + FROM runs r + LEFT JOIN workflows w ON w.id = r.workflow_id + LEFT JOIN lists l ON l.id = r.list_id + LEFT JOIN accounts a ON a.id = r.account_id + WHERE r.id = ?` + ) + .get(id); + if (!run) return res.status(404).json({ error: "not found" }); + + // Paging + optional target_id filter — a run can hold hundreds of profiles, so callers + // (incl. the MCP) can page or pull a single contact instead of the whole set. + const targetId = req.query.target_id as string | undefined; + const hasPaging = req.query.limit !== undefined || req.query.page !== undefined; + const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500); + const offset = (Number(req.query.page) || 0) * limit; + + const profileWhere = targetId ? "rp.run_id = ? AND rp.target_id = ?" : "rp.run_id = ?"; + const profileArgs: unknown[] = targetId ? [id, targetId] : [id]; + const profilesTotal = (db.prepare( + `SELECT COUNT(*) as c FROM run_profiles rp WHERE ${profileWhere}` + ).get(...profileArgs) as { c: number }).c; + + const pageClause = (targetId || hasPaging) ? " LIMIT ? OFFSET ?" : ""; + const pageArgs = (targetId || hasPaging) ? [limit, offset] : []; + + const profiles = db + .prepare( + `SELECT rp.id, rp.run_id, rp.target_id, rp.email_account_id, rp.created_at, + COALESCE(rt_li.state, 'pending') as state, + COALESCE(rt_li.current_step, 0) as current_step, + rt_li.next_step_at, rt_li.error_message, + rt_email.state as email_state, + rt_email.current_step as email_current_step, + t.full_name, t.linkedin_url, t.title, t.company + FROM run_profiles rp + LEFT JOIN targets t ON t.id = rp.target_id + LEFT JOIN run_profile_tracks rt_li ON rt_li.run_profile_id = rp.id AND rt_li.track = 'linkedin' + LEFT JOIN run_profile_tracks rt_email ON rt_email.run_profile_id = rp.id AND rt_email.track = 'email' + WHERE ${profileWhere} + ORDER BY rp.id${pageClause}` + ) + .all(...profileArgs, ...pageArgs); + + const logs = db + .prepare( + `SELECT lg.*, t.full_name as target_name + FROM logs lg + LEFT JOIN targets t ON t.id = lg.target_id + WHERE lg.run_id = ? + ORDER BY lg.created_at DESC + LIMIT 100` + ) + .all(id); + + return res.json({ ...run as object, profiles, profiles_total: profilesTotal, logs }); + } + + if (req.method === "PATCH") { + const { status } = req.body; + if (!status) return res.status(400).json({ error: "status required" }); + db.prepare("UPDATE runs SET status = ? WHERE id = ?").run(status, id); + return res.json({ ok: true }); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM runs WHERE id = ?").run(id); + return res.json({ ok: true }); + } + + res.status(405).end(); +} diff --git a/pages/api/runs/[id]/remove.ts b/pages/api/runs/[id]/remove.ts new file mode 100644 index 0000000..074a2c4 --- /dev/null +++ b/pages/api/runs/[id]/remove.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const runId = req.query.id as string; + const { target_ids } = req.body as { target_ids: string[] }; + + if (!target_ids?.length) return res.status(400).json({ error: "target_ids required" }); + + const placeholders = target_ids.map(() => "?").join(","); + const result = db + .prepare( + `DELETE FROM run_profiles + WHERE run_id = ? AND target_id IN (${placeholders})` + ) + .run(runId, ...target_ids); + + return res.json({ ok: true, removed: result.changes }); +} diff --git a/pages/api/runs/[id]/retry.ts b/pages/api/runs/[id]/retry.ts new file mode 100644 index 0000000..4e8bc37 --- /dev/null +++ b/pages/api/runs/[id]/retry.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const runId = req.query.id as string; + const { target_ids } = req.body as { target_ids: string[] }; + + if (!target_ids?.length) return res.status(400).json({ error: "target_ids required" }); + + const placeholders = target_ids.map(() => "?").join(","); + // Retry: reset all failed track-runs for these profiles back to in_progress + const rpRows = db.prepare( + `SELECT id FROM run_profiles WHERE run_id = ? AND target_id IN (${placeholders})` + ).all(runId, ...target_ids) as { id: string }[]; + + let retried = 0; + for (const rp of rpRows) { + const r = db.prepare( + `UPDATE run_profile_tracks SET state = 'in_progress', error_message = NULL, next_step_at = NULL + WHERE run_profile_id = ? AND state = 'failed'` + ).run(rp.id); + retried += r.changes; + } + + return res.json({ ok: true, retried }); +} diff --git a/pages/api/runs/[id]/start.ts b/pages/api/runs/[id]/start.ts new file mode 100644 index 0000000..6a9ea08 --- /dev/null +++ b/pages/api/runs/[id]/start.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { ensureGlobalRunnerStarted } from "@/lib/linkedin/runner"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const id = req.query.id as string; + + const run = db.prepare("SELECT status FROM runs WHERE id = ?").get(id) as { status: string } | undefined; + if (!run) return res.status(404).json({ error: "Run not found" }); + if (run.status === "running") return res.status(400).json({ error: "Run already running" }); + + db.prepare( + "UPDATE runs SET status = 'running', started_at = COALESCE(started_at, datetime('now')) WHERE id = ?" + ).run(id); + + ensureGlobalRunnerStarted(); + + return res.json({ ok: true }); +} diff --git a/pages/api/runs/[id]/unenroll.ts b/pages/api/runs/[id]/unenroll.ts new file mode 100644 index 0000000..55089b3 --- /dev/null +++ b/pages/api/runs/[id]/unenroll.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const runId = req.query.id as string; + const { target_id } = req.body as { target_id: string }; + + if (!target_id) return res.status(400).json({ error: "target_id required" }); + + // Unenroll: mark all track-runs for this profile as skipped + const rp = db.prepare( + "SELECT id FROM run_profiles WHERE run_id = ? AND target_id = ?" + ).get(runId, target_id) as { id: string } | undefined; + + if (!rp) return res.status(404).json({ error: "Profile not found" }); + + const result = db.prepare( + `UPDATE run_profile_tracks SET state = 'skipped', error_message = 'Manually unenrolled' + WHERE run_profile_id = ? AND state IN ('pending', 'in_progress')` + ).run(rp.id); + + if (result.changes === 0) { + return res.status(404).json({ error: "Profile not found or already completed/skipped" }); + } + + return res.json({ ok: true }); +} diff --git a/pages/api/runs/index.ts b/pages/api/runs/index.ts new file mode 100644 index 0000000..905f98f --- /dev/null +++ b/pages/api/runs/index.ts @@ -0,0 +1,160 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const runs = db + .prepare( + `SELECT r.*, + w.name as workflow_name, + l.name as list_name, + a.name as account_name, + COUNT(DISTINCT rp.id) as total_profiles, + COUNT(DISTINCT CASE WHEN NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt2 + WHERE rt2.run_profile_id = rp.id AND rt2.state NOT IN ('completed', 'failed', 'skipped') + ) AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt3 + WHERE rt3.run_profile_id = rp.id AND rt3.state = 'completed' + ) THEN rp.id END) as completed_profiles + FROM runs r + LEFT JOIN workflows w ON w.id = r.workflow_id + LEFT JOIN lists l ON l.id = r.list_id + LEFT JOIN accounts a ON a.id = r.account_id + LEFT JOIN run_profiles rp ON rp.run_id = r.id + GROUP BY r.id + ORDER BY r.created_at DESC` + ) + .all(); + return res.json(runs); + } + + if (req.method === "POST") { + const { workflow_id, list_id, account_id, email_account_id, email_account_ids, target_ids } = req.body; + if (!workflow_id || !list_id || !account_id) + return res.status(400).json({ error: "workflow_id, list_id, account_id required" }); + + // Normalise email account list — prefer the new array, fall back to legacy single-id + const emailAccountPool: string[] = Array.isArray(email_account_ids) && email_account_ids.length > 0 + ? email_account_ids + : (email_account_id ? [email_account_id] : []); + + // Check 1: only one active run per workflow + const activeRun = db.prepare( + "SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running', 'paused') LIMIT 1" + ).get(workflow_id) as { id: string } | undefined; + if (activeRun) { + return res.status(400).json({ + error: "workflow_already_active", + message: "This workflow is already running. Stop or pause it before enrolling a new list.", + }); + } + + const runId = randomUUID(); + // For backwards compat, store first email account on the run row (may be null for no-email campaigns) + db + .prepare("INSERT INTO runs (id, workflow_id, list_id, account_id, email_account_id) VALUES (?, ?, ?, ?, ?)") + .run(runId, workflow_id, list_id, account_id, emailAccountPool[0] ?? null); + + // Create run_profiles — either for selected targets or all targets in the list + const candidates: { target_id: string }[] = Array.isArray(target_ids) && target_ids.length > 0 + ? (target_ids as string[]).map((id) => ({ target_id: id })) + : db.prepare("SELECT target_id FROM list_targets WHERE list_id = ?").all(list_id) as { target_id: string }[]; + + // Exclude targets already enrolled in any run of this workflow + const alreadyEnrolled = new Set( + (db.prepare( + `SELECT DISTINCT rp.target_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ?` + ).all(workflow_id) as { target_id: string }[]).map((r) => r.target_id) + ); + + // Exclude targets currently active in any other running/paused workflow + const activeElsewhere = new Set( + (db.prepare( + `SELECT DISTINCT rp.target_id FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.status IN ('running', 'paused') + AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped') + )` + ).all() as { target_id: string }[]).map((r) => r.target_id) + ); + + const targets = candidates.filter((t) => !alreadyEnrolled.has(t.target_id) && !activeElsewhere.has(t.target_id)); + + if (targets.length === 0) { + // Clean up the run we just created since there's nothing to enroll + db.prepare("DELETE FROM runs WHERE id = ?").run(runId); + return res.status(400).json({ + error: "all_already_enrolled", + message: "All selected contacts are already enrolled in this workflow.", + }); + } + + // Assign email accounts: company-grouped round-robin + // All targets at the same company get the same sender; companies cycle through the pool + let emailAssignment: Map = new Map(); + if (emailAccountPool.length > 0) { + // Load company_id for each candidate target + const targetIds = targets.map(t => t.target_id); + const placeholders = targetIds.map(() => "?").join(","); + const companyRows = db.prepare( + `SELECT id, company_id FROM targets WHERE id IN (${placeholders})` + ).all(...targetIds) as { id: string; company_id: string | null }[]; + + const companyAccountMap = new Map(); // company_id → email_account_id + let poolCursor = 0; + + for (const row of companyRows) { + if (row.company_id) { + if (!companyAccountMap.has(row.company_id)) { + companyAccountMap.set(row.company_id, emailAccountPool[poolCursor % emailAccountPool.length]); + poolCursor++; + } + emailAssignment.set(row.id, companyAccountMap.get(row.company_id)!); + } else { + // No company — assign individually round-robin + emailAssignment.set(row.id, emailAccountPool[poolCursor % emailAccountPool.length]); + poolCursor++; + } + } + } + + // Determine which tracks this workflow has steps for + const workflowTracks = [...new Set( + (db.prepare("SELECT DISTINCT track FROM workflow_steps WHERE workflow_id = ?").all(workflow_id) as { track: string }[]).map(r => r.track) + )]; + // If no track column exists yet (old DB), default to linkedin-only + if (workflowTracks.length === 0) workflowTracks.push("linkedin"); + + const insertProfile = db.prepare( + "INSERT INTO run_profiles (id, run_id, target_id, email_account_id) VALUES (?, ?, ?, ?)" + ); + const insertTrack = db.prepare( + "INSERT INTO run_profile_tracks (id, run_profile_id, track, state, current_step) VALUES (?, ?, ?, 'pending', 0)" + ); + const insertMany = db.transaction((ts: { target_id: string }[]) => { + for (const t of ts) { + const assignedEmailAccountId = emailAssignment.get(t.target_id) ?? null; + const rpId = randomUUID(); + insertProfile.run(rpId, runId, t.target_id, assignedEmailAccountId); + for (const track of workflowTracks) { + // Skip email track if no email account is configured on this run + if (track === "email" && !assignedEmailAccountId) continue; + insertTrack.run(randomUUID(), rpId, track); + } + } + }); + insertMany(targets); + + return res.status(201).json({ id: runId }); + } + + res.status(405).end(); +} diff --git a/pages/api/settings/import-cap.ts b/pages/api/settings/import-cap.ts new file mode 100644 index 0000000..b3ef885 --- /dev/null +++ b/pages/api/settings/import-cap.ts @@ -0,0 +1,23 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getDailyImportCap, setDailyImportCap, DEFAULT_DAILY_CAP } from "@/lib/import-jobs"; + +/** GET → { cap }, PUT { cap } → set the global daily import cap. */ +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + return res.json({ cap: getDailyImportCap(db), default: DEFAULT_DAILY_CAP }); + } + + if (req.method === "PUT") { + const { cap } = req.body as { cap?: number }; + const n = Number(cap); + if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: "cap must be a positive number" }); + setDailyImportCap(db, n); + return res.json({ cap: getDailyImportCap(db) }); + } + + res.setHeader("Allow", ["GET", "PUT"]); + return res.status(405).end(); +} diff --git a/pages/api/system/update.ts b/pages/api/system/update.ts new file mode 100644 index 0000000..f677344 --- /dev/null +++ b/pages/api/system/update.ts @@ -0,0 +1,6 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getUpdateState } from "@/lib/update-check"; + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.json(getUpdateState()); +} diff --git a/pages/api/targets/[id].ts b/pages/api/targets/[id].ts new file mode 100644 index 0000000..a166925 --- /dev/null +++ b/pages/api/targets/[id].ts @@ -0,0 +1,65 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const target = db.prepare("SELECT * FROM targets WHERE id = ?").get(id); + if (!target) return res.status(404).json({ error: "Not found" }); + + const company = db.prepare("SELECT * FROM companies WHERE id = (SELECT company_id FROM targets WHERE id = ?)").get(id); + const lists = db.prepare(` + SELECT l.id, l.name FROM lists l + INNER JOIN list_targets lt ON lt.list_id = l.id + WHERE lt.target_id = ? + ORDER BY l.name COLLATE NOCASE + `).all(id); + + return res.json({ ...target as object, company: company ?? null, lists }); + } + + if (req.method === "PATCH") { + const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(id); + if (!target) return res.status(404).json({ error: "Not found" }); + + // Editable contact fields (CRM hygiene). Anything else is owned by enrichment/automation. + const EDITABLE = [ + "first_name", "last_name", "full_name", "title", "company", "location", + "email", "phone", "headline", "summary", "notes", + ] as const; + + const body = req.body as Record; + const fields: string[] = []; + const params: unknown[] = []; + for (const col of EDITABLE) { + if (body[col] !== undefined) { + const v = body[col]; + fields.push(`${col} = ?`); + params.push(typeof v === "string" ? (v.trim() || null) : v); + } + } + if (fields.length === 0) return res.status(400).json({ error: "No editable fields provided" }); + params.push(id); + db.prepare(`UPDATE targets SET ${fields.join(", ")} WHERE id = ?`).run(...params); + + return res.json(db.prepare("SELECT * FROM targets WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(id); + if (!target) return res.status(404).json({ error: "Not found" }); + // Some references (run_profiles, logs) have no ON DELETE CASCADE — clear them first so the + // FK constraint doesn't block the delete. run_profile_tracks cascade off run_profiles. + db.transaction(() => { + db.prepare("DELETE FROM run_profiles WHERE target_id = ?").run(id); + db.prepare("DELETE FROM logs WHERE target_id = ?").run(id); + db.prepare("DELETE FROM targets WHERE id = ?").run(id); + })(); + return res.json({ ok: true }); + } + + res.setHeader("Allow", ["GET", "PATCH", "DELETE"]); + return res.status(405).end(); +} diff --git a/pages/api/targets/[id]/emails.ts b/pages/api/targets/[id]/emails.ts new file mode 100644 index 0000000..9a9e644 --- /dev/null +++ b/pages/api/targets/[id]/emails.ts @@ -0,0 +1,193 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import Imap from "imap"; +import { simpleParser } from "mailparser"; +import { getDb } from "@/lib/db"; +import { sendEmail, type EmailAccount } from "@/lib/email/sender"; +import { decryptSecret } from "@/lib/crypto"; + +// Email conversation for one contact, account-resolved automatically so callers (incl. the MCP) +// never need to know the email_account_id. +// GET → the IMAP thread (sent + received) with this contact. +// POST → send an email to this contact as the Linki sending identity (subject optional → reply). +// +// Account resolution order: explicit email_account_id → the contact's most recent run_profile +// assignment → the single configured account if there's exactly one. + +type AccountRow = { + id: string; from_email: string; from_name: string | null; reply_to: string | null; + smtp_host: string; smtp_port: number; smtp_secure: number; + imap_host: string | null; imap_port: number | null; + username: string; password: string; + imap_username: string | null; imap_password: string | null; +}; + +function resolveAccount(db: ReturnType, targetId: string, explicitId?: string): AccountRow | null { + const cols = `id, from_email, from_name, reply_to, smtp_host, smtp_port, smtp_secure, + imap_host, imap_port, username, password, imap_username, imap_password`; + if (explicitId) { + return (db.prepare(`SELECT ${cols} FROM email_accounts WHERE id = ?`).get(explicitId) as AccountRow) ?? null; + } + const assigned = db.prepare(` + SELECT rp.email_account_id FROM run_profiles rp + WHERE rp.target_id = ? AND rp.email_account_id IS NOT NULL + ORDER BY rp.created_at DESC LIMIT 1 + `).get(targetId) as { email_account_id: string } | undefined; + if (assigned?.email_account_id) { + return (db.prepare(`SELECT ${cols} FROM email_accounts WHERE id = ?`).get(assigned.email_account_id) as AccountRow) ?? null; + } + const all = db.prepare(`SELECT ${cols} FROM email_accounts`).all() as AccountRow[]; + return all.length === 1 ? all[0] : null; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const targetId = req.query.id as string; + + const target = db.prepare("SELECT id, email FROM targets WHERE id = ?").get(targetId) as + | { id: string; email: string | null } | undefined; + if (!target) return res.status(404).json({ error: "Contact not found" }); + if (!target.email) return res.status(400).json({ error: "Contact has no email address" }); + + // ── Read thread ── + if (req.method === "GET") { + const account = resolveAccount(db, targetId, req.query.email_account_id as string | undefined); + if (!account?.imap_host) { + return res.status(400).json({ error: "No email account with IMAP config could be resolved for this contact" }); + } + const cfg = { + host: account.imap_host, + port: account.imap_port ?? 993, + user: account.imap_username ?? account.username, + password: decryptSecret(account.imap_password) ?? decryptSecret(account.password)!, + }; + // Transient IMAP failures (dropped connection, auth timeout) are common — retry once + // before surfacing a structured error rather than an opaque 500. + let lastErr: unknown = null; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const messages = await fetchThread(cfg, target.email.toLowerCase()); + return res.json({ email_account_id: account.id, contact_email: target.email, messages }); + } catch (err) { + lastErr = err; + console.warn(`[targets/emails] IMAP attempt ${attempt} failed:`, err instanceof Error ? err.message : err); + if (attempt < 2) await new Promise((r) => setTimeout(r, 800)); + } + } + return res.status(502).json({ + error: "IMAP fetch failed", + detail: lastErr instanceof Error ? lastErr.message : String(lastErr), + email_account_id: account.id, + mailbox: "INBOX", + contact_email: target.email, + hint: "Transient IMAP error after 1 retry. For reliable reply retrieval use get_replies, which reads stored replies and never hits IMAP live.", + }); + } + + // ── Send / reply ── + if (req.method === "POST") { + const { subject, body, email_account_id } = req.body as { + subject?: string; body?: string; email_account_id?: string; + }; + if (!body?.trim()) return res.status(400).json({ error: "body is required" }); + + const account = resolveAccount(db, targetId, email_account_id); + if (!account) return res.status(400).json({ error: "No email account could be resolved for this contact" }); + + // No subject → treat as a reply: reuse the last subject in the thread (Re: …) or a sane default. + let finalSubject = subject?.trim(); + if (!finalSubject) { + const lastReply = db.prepare( + "SELECT subject FROM email_replies WHERE target_id = ? AND subject IS NOT NULL ORDER BY received_at DESC LIMIT 1" + ).get(targetId) as { subject: string } | undefined; + finalSubject = lastReply?.subject + ? (/^re:/i.test(lastReply.subject) ? lastReply.subject : `Re: ${lastReply.subject}`) + : "Re:"; + } + + try { + await sendEmail( + { ...account, password: decryptSecret(account.password)! } as EmailAccount, + target.email, finalSubject, body + ); + return res.json({ ok: true, email_account_id: account.id, to: target.email, subject: finalSubject }); + } catch (err) { + console.error("[targets/emails] send failed:", err); + return res.status(500).json({ error: err instanceof Error ? err.message : "Send failed" }); + } + } + + res.setHeader("Allow", ["GET", "POST"]); + return res.status(405).end(); +} + +interface ImapConfig { host: string; port: number; user: string; password: string; } +interface EmailMessage { + uid: number; from: string; to: string; subject: string; date: string; + text: string; messageId: string | null; +} + +async function fetchThread(cfg: ImapConfig, contactEmail: string): Promise { + return new Promise((resolve, reject) => { + const imap = new Imap({ + host: cfg.host, port: cfg.port, tls: true, + tlsOptions: { rejectUnauthorized: false }, + user: cfg.user, password: cfg.password, + authTimeout: 10_000, connTimeout: 12_000, + }); + const done = (err?: Error) => { try { imap.end(); } catch { /* ignore */ } if (err) reject(err); }; + const messages: EmailMessage[] = []; + + imap.once("error", (err: Error) => done(err)); + imap.once("ready", () => { + imap.openBox("INBOX", true, (boxErr) => { + if (boxErr) { done(boxErr); return; } + imap.search([["OR", ["FROM", contactEmail], ["TO", contactEmail]]], (searchErr, uids) => { + if (searchErr) { done(searchErr); return; } + if (uids.length === 0) { resolve([]); done(); return; } + const toFetch = uids.slice(-20); + const fetch = imap.fetch(toFetch, { bodies: "", struct: false }); + const pending: Promise[] = []; + + fetch.on("message", (msg) => { + let uid = 0; + msg.on("attributes", (attrs) => { uid = attrs.uid; }); + const p = new Promise((res2) => { + const chunks: Buffer[] = []; + let bodyEnded = false; + msg.on("body", (stream) => { + stream.on("data", (c: Buffer) => chunks.push(c)); + stream.once("end", async () => { + bodyEnded = true; + try { + const parsed = await simpleParser(Buffer.concat(chunks)); + messages.push({ + uid, + from: parsed.from?.text ?? "", + to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map(a => a.text).join(", ") : parsed.to.text) : "", + subject: parsed.subject ?? "(no subject)", + date: parsed.date?.toISOString() ?? new Date().toISOString(), + text: parsed.text ?? "", + messageId: parsed.messageId ?? null, + }); + } catch { /* skip malformed */ } + res2(); + }); + }); + msg.once("end", () => { if (!bodyEnded) res2(); }); + }); + pending.push(p); + }); + + fetch.once("error", (fetchErr: Error) => done(fetchErr)); + fetch.once("end", async () => { + await Promise.allSettled(pending); + messages.sort((a, b) => a.date.localeCompare(b.date)); + resolve(messages); + done(); + }); + }); + }); + }); + imap.connect(); + }); +} diff --git a/pages/api/targets/[id]/profile-scrape.ts b/pages/api/targets/[id]/profile-scrape.ts new file mode 100644 index 0000000..c480a56 --- /dev/null +++ b/pages/api/targets/[id]/profile-scrape.ts @@ -0,0 +1,66 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { getSessionContext } from "@/lib/linkedin/session"; +import { scrapeProfile } from "@/lib/linkedin/profile-scrape"; +import { resolveLinkedInAccount } from "@/lib/linkedin/resolve-account"; + +// POST /api/targets/[id]/profile-scrape +// Live-scrapes a lead's LinkedIn profile (Sales Nav career + recent posts) and +// returns clean structured JSON. Read-only on LinkedIn's side; does not persist. +// Account auto-resolved (explicit account_id → contact's last run → sole account). +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); + + const db = getDb(); + const id = req.query.id as string; + const target = db.prepare( + "SELECT id, full_name, linkedin_url, sales_nav_url FROM targets WHERE id = ?" + ).get(id) as { id: string; full_name: string | null; linkedin_url: string | null; sales_nav_url: string | null } | undefined; + + if (!target) return res.status(404).json({ error: "Contact not found" }); + if (!target.sales_nav_url) { + return res.status(400).json({ error: "Contact has no Sales Navigator URL — re-import the list to capture it." }); + } + + const account = resolveLinkedInAccount(db, id, req.body?.account_id); + if (!account) return res.status(400).json({ error: "No authenticated LinkedIn account could be resolved." }); + + try { + const ctx = await getSessionContext(account.id); + const profile = await scrapeProfile(ctx, target); + + // Persist what we scraped so it isn't thrown away: posts + the career fields + // we already have columns for (headline/summary/positions). Cheap and lets the + // runner's AI writer reuse it without re-scraping. + db.prepare(` + UPDATE targets SET + posts_json = CASE WHEN ? IS NOT NULL THEN ? ELSE posts_json END, + posts_scraped_at = datetime('now'), + headline = COALESCE(?, headline), + summary = COALESCE(?, summary), + positions_json = CASE WHEN ? IS NOT NULL THEN ? ELSE positions_json END + WHERE id = ? + `).run( + profile.recent_posts.length ? "1" : null, + profile.recent_posts.length ? JSON.stringify(profile.recent_posts) : null, + profile.headline, + profile.summary, + profile.positions.length ? "1" : null, + profile.positions.length ? JSON.stringify(profile.positions) : null, + id + ); + + return res.json({ contact_id: id, account_id: account.id, profile }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // A dead session surfaces as "No data intercepted" / re-auth — flag it so the runner stops. + if (/re-authentication|No data intercepted|login|checkpoint/i.test(message)) { + try { + const { markNeedsReauth } = await import("@/lib/linkedin/session"); + await markNeedsReauth(account.id); + } catch { /* ignore */ } + return res.status(502).json({ error: `LinkedIn session needs re-authentication: ${message}` }); + } + return res.status(500).json({ error: message }); + } +} diff --git a/pages/api/targets/[id]/runs.ts b/pages/api/targets/[id]/runs.ts new file mode 100644 index 0000000..db315a1 --- /dev/null +++ b/pages/api/targets/[id]/runs.ts @@ -0,0 +1,68 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Which runs/campaigns a contact is enrolled in, with per-track (linkedin/email) state + step. +// Answers "is this person in a campaign, and where are they in it?" without scanning a whole run. +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + + const db = getDb(); + const targetId = req.query.id as string; + + const target = db.prepare("SELECT id FROM targets WHERE id = ?").get(targetId); + if (!target) return res.status(404).json({ error: "Not found" }); + + const rows = db.prepare(` + SELECT + rp.id AS run_profile_id, + r.id AS run_id, + r.status AS run_status, + r.workflow_id, + w.name AS workflow_name, + l.id AS list_id, + l.name AS list_name, + rp.email_account_id, + rt.track, + rt.state, + rt.current_step, + rt.next_step_at, + rt.error_message + FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + LEFT JOIN workflows w ON w.id = r.workflow_id + LEFT JOIN lists l ON l.id = r.list_id + LEFT JOIN run_profile_tracks rt ON rt.run_profile_id = rp.id + WHERE rp.target_id = ? + ORDER BY r.created_at DESC, rt.track + `).all(targetId) as Array>; + + // Group tracks under their run. + const byRun = new Map>(); + for (const row of rows) { + const rid = row.run_id as string; + if (!byRun.has(rid)) { + byRun.set(rid, { + run_id: rid, + run_profile_id: row.run_profile_id, + run_status: row.run_status, + workflow_id: row.workflow_id, + workflow_name: row.workflow_name, + list_id: row.list_id, + list_name: row.list_name, + email_account_id: row.email_account_id, + tracks: [], + }); + } + if (row.track) { + (byRun.get(rid)!.tracks as unknown[]).push({ + track: row.track, + state: row.state, + current_step: row.current_step, + next_step_at: row.next_step_at, + error_message: row.error_message, + }); + } + } + + return res.json({ runs: Array.from(byRun.values()) }); +} diff --git a/pages/api/targets/index.ts b/pages/api/targets/index.ts new file mode 100644 index 0000000..af82d65 --- /dev/null +++ b/pages/api/targets/index.ts @@ -0,0 +1,232 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; +import type { ActiveFilter, FilterOp } from "@/components/ui/FilterBar"; + +// Parse f[0][field], f[0][op], f[0][value], f[1][field], ... from query +function parseFilters(query: NextApiRequest["query"]): ActiveFilter[] { + const filters: ActiveFilter[] = []; + let i = 0; + while (query[`f[${i}][field]`]) { + const field = query[`f[${i}][field]`] as string; + const op = query[`f[${i}][op]`] as FilterOp; + const value = query[`f[${i}][value]`] as string | undefined; + filters.push({ id: String(i), field, op, value }); + i++; + } + return filters; +} + +function buildFilterClause(filters: ActiveFilter[]): { sql: string; params: unknown[] } { + const parts: string[] = []; + const params: unknown[] = []; + + for (const f of filters) { + // connection_status is derived — map to real columns + if (f.field === "connection_status") { + let expr: string; + switch (f.value) { + case "replied": + expr = "t.last_replied_at IS NOT NULL"; + break; + case "messaged": + expr = "t.message_sent_at IS NOT NULL AND t.last_replied_at IS NULL"; + break; + case "connected": + expr = "t.degree = 1 AND t.message_sent_at IS NULL"; + break; + case "request_sent": + expr = "t.connection_requested_at IS NOT NULL AND (t.degree IS NULL OR t.degree != 1)"; + break; + case "not_contacted": + default: + expr = "t.connection_requested_at IS NULL AND t.message_sent_at IS NULL"; + } + if (f.op === "is_not") expr = `NOT (${expr})`; + parts.push(expr); + continue; + } + + // Safe-list of allowed columns + const ALLOWED: Record = { + seniority: "t.seniority", + email_status: "t.email_status", + degree: "t.degree", + email: "t.email", + apollo_enriched_at: "t.apollo_enriched_at", + connection_requested_at: "t.connection_requested_at", + connected_at: "t.connected_at", + message_sent_at: "t.message_sent_at", + last_replied_at: "t.last_replied_at", + open_link: "t.open_link", + email_domain_catchall: "t.email_domain_catchall", + company_size: "t.company_size", + tenure_months: "t.tenure_months", + country: "t.country", + company_industry: "t.company_industry", + company: "t.company", + }; + + const col = ALLOWED[f.field]; + if (!col) continue; + + switch (f.op) { + case "is_set": + parts.push(`${col} IS NOT NULL AND ${col} != ''`); + break; + case "is_not_set": + parts.push(`(${col} IS NULL OR ${col} = '')`); + break; + case "is_true": + parts.push(`${col} = 1`); + break; + case "is_false": + parts.push(`(${col} = 0 OR ${col} IS NULL)`); + break; + case "is": + parts.push(`LOWER(${col}) = LOWER(?)`); + params.push(f.value ?? ""); + break; + case "is_not": + parts.push(`LOWER(${col}) != LOWER(?)`); + params.push(f.value ?? ""); + break; + case "contains": + parts.push(`${col} LIKE ?`); + params.push(`%${f.value ?? ""}%`); + break; + case "gt": + parts.push(`CAST(${col} AS REAL) > ?`); + params.push(Number(f.value ?? 0)); + break; + case "lt": + parts.push(`CAST(${col} AS REAL) < ?`); + params.push(Number(f.value ?? 0)); + break; + } + } + + return { + sql: parts.length > 0 ? " AND " + parts.join(" AND ") : "", + params, + }; +} + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + const db = getDb(); + const { full_name, linkedin_url, title, company, location, email, phone, list_id } = req.body; + if (!full_name || !linkedin_url) { + return res.status(400).json({ error: "full_name and linkedin_url are required" }); + } + const id = randomUUID(); + try { + db.prepare( + `INSERT INTO targets (id, full_name, linkedin_url, title, company, location, email, phone) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, full_name, linkedin_url, title ?? null, company ?? null, location ?? null, email ?? null, phone ?? null); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("UNIQUE")) { + return res.status(409).json({ error: "A contact with this LinkedIn URL already exists" }); + } + throw e; + } + if (list_id) { + try { + db.prepare("INSERT OR IGNORE INTO list_targets (list_id, target_id) VALUES (?, ?)").run(list_id, id); + } catch { /* ignore */ } + } + return res.status(201).json(db.prepare("SELECT * FROM targets WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + const db = getDb(); + const { target_ids } = req.body as { target_ids?: string[] }; + if (!Array.isArray(target_ids) || target_ids.length === 0) { + return res.status(400).json({ error: "target_ids must be a non-empty array" }); + } + const placeholders = target_ids.map(() => "?").join(","); + // run_profiles/logs have no ON DELETE CASCADE — clear them first so the FK + // constraint doesn't block the delete. run_profile_tracks cascade off run_profiles. + const result = db.transaction(() => { + db.prepare(`DELETE FROM run_profiles WHERE target_id IN (${placeholders})`).run(...target_ids); + db.prepare(`DELETE FROM logs WHERE target_id IN (${placeholders})`).run(...target_ids); + return db.prepare(`DELETE FROM targets WHERE id IN (${placeholders})`).run(...target_ids); + })(); + return res.json({ deleted: result.changes }); + } + + if (req.method !== "GET") { + res.setHeader("Allow", ["GET", "POST", "DELETE"]); + return res.status(405).end(); + } + + const db = getDb(); + const { list_id, page = "0", limit = "50", search } = req.query; + const offset = Number(page) * Number(limit); + + const extraClauses: string[] = []; + const extraParams: unknown[] = []; + + if (search && typeof search === "string" && search.trim()) { + const like = `%${search.trim()}%`; + extraClauses.push("(t.full_name LIKE ? OR t.company LIKE ? OR t.title LIKE ?)"); + extraParams.push(like, like, like); + } + + const filters = parseFilters(req.query); + const { sql: filterSql, params: filterParams } = buildFilterClause(filters); + + const extraWhere = + (extraClauses.length > 0 ? " AND " + extraClauses.join(" AND ") : "") + filterSql; + const allExtraParams = [...extraParams, ...filterParams]; + + const SELECT = `SELECT t.id, t.linkedin_url, t.full_name, t.title, t.company, t.location, + t.email, t.email_status, t.degree, + t.connection_requested_at, t.connected_at, t.message_sent_at, t.last_replied_at, + t.apollo_enriched_at, t.seniority, t.created_at + FROM targets t`; + + let rows: unknown[]; + let total: number; + + if (list_id) { + rows = db + .prepare( + `${SELECT} + JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ?${extraWhere} + ORDER BY t.full_name ASC + LIMIT ? OFFSET ?` + ) + .all(list_id, ...allExtraParams, Number(limit), offset); + total = ( + db + .prepare( + `SELECT COUNT(*) as c FROM targets t + JOIN list_targets lt ON lt.target_id = t.id + WHERE lt.list_id = ?${extraWhere}` + ) + .get(list_id, ...allExtraParams) as { c: number } + ).c; + } else { + // All contacts — including list-less ones (e.g. created via the MCP without a list_id). + // Previously gated behind EXISTS(list_targets), which hid them entirely. + const whereClause = extraWhere ? `WHERE 1=1${extraWhere}` : ""; + rows = db + .prepare( + `${SELECT} + ${whereClause} + ORDER BY t.full_name ASC + LIMIT ? OFFSET ?` + ) + .all(...allExtraParams, Number(limit), offset); + total = ( + db + .prepare(`SELECT COUNT(*) as c FROM targets t ${whereClause}`) + .get(...allExtraParams) as { c: number } + ).c; + } + + return res.json({ contacts: rows, total }); +} diff --git a/pages/api/templates/[id].ts b/pages/api/templates/[id].ts new file mode 100644 index 0000000..7f86580 --- /dev/null +++ b/pages/api/templates/[id].ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const t = db.prepare("SELECT * FROM templates WHERE id = ?").get(id); + if (!t) return res.status(404).json({ error: "Not found" }); + return res.json(t); + } + + if (req.method === "PUT") { + const { name, body } = req.body; + db.prepare( + "UPDATE templates SET name = COALESCE(?, name), body = COALESCE(?, body) WHERE id = ?" + ).run(name, body, id); + return res.json(db.prepare("SELECT * FROM templates WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM templates WHERE id = ?").run(id); + return res.status(204).end(); + } + + res.setHeader("Allow", ["GET", "PUT", "DELETE"]); + res.status(405).end(); +} diff --git a/pages/api/templates/index.ts b/pages/api/templates/index.ts new file mode 100644 index 0000000..3aebec7 --- /dev/null +++ b/pages/api/templates/index.ts @@ -0,0 +1,23 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const templates = db.prepare("SELECT * FROM templates ORDER BY created_at DESC").all(); + return res.json(templates); + } + + if (req.method === "POST") { + const { name, body } = req.body; + if (!name || !body) return res.status(400).json({ error: "name and body required" }); + const id = randomUUID(); + db.prepare("INSERT INTO templates (id, name, body) VALUES (?, ?, ?)").run(id, name, body); + return res.status(201).json(db.prepare("SELECT * FROM templates WHERE id = ?").get(id)); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(); +} diff --git a/pages/api/tour.ts b/pages/api/tour.ts new file mode 100644 index 0000000..6889e16 --- /dev/null +++ b/pages/api/tour.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// Per-page product tour "seen" flags, stored as app_settings rows (tour_seen_). +// Single-user tool — no per-account state needed, so a global key/value flag is enough. +const KEY_PREFIX = "tour_seen_"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const rows = db.prepare("SELECT key FROM app_settings WHERE key LIKE ?").all(`${KEY_PREFIX}%`) as { key: string }[]; + const seen = rows.map((r) => r.key.slice(KEY_PREFIX.length)); + return res.json({ seen }); + } + + if (req.method === "POST") { + const { page } = req.body as { page?: string }; + if (!page) return res.status(400).json({ error: "page is required" }); + db.prepare( + `INSERT INTO app_settings (key, value, updated_at) VALUES (?, '1', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = '1', updated_at = datetime('now')` + ).run(`${KEY_PREFIX}${page}`); + return res.json({ ok: true }); + } + + res.setHeader("Allow", ["GET", "POST"]); + return res.status(405).end(); +} diff --git a/pages/api/workflows/[id]/analytics.ts b/pages/api/workflows/[id]/analytics.ts new file mode 100644 index 0000000..f8df996 --- /dev/null +++ b/pages/api/workflows/[id]/analytics.ts @@ -0,0 +1,190 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).end(); + + try { + const db = getDb(); + const workflowId = req.query.id as string; + const days = Math.min(Math.max(Number(req.query.days) || 30, 7), 90); + + const RUNS = `SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running','paused','completed')`; + + // ── Audience: enrolled vs. addressable ────────────────────────────────────── + // The funnel's `total` counts everyone enrolled, but that overstates runway when most + // contacts have no deliverable email. `eligible` = enrolled AND has a non-bounced email. + // `verified` = the strict subset with a verified email. `email_real_replies` excludes + // auto-responders (OOO / substitute / call_task are automated) so the reply signal is honest. + const audience = db.prepare(` + WITH enrolled AS ( + SELECT DISTINCT rp.target_id + FROM run_profiles rp JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ? AND r.status IN ('running','paused','completed') + ) + SELECT + (SELECT COUNT(*) FROM enrolled) AS enrolled, + (SELECT COUNT(*) FROM enrolled e JOIN targets t ON t.id = e.target_id + WHERE t.email IS NOT NULL AND t.email != '' + AND (t.email_status IS NULL OR t.email_status NOT IN ('invalid','unavailable'))) AS eligible, + (SELECT COUNT(*) FROM enrolled e JOIN targets t ON t.id = e.target_id + WHERE t.email_status = 'verified') AS verified, + (SELECT COUNT(DISTINCT t.id) FROM enrolled e JOIN targets t ON t.id = e.target_id + WHERE t.reply_kind IN ('human_reply','not_interested')) AS email_real_replies, + (SELECT COUNT(DISTINCT t.id) FROM enrolled e JOIN targets t ON t.id = e.target_id + WHERE t.reply_kind IN ('ooo_followup','substitute','call_task')) AS email_auto_replies + `).get(workflowId) as { + enrolled: number; eligible: number; verified: number; + email_real_replies: number; email_auto_replies: number; + }; + + // Eligible contacts we've actually emailed at least once (Email sent log). + const contactedRow = db.prepare(` + SELECT COUNT(DISTINCT l.target_id) AS contacted + FROM logs l JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${RUNS}) AND l.message LIKE 'Email sent%' + AND t.email IS NOT NULL AND t.email != '' + AND (t.email_status IS NULL OR t.email_status NOT IN ('invalid','unavailable')) + `).get(workflowId) as { contacted: number }; + + const audienceOut = { + enrolled: audience.enrolled, + eligible: audience.eligible, // has a deliverable email — the real addressable count + verified: audience.verified, // strict subset with a verified email + contacted: contactedRow.contacted, // eligible contacts emailed at least once + replied: audience.email_real_replies, // genuine human replies (auto-responders excluded) + auto_replied: audience.email_auto_replies, // OOO / substitute / call_task (informational) + remaining: Math.max(audience.eligible - contactedRow.contacted, 0), // eligible not yet emailed + }; + + // ── Funnel ──────────────────────────────────────────────────────────────── + const funnel = db.prepare(` + SELECT + (SELECT COUNT(DISTINCT rp.target_id) + FROM run_profiles rp JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ? AND r.status IN ('running','paused','completed')) AS total, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Connection request sent%') AS connections_sent, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${RUNS}) + AND l.message LIKE 'Connection request sent%' + AND t.connected_at IS NOT NULL) AS connected, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Message sent%') AS messages_sent, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'InMail sent%') AS inmails_sent, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${RUNS}) + AND (l.message LIKE 'Message sent%' OR l.message LIKE 'InMail sent%') + AND t.last_replied_at IS NOT NULL) AS li_replies, + + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Email sent%') AS emails_sent, + + (SELECT COUNT(DISTINCT l.target_id) FROM logs l + JOIN targets t ON t.id = l.target_id + WHERE l.run_id IN (${RUNS}) + AND l.message LIKE 'Email sent%' + AND t.email_replied_at IS NOT NULL) AS email_replies, + + (SELECT COUNT(DISTINCT rp.target_id) + FROM run_profiles rp JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ? AND r.status IN ('running','paused','completed') + AND NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed', 'failed', 'skipped') + ) + AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state = 'completed' + )) AS completed + `).get( + workflowId, workflowId, workflowId, workflowId, + workflowId, workflowId, workflowId, workflowId, workflowId, + ) as { + total: number; connections_sent: number; connected: number; + messages_sent: number; inmails_sent: number; li_replies: number; + emails_sent: number; email_replies: number; completed: number; + }; + + // ── Daily activity ──────────────────────────────────────────────────────── + const activity = db.prepare(` + SELECT + date(l.created_at) AS day, + COUNT(CASE WHEN l.message LIKE 'Visited%' THEN 1 END) AS visits, + COUNT(CASE WHEN l.message LIKE 'Connection request sent%' THEN 1 END) AS connections, + COUNT(CASE WHEN l.message LIKE 'Message sent%' THEN 1 END) AS messages, + COUNT(CASE WHEN l.message LIKE 'InMail sent%' THEN 1 END) AS inmails, + COUNT(CASE WHEN l.message LIKE 'Email sent%' THEN 1 END) AS emails + FROM logs l + WHERE l.run_id IN (${RUNS}) + AND l.created_at >= datetime('now', '-${days} days') + GROUP BY date(l.created_at) + ORDER BY day ASC + `).all(workflowId) as { day: string; visits: number; connections: number; messages: number; inmails: number; emails: number }[]; + + const filled: typeof activity = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + const found = activity.find(r => r.day === key); + filled.push(found ?? { day: key, visits: 0, connections: 0, messages: 0, inmails: 0, emails: 0 }); + } + + // ── AI cost — daily time-series scoped to this workflow's runs ──────────── + const aiDaily = db.prepare(` + SELECT + date(a.created_at) AS day, + SUM(a.cost_usd) AS cost_usd, + SUM(a.input_tokens) AS input_tokens, + SUM(a.output_tokens) AS output_tokens + FROM agent_sessions a + WHERE a.run_id IN (${RUNS}) + AND a.created_at >= datetime('now', '-${days} days') + GROUP BY date(a.created_at) + ORDER BY day ASC + `).all(workflowId) as { day: string; cost_usd: number; input_tokens: number; output_tokens: number }[]; + + const aiDailyFilled: typeof aiDaily = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + const found = aiDaily.find(r => r.day === key); + aiDailyFilled.push(found ?? { day: key, cost_usd: 0, input_tokens: 0, output_tokens: 0 }); + } + + // ── AI cost — breakdown per step (step_type + step_order from workflow_steps) ─ + const aiByStep = db.prepare(` + SELECT + ws.step_order, + ws.step_type, + COUNT(a.id) AS call_count, + SUM(a.input_tokens) AS input_tokens, + SUM(a.output_tokens) AS output_tokens, + SUM(a.cost_usd) AS cost_usd, + GROUP_CONCAT(DISTINCT a.model) AS models + FROM agent_sessions a + JOIN workflow_steps ws ON ws.id = a.step_id + WHERE a.run_id IN (${RUNS}) + GROUP BY a.step_id + ORDER BY ws.step_order + `).all(workflowId) as { + step_order: number; step_type: string; call_count: number; + input_tokens: number; output_tokens: number; cost_usd: number; models: string; + }[]; + + res.json({ funnel, audience: audienceOut, activity: filled, aiDaily: aiDailyFilled, aiByStep }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to load analytics" }); + } +} diff --git a/pages/api/workflows/[id]/duplicate.ts b/pages/api/workflows/[id]/duplicate.ts new file mode 100644 index 0000000..625238c --- /dev/null +++ b/pages/api/workflows/[id]/duplicate.ts @@ -0,0 +1,61 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).end(); + + const db = getDb(); + const sourceId = req.query.id as string; + + const source = db.prepare("SELECT * FROM workflows WHERE id = ?").get(sourceId) as + | { id: string; name: string; description: string | null } + | undefined; + if (!source) return res.status(404).json({ error: "Workflow not found" }); + + const newId = randomUUID(); + db.prepare("INSERT INTO workflows (id, name, description) VALUES (?, ?, ?)").run( + newId, + `${source.name} (copy)`, + source.description ?? null + ); + + const steps = db + .prepare("SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_order") + .all(sourceId) as Array>; + + const getTemplateIds = db.prepare( + "SELECT template_id FROM workflow_step_templates WHERE step_id = ?" + ); + const insertStep = db.prepare( + `INSERT INTO workflow_steps + (id, workflow_id, step_order, step_type, template_id, delay_seconds, + connect_note, message_body, email_subject, email_body, + email_position, message_position, + ai_enabled, ai_model, ai_prompt, ai_max_words, ai_language) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + const insertLink = db.prepare( + "INSERT OR IGNORE INTO workflow_step_templates (step_id, template_id) VALUES (?, ?)" + ); + + for (const s of steps) { + const newStepId = randomUUID(); + insertStep.run( + newStepId, newId, s.step_order, s.step_type, + s.template_id ?? null, s.delay_seconds ?? 0, + s.connect_note ?? null, s.message_body ?? null, + s.email_subject ?? null, s.email_body ?? null, + s.email_position ?? 1, s.message_position ?? 1, + s.ai_enabled ?? 0, s.ai_model ?? null, + s.ai_prompt ?? null, s.ai_max_words ?? null, + s.ai_language ?? null + ); + const links = getTemplateIds.all(s.id) as Array<{ template_id: string }>; + for (const { template_id } of links) { + insertLink.run(newStepId, template_id); + } + } + + return res.status(201).json({ id: newId }); +} diff --git a/pages/api/workflows/[id]/enrollments.ts b/pages/api/workflows/[id]/enrollments.ts new file mode 100644 index 0000000..e2eaf51 --- /dev/null +++ b/pages/api/workflows/[id]/enrollments.ts @@ -0,0 +1,63 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +// GET /api/workflows/[id]/enrollments +// Returns current enrollment list + step groups for live polling +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + const db = getDb(); + const workflowId = req.query.id as string; + + const enrollments = db.prepare( + `SELECT r.id, r.list_id, r.status, r.created_at, + l.name as list_name, a.name as account_name, + COUNT(DISTINCT rp.id) as total_profiles, + COUNT(DISTINCT CASE WHEN NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt2 + WHERE rt2.run_profile_id = rp.id AND rt2.state NOT IN ('completed','failed','skipped') + ) AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt3 + WHERE rt3.run_profile_id = rp.id AND rt3.state = 'completed' + ) THEN rp.id END) as completed_profiles, + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM run_profile_tracks rt4 + WHERE rt4.run_profile_id = rp.id AND rt4.state = 'failed' + ) AND NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt5 + WHERE rt5.run_profile_id = rp.id AND rt5.state NOT IN ('completed','failed','skipped') + ) THEN rp.id END) as failed_profiles, + COUNT(DISTINCT CASE WHEN NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt6 + WHERE rt6.run_profile_id = rp.id AND rt6.state NOT IN ('skipped','completed','failed') + ) AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt7 + WHERE rt7.run_profile_id = rp.id AND rt7.state = 'skipped' + ) AND NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt8 + WHERE rt8.run_profile_id = rp.id AND rt8.state IN ('completed','failed') + ) THEN rp.id END) as skipped_profiles + FROM runs r + LEFT JOIN lists l ON l.id = r.list_id + LEFT JOIN accounts a ON a.id = r.account_id + LEFT JOIN run_profiles rp ON rp.run_id = r.id + WHERE r.workflow_id = ? GROUP BY r.id ORDER BY r.created_at DESC` + ).all(workflowId); + + // Step groups: use the linkedin track-run current_step for display (most representative) + const stepGroups = db.prepare( + `SELECT rt.current_step as step_order, ws.step_type, t.name as template_name, COUNT(*) as count + FROM run_profile_tracks rt + JOIN run_profiles rp ON rp.id = rt.run_profile_id + JOIN runs r ON r.id = rp.run_id + JOIN workflow_steps ws ON ws.workflow_id = r.workflow_id AND ws.track = rt.track AND ws.step_order = rt.current_step + 1 + LEFT JOIN templates t ON t.id = ws.template_id + WHERE r.workflow_id = ? AND rt.state NOT IN ('completed', 'failed', 'skipped') + GROUP BY rt.current_step, rt.track ORDER BY rt.current_step` + ).all(workflowId); + + return res.json({ enrollments, stepGroups }); +} diff --git a/pages/api/workflows/[id]/index.ts b/pages/api/workflows/[id]/index.ts new file mode 100644 index 0000000..87754aa --- /dev/null +++ b/pages/api/workflows/[id]/index.ts @@ -0,0 +1,54 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const id = req.query.id as string; + + if (req.method === "GET") { + const workflow = db.prepare("SELECT * FROM workflows WHERE id = ?").get(id); + if (!workflow) return res.status(404).json({ error: "not found" }); + const steps = db + .prepare( + `SELECT ws.*, t.name as template_name + FROM workflow_steps ws + LEFT JOIN templates t ON t.id = ws.template_id + WHERE ws.workflow_id = ? + ORDER BY ws.step_order` + ) + .all(id); + return res.json({ ...workflow as object, steps }); + } + + if (req.method === "PUT") { + const { name, description, prompt } = req.body; + // name/description: COALESCE so a rename-only request doesn't null them out + // prompt: always update when present in body (even "" to clear it) + if (prompt !== undefined) { + db.prepare( + "UPDATE workflows SET name = COALESCE(?, name), description = COALESCE(?, description), prompt = ? WHERE id = ?" + ).run(name ?? null, description ?? null, prompt || null, id); + } else { + db.prepare( + "UPDATE workflows SET name = COALESCE(?, name), description = COALESCE(?, description) WHERE id = ?" + ).run(name ?? null, description ?? null, id); + } + return res.json(db.prepare("SELECT * FROM workflows WHERE id = ?").get(id)); + } + + if (req.method === "PATCH") { + const { is_archived } = req.body; + if (is_archived !== undefined) { + db.prepare("UPDATE workflows SET is_archived = ? WHERE id = ?").run(is_archived ? 1 : 0, id); + } + return res.json(db.prepare("SELECT * FROM workflows WHERE id = ?").get(id)); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM runs WHERE workflow_id = ?").run(id); + db.prepare("DELETE FROM workflows WHERE id = ?").run(id); + return res.json({ ok: true }); + } + + res.status(405).end(); +} diff --git a/pages/api/workflows/[id]/prospects.ts b/pages/api/workflows/[id]/prospects.ts new file mode 100644 index 0000000..4414798 --- /dev/null +++ b/pages/api/workflows/[id]/prospects.ts @@ -0,0 +1,204 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import type { ActiveFilter, FilterOp } from "@/components/ui/FilterBar"; + +function parseFilters(query: NextApiRequest["query"]): ActiveFilter[] { + const filters: ActiveFilter[] = []; + let i = 0; + while (query[`f[${i}][field]`]) { + filters.push({ + id: String(i), + field: query[`f[${i}][field]`] as string, + op: query[`f[${i}][op]`] as FilterOp, + value: query[`f[${i}][value]`] as string | undefined, + }); + i++; + } + return filters; +} + +function buildFilterClause(filters: ActiveFilter[]): { sql: string; params: unknown[] } { + const parts: string[] = []; + const params: unknown[] = []; + + const ALLOWED: Record = { + degree: "t.degree", + connection_requested_at: "t.connection_requested_at", + connected_at: "t.connected_at", + message_sent_at: "t.message_sent_at", + company: "t.company", + title: "t.title", + seniority: "t.seniority", + country: "t.country", + }; + + for (const f of filters) { + const col = ALLOWED[f.field]; + if (!col) continue; + + switch (f.op) { + case "is_set": + parts.push(`${col} IS NOT NULL AND ${col} != ''`); + break; + case "is_not_set": + parts.push(`(${col} IS NULL OR ${col} = '')`); + break; + case "is": + parts.push(`LOWER(${col}) = LOWER(?)`); + params.push(f.value ?? ""); + break; + case "is_not": + parts.push(`LOWER(${col}) != LOWER(?)`); + params.push(f.value ?? ""); + break; + case "contains": + parts.push(`${col} LIKE ?`); + params.push(`%${f.value ?? ""}%`); + break; + case "gt": + parts.push(`CAST(${col} AS REAL) > ?`); + params.push(Number(f.value ?? 0)); + break; + case "lt": + parts.push(`CAST(${col} AS REAL) < ?`); + params.push(Number(f.value ?? 0)); + break; + } + } + + return { sql: parts.length > 0 ? " AND " + parts.join(" AND ") : "", params }; +} + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + try { + const db = getDb(); + const workflowId = req.query.id as string; + const stepFilter = req.query.step !== undefined ? Number(req.query.step) : null; + const trackFilter = (req.query.track as string | undefined) ?? "linkedin"; + const stateFilter = req.query.state as string | undefined; + const search = req.query.search as string | undefined; + const page = req.query.page ? Number(req.query.page) : 0; + const limit = 25; + const offset = page * limit; + + const conditions: string[] = ["r.workflow_id = ?", "r.status IN ('running', 'paused', 'completed')"]; + const params: unknown[] = [workflowId]; + + if (stepFilter !== null) { + const trackAlias = trackFilter === "email" ? "rt_em" : "rt_li"; + // Only show prospects actively at this step (exclude failed/skipped/completed at that step) + conditions.push(`COALESCE(${trackAlias}.current_step, 0) = ? AND ${trackAlias}.state NOT IN ('completed','skipped','failed')`); + params.push(stepFilter - 1); + } + if (stateFilter) { + const states = stateFilter.split(","); + const stateConditions = states.map((s) => { + if (s === "completed") { + // No active tracks, at least one completed — matches stats API definition + return `NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf WHERE rt_sf.run_profile_id = rp.id AND rt_sf.state IN ('in_progress','pending')) + AND EXISTS (SELECT 1 FROM run_profile_tracks rt_sf2 WHERE rt_sf2.run_profile_id = rp.id AND rt_sf2.state = 'completed')`; + } + if (s === "in_progress") { + return "EXISTS (SELECT 1 FROM run_profile_tracks rt_sf WHERE rt_sf.run_profile_id = rp.id AND rt_sf.state = 'in_progress')"; + } + if (s === "failed") { + // No active tracks, no completed tracks, has a failed track + return `NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf WHERE rt_sf.run_profile_id = rp.id AND rt_sf.state IN ('in_progress','pending')) + AND NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf2 WHERE rt_sf2.run_profile_id = rp.id AND rt_sf2.state = 'completed') + AND EXISTS (SELECT 1 FROM run_profile_tracks rt_sf3 WHERE rt_sf3.run_profile_id = rp.id AND rt_sf3.state = 'failed')`; + } + if (s === "skipped") { + // No active tracks, no completed, no failed, has a skipped track + return `NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf WHERE rt_sf.run_profile_id = rp.id AND rt_sf.state IN ('in_progress','pending')) + AND NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf2 WHERE rt_sf2.run_profile_id = rp.id AND rt_sf2.state = 'completed') + AND NOT EXISTS (SELECT 1 FROM run_profile_tracks rt_sf3 WHERE rt_sf3.run_profile_id = rp.id AND rt_sf3.state = 'failed') + AND EXISTS (SELECT 1 FROM run_profile_tracks rt_sf4 WHERE rt_sf4.run_profile_id = rp.id AND rt_sf4.state = 'skipped')`; + } + return "EXISTS (SELECT 1 FROM run_profile_tracks rt_sf WHERE rt_sf.run_profile_id = rp.id AND rt_sf.state = ?)"; + }); + const filteredStates = states.filter(s => !["completed","in_progress","failed","skipped"].includes(s)); + conditions.push(`(${stateConditions.join(" OR ")})`); + params.push(...filteredStates); + } + if (search) { + conditions.push("(t.full_name LIKE ? OR t.company LIKE ?)"); + params.push(`%${search}%`, `%${search}%`); + } + + // Extra filters from FilterBar + const activeFilters = parseFilters(req.query); + const { sql: filterSql, params: filterParams } = buildFilterClause(activeFilters); + if (filterSql) { + conditions.push(filterSql.replace(/^ AND /, "")); + params.push(...filterParams); + } + + const where = conditions.join(" AND "); + + const total = (db.prepare( + `SELECT COUNT(*) as c + FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + JOIN targets t ON t.id = rp.target_id + LEFT JOIN run_profile_tracks rt_li ON rt_li.run_profile_id = rp.id AND rt_li.track = 'linkedin' + LEFT JOIN run_profile_tracks rt_em ON rt_em.run_profile_id = rp.id AND rt_em.track = 'email' + WHERE ${where}` + ).get(...params) as { c: number }).c; + + params.push(limit, offset); + + const prospects = db.prepare( + `SELECT rp.id, rp.run_id, rp.target_id, + CASE + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'in_progress') THEN 'in_progress' + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'pending') THEN 'pending' + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'failed') THEN 'failed' + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'skipped') THEN 'skipped' + ELSE 'completed' + END as state, + COALESCE(rt_li.current_step, 0) as current_step, + COALESCE(rt_li.next_step_at, rt_em.next_step_at) as next_step_at, + COALESCE(rt_li.error_message, rt_em.error_message) as error_message, + t.full_name, t.title, t.company, t.linkedin_url, + t.degree, t.connection_requested_at, t.connected_at, t.message_sent_at, + ws_li.step_type as li_step_type, + ws_em.step_type as em_step_type, + CASE + WHEN rt_li.state NOT IN ('completed','skipped') THEN ws_li.step_type + ELSE ws_em.step_type + END as step_type, + CASE + WHEN rt_li.state NOT IN ('completed','skipped') THEN ws_li.track + ELSE ws_em.track + END as step_track + FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + JOIN targets t ON t.id = rp.target_id + LEFT JOIN run_profile_tracks rt_li ON rt_li.run_profile_id = rp.id AND rt_li.track = 'linkedin' + LEFT JOIN run_profile_tracks rt_em ON rt_em.run_profile_id = rp.id AND rt_em.track = 'email' + LEFT JOIN workflow_steps ws_li ON ws_li.workflow_id = r.workflow_id AND ws_li.track = 'linkedin' AND ws_li.step_order = COALESCE(rt_li.current_step, 0) + 1 + LEFT JOIN workflow_steps ws_em ON ws_em.workflow_id = r.workflow_id AND ws_em.track = 'email' AND ws_em.step_order = COALESCE(rt_em.current_step, 0) + 1 + WHERE ${where} + ORDER BY + CASE + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'in_progress') THEN 0 + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'pending') THEN 1 + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'failed') THEN 2 + WHEN EXISTS (SELECT 1 FROM run_profile_tracks rt_a WHERE rt_a.run_profile_id = rp.id AND rt_a.state = 'skipped') THEN 3 + ELSE 4 + END, + t.full_name + LIMIT ? OFFSET ?` + ).all(...params); + + return res.json({ prospects, total }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ error: message }); + } +} diff --git a/pages/api/workflows/[id]/stats.ts b/pages/api/workflows/[id]/stats.ts new file mode 100644 index 0000000..b7d065b --- /dev/null +++ b/pages/api/workflows/[id]/stats.ts @@ -0,0 +1,93 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(); + } + + try { + const db = getDb(); + const workflowId = req.query.id as string; + + const RUNS = `SELECT id FROM runs WHERE workflow_id = ? AND status IN ('running','paused','completed')`; + + const counts = db.prepare(` + SELECT + COUNT(DISTINCT rp.id) AS total_prospects, + COUNT(DISTINCT CASE WHEN EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state IN ('pending','in_progress') + ) THEN rp.id END) AS active_prospects, + COUNT(DISTINCT CASE WHEN NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('completed','failed','skipped') + ) AND EXISTS ( + SELECT 1 FROM run_profile_tracks rt2 + WHERE rt2.run_profile_id = rp.id AND rt2.state = 'completed' + ) THEN rp.id END) AS completed_prospects, + COUNT(DISTINCT CASE WHEN NOT EXISTS ( + SELECT 1 FROM run_profile_tracks rt + WHERE rt.run_profile_id = rp.id AND rt.state NOT IN ('failed','skipped') + ) THEN rp.id END) AS failed_prospects, + -- Log-based counts scoped to this workflow's runs only + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Connection request sent%') AS connections_sent, + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Connection request sent%' + AND target_id IN (SELECT id FROM targets WHERE connected_at IS NOT NULL)) AS connections_accepted, + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Message sent%') AS messages_sent, + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'InMail sent%') AS inmails_sent, + (SELECT COUNT(DISTINCT target_id) FROM logs + WHERE run_id IN (${RUNS}) AND message LIKE 'Email sent%') AS emails_sent + FROM run_profiles rp + JOIN runs r ON r.id = rp.run_id + WHERE r.workflow_id = ? AND r.status IN ('running','paused','completed') + `).get(workflowId, workflowId, workflowId, workflowId, workflowId, workflowId) as { + total_prospects: number; + active_prospects: number; + completed_prospects: number; + failed_prospects: number; + connections_sent: number; + connections_accepted: number; + messages_sent: number; + inmails_sent: number; + emails_sent: number; + }; + + const connections_sent = counts.connections_sent ?? 0; + const connections_accepted = counts.connections_accepted ?? 0; + const acceptance_rate = connections_sent > 0 + ? Math.round((connections_accepted / connections_sent) * 100) + : 0; + + const activeRun = db.prepare( + `SELECT r.id, r.status, r.list_id, l.name as list_name, a.name as account_name + FROM runs r + LEFT JOIN lists l ON l.id = r.list_id + LEFT JOIN accounts a ON a.id = r.account_id + WHERE r.workflow_id = ? AND r.status IN ('running', 'paused') + LIMIT 1` + ).get(workflowId) as { id: string; status: string; list_id: string; list_name: string; account_name: string } | undefined; + + return res.json({ + total_prospects: counts.total_prospects ?? 0, + active_prospects: counts.active_prospects ?? 0, + completed_prospects: counts.completed_prospects ?? 0, + failed_prospects: counts.failed_prospects ?? 0, + connections_sent, + connections_accepted, + acceptance_rate, + messages_sent: counts.messages_sent ?? 0, + inmails_sent: counts.inmails_sent ?? 0, + emails_sent: counts.emails_sent ?? 0, + active_run: activeRun ?? null, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ error: message }); + } +} diff --git a/pages/api/workflows/[id]/steps.ts b/pages/api/workflows/[id]/steps.ts new file mode 100644 index 0000000..728c17e --- /dev/null +++ b/pages/api/workflows/[id]/steps.ts @@ -0,0 +1,67 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const workflowId = req.query.id as string; + + if (req.method === "GET") { + const steps = db + .prepare( + `SELECT ws.*, t.name as template_name + FROM workflow_steps ws + LEFT JOIN templates t ON t.id = ws.template_id + WHERE ws.workflow_id = ? + ORDER BY ws.track, ws.step_order` + ) + .all(workflowId); + + // Attach multi-template ids to each step + const getTemplateIds = db.prepare( + `SELECT wst.template_id, t.name + FROM workflow_step_templates wst + JOIN templates t ON t.id = wst.template_id + WHERE wst.step_id = ?` + ); + const stepsWithTemplates = (steps as Array>).map((s) => ({ + ...s, + template_ids: (getTemplateIds.all(s.id) as Array<{ template_id: string; name: string }>).map((r) => r.template_id), + template_names: (getTemplateIds.all(s.id) as Array<{ template_id: string; name: string }>).map((r) => r.name), + })); + + return res.json(stepsWithTemplates); + } + + if (req.method === "POST") { + const { step_type, track: trackIn, template_id, template_ids, delay_seconds, connect_note, message_body, email_subject, email_body, email_signature, email_position, message_position, ai_enabled, ai_model, ai_prompt, ai_max_words, ai_language } = req.body; + if (!step_type) return res.status(400).json({ error: "step_type required" }); + + // Auto-assign track: email step_type always goes on the email track; everything else linkedin + const track: "linkedin" | "email" = trackIn === "email" || step_type === "email" ? "email" : "linkedin"; + + const maxRow = db + .prepare("SELECT MAX(step_order) as max_order FROM workflow_steps WHERE workflow_id = ? AND track = ?") + .get(workflowId, track) as { max_order: number | null }; + const nextOrder = (maxRow.max_order ?? 0) + 1; + + const id = randomUUID(); + db.prepare( + "INSERT INTO workflow_steps (id, workflow_id, step_order, track, step_type, template_id, delay_seconds, connect_note, message_body, email_subject, email_body, email_signature, email_position, message_position, ai_enabled, ai_model, ai_prompt, ai_max_words, ai_language) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).run(id, workflowId, nextOrder, track, step_type, template_id ?? null, delay_seconds ?? 0, connect_note ?? null, message_body ?? null, email_subject ?? null, email_body ?? null, email_signature !== undefined ? email_signature : null, email_position ?? 1, message_position ?? 1, ai_enabled ?? 0, ai_model ?? null, ai_prompt ?? null, ai_max_words ?? null, ai_language ?? null); + + // Insert multi-template associations + if (Array.isArray(template_ids) && template_ids.length > 0) { + const insertLink = db.prepare( + "INSERT OR IGNORE INTO workflow_step_templates (step_id, template_id) VALUES (?, ?)" + ); + for (const tid of template_ids) { + insertLink.run(id, tid); + } + } + + return res.status(201).json({ id }); + } + + res.status(405).end(); +} diff --git a/pages/api/workflows/[id]/steps/[stepId].ts b/pages/api/workflows/[id]/steps/[stepId].ts new file mode 100644 index 0000000..b5ddb1e --- /dev/null +++ b/pages/api/workflows/[id]/steps/[stepId].ts @@ -0,0 +1,31 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + const stepId = req.query.stepId as string; + + if (req.method === "PUT") { + const { step_type, template_id, delay_seconds, step_order, connect_note, message_body, email_subject, email_body } = req.body; + db.prepare( + `UPDATE workflow_steps SET + step_type = COALESCE(?, step_type), + template_id = COALESCE(?, template_id), + delay_seconds = COALESCE(?, delay_seconds), + step_order = COALESCE(?, step_order), + connect_note = ?, + message_body = ?, + email_subject = ?, + email_body = ? + WHERE id = ?` + ).run(step_type ?? null, template_id ?? null, delay_seconds ?? null, step_order ?? null, connect_note ?? null, message_body ?? null, email_subject ?? null, email_body ?? null, stepId); + return res.json({ ok: true }); + } + + if (req.method === "DELETE") { + db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(stepId); + return res.json({ ok: true }); + } + + res.status(405).end(); +} diff --git a/pages/api/workflows/index.ts b/pages/api/workflows/index.ts new file mode 100644 index 0000000..6f1f0aa --- /dev/null +++ b/pages/api/workflows/index.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getDb } from "@/lib/db"; +import { randomUUID } from "crypto"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const db = getDb(); + + if (req.method === "GET") { + const workflows = db + .prepare( + `SELECT w.*, COUNT(ws.id) as step_count + FROM workflows w + LEFT JOIN workflow_steps ws ON ws.workflow_id = w.id + GROUP BY w.id + ORDER BY w.created_at DESC` + ) + .all(); + return res.json(workflows); + } + + if (req.method === "POST") { + const { name, description, prompt } = req.body; + if (!name) return res.status(400).json({ error: "name required" }); + const id = randomUUID(); + db.prepare("INSERT INTO workflows (id, name, description, prompt) VALUES (?, ?, ?, ?)").run(id, name, description ?? null, prompt ?? null); + return res.status(201).json(db.prepare("SELECT * FROM workflows WHERE id = ?").get(id)); + } + + res.status(405).end(); +} diff --git a/pages/companies/[id].tsx b/pages/companies/[id].tsx new file mode 100644 index 0000000..cfb8342 --- /dev/null +++ b/pages/companies/[id].tsx @@ -0,0 +1,290 @@ +import Head from "next/head"; +import Link from "next/link"; +import { GetServerSideProps } from "next"; +import { getDb } from "@/lib/db"; +import { + RiArrowLeftLine, RiExternalLinkLine, RiGlobalLine, + RiMapPinLine, RiBuildingLine, RiLinkedinBoxLine, RiUserLine, + RiMailLine, RiPhoneLine, RiMoneyDollarCircleLine, RiCalendarLine, + RiGroupLine, RiCodeBoxLine, RiPriceTagLine, RiErrorWarningLine, +} from "react-icons/ri"; + +interface Contact { + id: string; + full_name: string | null; + title: string | null; + email: string | null; + email_status: string | null; + seniority: string | null; + linkedin_url: string | null; + degree: number | null; + connected_at: string | null; +} + +interface Company { + id: string; + name: string; + domain: string | null; + industry: string | null; + location: string | null; + city: string | null; + country: string | null; + linkedin_url: string | null; + website: string | null; + description: string | null; + employee_count: number | null; + founded_year: number | null; + annual_revenue: string | null; + phone: string | null; + technology_names: string | null; + keywords: string | null; + notes: string | null; + email_domain_invalid: number | null; + created_at: string; + contacts: Contact[]; +} + +export const getServerSideProps: GetServerSideProps = async ({ params }) => { + const db = getDb(); + const id = params?.id as string; + const company = db.prepare(` + SELECT id, name, domain, industry, location, city, country, linkedin_url, website, + description, employee_count, founded_year, annual_revenue, phone, + technology_names, keywords, notes, email_domain_invalid, created_at + FROM companies WHERE id = ? + `).get(id) as Company | undefined; + if (!company) return { notFound: true }; + const contacts = db.prepare(` + SELECT id, full_name, title, email, email_status, seniority, linkedin_url, degree, connected_at + FROM targets WHERE company_id = ? ORDER BY full_name COLLATE NOCASE + `).all(id) as Contact[]; + return { props: { company: { ...company, contacts } } }; +}; + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + if (!value) return null; + return ( +
+

{label}

+
{value}
+
+ ); +} + +export default function CompanyDetailPage({ company }: { company: Company }) { + return ( + <> + + {company.name} — Companies — Linki + + +
+ {/* Back */} +
+ + + + Companies +
+ + {/* Header */} +
+ + {/* Email domain invalid warning */} + {!!company.email_domain_invalid && ( +
+ + Email domain flagged invalid — bounce detected for a contact at this company. All contacts have been unenrolled from email steps. +
+ )} + + {/* Description */} + {company.description && ( +
+

About

+

{company.description}

+
+ )} + + {/* Details grid */} + {(company.employee_count || company.founded_year || company.annual_revenue || company.phone || + company.city || company.country || company.technology_names || company.keywords) && ( +
+

Details

+
+ {company.employee_count && ( +
+ + {company.employee_count.toLocaleString()} employees +
+ )} + {company.founded_year && ( +
+ + Founded {company.founded_year} +
+ )} + {company.annual_revenue && ( +
+ + {company.annual_revenue} +
+ )} + {company.phone && ( +
+ + {company.phone} +
+ )} + {(company.city || company.country) && ( +
+ + {[company.city, company.country].filter(Boolean).join(", ")} +
+ )} +
+ + {company.technology_names && (() => { + try { + const tech: string[] = JSON.parse(company.technology_names!); + if (!tech.length) return null; + return ( +
+
+ +

Tech stack

+
+
+ {tech.map(t => ( + {t} + ))} +
+
+ ); + } catch { return null; } + })()} + + {company.keywords && (() => { + try { + const kw: string[] = JSON.parse(company.keywords!); + if (!kw.length) return null; + return ( +
+
+ +

Keywords

+
+
+ {kw.map(k => ( + {k} + ))} +
+
+ ); + } catch { return null; } + })()} +
+ )} + + {/* Notes */} + {company.notes && ( +
+

Notes

+

{company.notes}

+
+ )} + + {/* Contacts */} +
+

+ Contacts ({company.contacts.length}) +

+ {company.contacts.length === 0 ? ( +

No contacts linked to this company.

+ ) : ( +
+ {company.contacts.map((c) => ( +
+
+
+ +
+
+ + {c.full_name ?? "—"} + +
+ {c.title && {c.title}} + {c.seniority && ( + {c.seniority} + )} +
+
+
+
+ {c.email && ( + + + + )} + {c.degree === 1 && ( + 1st + )} + {c.linkedin_url && ( + + + + )} +
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/pages/companies/index.tsx b/pages/companies/index.tsx new file mode 100644 index 0000000..d1db953 --- /dev/null +++ b/pages/companies/index.tsx @@ -0,0 +1,247 @@ +import Head from "next/head"; +import { useState } from "react"; +import { GetServerSideProps } from "next"; +import Link from "next/link"; +import { getDb } from "@/lib/db"; +import { toast } from "sonner"; +import { RiAddLine, RiDeleteBinLine, RiBuildingLine, RiGlobalLine } from "react-icons/ri"; + +interface Company { + id: string; + name: string; + domain: string | null; + industry: string | null; + location: string | null; + linkedin_url: string | null; + website: string | null; + notes: string | null; + contact_count: number; + created_at: string; +} + +const BLANK_FORM = { name: "", domain: "", industry: "", location: "", linkedin_url: "", website: "", notes: "" }; + +export const getServerSideProps: GetServerSideProps = async () => { + const db = getDb(); + const companies = db.prepare(` + SELECT c.*, COUNT(t.id) as contact_count + FROM companies c + LEFT JOIN targets t ON t.company_id = c.id + GROUP BY c.id + ORDER BY c.name COLLATE NOCASE + `).all(); + return { props: { initialCompanies: companies } }; +}; + +export default function CompaniesPage({ initialCompanies }: { initialCompanies: Company[] }) { + const [companies, setCompanies] = useState(initialCompanies); + const [showModal, setShowModal] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState(BLANK_FORM); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + + async function refresh() { + // full=1 + no paging → full company rows as a single list (page does its own search/filter) + const res = await fetch("/api/companies?full=1"); + const data = await res.json(); + setCompanies(data.companies ?? data); + } + + function openCreate() { + setEditId(null); + setForm(BLANK_FORM); + setShowModal(true); + } + + function openEdit(c: Company) { + setEditId(c.id); + setForm({ + name: c.name, + domain: c.domain ?? "", + industry: c.industry ?? "", + location: c.location ?? "", + linkedin_url: c.linkedin_url ?? "", + website: c.website ?? "", + notes: c.notes ?? "", + }); + setShowModal(true); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const body = { + name: form.name, + domain: form.domain || null, + industry: form.industry || null, + location: form.location || null, + linkedin_url: form.linkedin_url || null, + website: form.website || null, + notes: form.notes || null, + }; + const res = editId + ? await fetch(`/api/companies/${editId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) + : await fetch("/api/companies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + setLoading(false); + if (!res.ok) { toast.error((await res.json()).error ?? "Failed"); return; } + toast.success(editId ? "Company updated" : "Company created"); + setShowModal(false); + refresh(); + } + + async function deleteCompany(id: string) { + if (!confirm("Delete this company? Contacts will be unlinked but not deleted.")) return; + await fetch(`/api/companies/${id}`, { method: "DELETE" }); + toast.success("Deleted"); + setCompanies((prev) => prev.filter((c) => c.id !== id)); + } + + const filtered = companies.filter((c) => + !search || c.name.toLowerCase().includes(search.toLowerCase()) || (c.domain ?? "").toLowerCase().includes(search.toLowerCase()) + ); + + return ( + <> + + Companies — Linki + + +
+
+
+

Companies

+

Organisations associated with your contacts

+
+ +
+ +
+ setSearch(e.target.value)} + /> +
+ + {filtered.length === 0 ? ( +
+ {search ? "No companies match your search." : "No companies yet."} +
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((c) => ( + + + + + + + + + ))} + +
NameDomainIndustryLocationContacts
+
+ + + + + {c.name} + +
+
+ {c.domain ? ( + + {c.domain} + + ) : —} + {c.industry ?? —}{c.location ?? —}{c.contact_count} +
+ + +
+
+
+ )} + + {showModal && ( +
+
+

{editId ? "Edit Company" : "Add Company"}

+
+
+ + setForm({ ...form, name: e.target.value })} required /> +
+
+
+ + setForm({ ...form, domain: e.target.value })} /> +
+
+ + setForm({ ...form, industry: e.target.value })} /> +
+
+
+
+ + setForm({ ...form, location: e.target.value })} /> +
+
+ + setForm({ ...form, website: e.target.value })} /> +
+
+
+ + setForm({ ...form, linkedin_url: e.target.value })} /> +
+
+ +