diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0eb4c73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.gitignore +*.md +src/__tests__ +vitest.config.ts +.env* +.credentials +memory +dist diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9dff451..22f0f68 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -13,6 +13,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + env: + NODE_ENV: test + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -31,6 +44,7 @@ jobs: with: context: . push: true + no-cache: true tags: | git.cloonar.com/openclawd/docfast:latest git.cloonar.com/openclawd/docfast:${{ github.sha }} diff --git a/.forgejo/workflows/promote.yml b/.forgejo/workflows/promote.yml index 2fae872..f7b861c 100644 --- a/.forgejo/workflows/promote.yml +++ b/.forgejo/workflows/promote.yml @@ -11,18 +11,24 @@ jobs: runs-on: ubuntu-latest steps: + - name: Checkout code at tag + uses: actions/checkout@v4 + - name: Install kubectl run: | curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" chmod +x kubectl - - name: Get image from tag + - name: Get image info id: image run: | - # Tag format: v0.2.1 or v0.2.1-rc1 - # The staging pipeline already pushed the image with the commit SHA - # We retag with the version tag for traceability + # Use the commit SHA instead of "latest" to avoid a race condition: + # The tag event can fire before the staging build (deploy.yml) finishes + # pushing the new "latest" image. By referencing the exact SHA that + # deploy.yml tags images with (${{ github.sha }}), we ensure we + # promote the correct build — and wait for it if it's still running. echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Login to Forgejo Registry uses: docker/login-action@v3 @@ -31,13 +37,28 @@ jobs: username: openclawd password: ${{ secrets.REGISTRY_TOKEN }} - - name: Retag image for production + - name: Wait for staging image and retag for production run: | - # Pull latest staging image and tag with version - docker pull --platform linux/arm64 git.cloonar.com/openclawd/docfast:latest - docker tag git.cloonar.com/openclawd/docfast:latest \ - git.cloonar.com/openclawd/docfast:${{ steps.image.outputs.tag }} - docker push git.cloonar.com/openclawd/docfast:${{ steps.image.outputs.tag }} + SHA_IMAGE="git.cloonar.com/openclawd/docfast:${{ steps.image.outputs.sha }}" + PROD_IMAGE="git.cloonar.com/openclawd/docfast:${{ steps.image.outputs.tag }}" + + # Wait for the SHA-tagged image (built by staging) to be available + for i in $(seq 1 20); do + echo "Attempt $i/20: pulling $SHA_IMAGE ..." + if docker pull --platform linux/arm64 "$SHA_IMAGE" 2>/dev/null; then + echo "✅ Image found!" + break + fi + if [ "$i" -eq 20 ]; then + echo "❌ Image not available after 10 minutes. Aborting." + exit 1 + fi + echo "Image not ready yet, waiting 30s..." + sleep 30 + done + + docker tag "$SHA_IMAGE" "$PROD_IMAGE" + docker push "$PROD_IMAGE" - name: Deploy to Production run: | diff --git a/BACKUP_PROCEDURES.md b/BACKUP_PROCEDURES.md deleted file mode 100644 index 52106ca..0000000 --- a/BACKUP_PROCEDURES.md +++ /dev/null @@ -1,184 +0,0 @@ -# DocFast Backup & Disaster Recovery Procedures - -## Overview -DocFast now uses BorgBackup for full disaster recovery backups. The system backs up all critical components needed to restore the service on a new server. - -## What is Backed Up -- **PostgreSQL database** - Full database dump with schema and data -- **Docker volumes** - Application data and files -- **Nginx configuration** - Web server configuration -- **SSL certificates** - Let's Encrypt certificates and keys -- **Crontabs** - Scheduled tasks -- **OpenDKIM keys** - Email authentication keys -- **DocFast application files** - docker-compose.yml, .env, scripts -- **System information** - Installed packages, enabled services, disk usage - -## Backup Location & Schedule - -### Current Setup (Local) -- **Location**: `/opt/borg-backups/docfast` -- **Schedule**: Daily at 03:00 UTC -- **Retention**: 7 daily + 4 weekly + 3 monthly backups -- **Compression**: LZ4 (fast compression/decompression) -- **Encryption**: repokey mode (encrypted with passphrase) - -### Security -- **Passphrase**: `docfast-backup-YYYY` (where YYYY is current year) -- **Key backup**: Stored in `/opt/borg-backups/docfast-key-backup.txt` -- **⚠️ IMPORTANT**: Both passphrase AND key are required for restore! - -## Scripts - -### Backup Script: `/opt/docfast-borg-backup.sh` -- Automated backup creation -- Runs via cron daily at 03:00 UTC -- Logs to `/var/log/docfast-backup.log` -- Auto-prunes old backups - -### Restore Script: `/opt/docfast-borg-restore.sh` -- List available backups: `./docfast-borg-restore.sh list` -- Restore specific backup: `./docfast-borg-restore.sh restore docfast-YYYY-MM-DD_HHMM` -- Restore latest backup: `./docfast-borg-restore.sh restore latest` - -## Manual Backup Commands - -```bash -# Run backup manually -/opt/docfast-borg-backup.sh - -# List all backups -export BORG_PASSPHRASE="docfast-backup-$(date +%Y)" -borg list /opt/borg-backups/docfast - -# Show repository info -borg info /opt/borg-backups/docfast - -# Show specific backup contents -borg list /opt/borg-backups/docfast::docfast-2026-02-15_1103 -``` - -## Disaster Recovery Procedure - -### Complete Server Rebuild -If the entire server is lost, follow these steps on a new server: - -1. **Install dependencies**: - ```bash - apt update && apt install -y docker.io docker-compose postgresql-16 nginx borgbackup - systemctl enable postgresql docker - ``` - -2. **Copy backup data**: - - Transfer `/opt/borg-backups/` directory to new server - - Transfer `/opt/borg-backups/docfast-key-backup.txt` - -3. **Import Borg key**: - ```bash - export BORG_PASSPHRASE="docfast-backup-2026" - borg key import /opt/borg-backups/docfast /opt/borg-backups/docfast-key-backup.txt - ``` - -4. **Restore latest backup**: - ```bash - /opt/docfast-borg-restore.sh restore latest - ``` - -5. **Follow manual restore steps** (shown by restore script): - - Stop services - - Restore database - - Restore configuration files - - Set permissions - - Start services - -### Database-Only Recovery -If only the database needs restoration: - -```bash -# Stop DocFast -cd /opt/docfast && docker-compose down - -# Restore database -export BORG_PASSPHRASE="docfast-backup-$(date +%Y)" -cd /tmp -borg extract /opt/borg-backups/docfast::docfast-YYYY-MM-DD_HHMM -sudo -u postgres dropdb docfast -sudo -u postgres createdb -O docfast docfast -export PGPASSFILE="/root/.pgpass" -pg_restore -d docfast /tmp/tmp/docfast-backup-*/docfast-db.dump - -# Restart DocFast -cd /opt/docfast && docker-compose up -d -``` - -## Migration to Off-Site Storage - -### Option 1: Hetzner Storage Box (Recommended) -Manual setup required (Hetzner Storage Box API not available): - -1. **Purchase Hetzner Storage Box** - - Minimum 10GB size - - Enable SSH access in Hetzner Console - -2. **Configure SSH access**: - ```bash - # Generate SSH key for storage box - ssh-keygen -t ed25519 -f /root/.ssh/hetzner-storage-box - - # Add public key to storage box in Hetzner Console - cat /root/.ssh/hetzner-storage-box.pub - ``` - -3. **Update backup script**: - Change `BORG_REPO` in `/opt/docfast-borg-backup.sh`: - ```bash - BORG_REPO="ssh://uXXXXXX@uXXXXXX.your-storagebox.de:23/./docfast-backups" - ``` - -4. **Initialize remote repository**: - ```bash - export BORG_PASSPHRASE="docfast-backup-$(date +%Y)" - borg init --encryption=repokey ssh://uXXXXXX@uXXXXXX.your-storagebox.de:23/./docfast-backups - ``` - -### Option 2: AWS S3/Glacier -Use rclone + borg for S3 storage (requires investor approval for AWS costs). - -## Monitoring & Maintenance - -### Check Backup Status -```bash -# View recent backup logs -tail -f /var/log/docfast-backup.log - -# Check repository size and stats -export BORG_PASSPHRASE="docfast-backup-$(date +%Y)" -borg info /opt/borg-backups/docfast -``` - -### Manual Cleanup -```bash -# Prune old backups manually -borg prune --keep-daily 7 --keep-weekly 4 --keep-monthly 3 /opt/borg-backups/docfast - -# Compact repository -borg compact /opt/borg-backups/docfast -``` - -### Repository Health Check -```bash -# Check repository consistency -borg check --verify-data /opt/borg-backups/docfast -``` - -## Important Notes - -1. **Test restores regularly** - Run restore test monthly -2. **Monitor backup logs** - Check for failures in `/var/log/docfast-backup.log` -3. **Keep key safe** - Store `/opt/borg-backups/docfast-key-backup.txt` securely off-site -4. **Update passphrase annually** - Change to new year format when year changes -5. **Local storage limit** - Current server has ~19GB available, monitor usage - -## Migration Timeline -- **Immediate**: Local BorgBackup operational (✅ Complete) -- **Phase 2**: Off-site storage setup (requires Storage Box purchase or AWS approval) -- **Phase 3**: Automated off-site testing and monitoring \ No newline at end of file diff --git a/CI-CD-SETUP-COMPLETE.md b/CI-CD-SETUP-COMPLETE.md deleted file mode 100644 index d1aee96..0000000 --- a/CI-CD-SETUP-COMPLETE.md +++ /dev/null @@ -1,121 +0,0 @@ -# DocFast CI/CD Pipeline Setup - COMPLETED ✅ - -## What Was Implemented - -### ✅ Forgejo Actions Workflow -- **File**: `.forgejo/workflows/deploy.yml` -- **Trigger**: Push to `main` branch -- **Process**: - 1. SSH to production server (167.235.156.214) - 2. Pull latest code from git - 3. Tag current Docker image for rollback (`rollback-YYYYMMDD-HHMMSS`) - 4. Build new Docker image with `--no-cache` - 5. Stop current services (30s graceful timeout) - 6. Start new services with `docker compose up -d` - 7. Health check at `http://127.0.0.1:3100/health` (30 attempts, 5s intervals) - 8. **Auto-rollback** if health check fails - 9. Cleanup old rollback images (keeps last 5) - -### ✅ Rollback Mechanism -- **Automatic**: Built into the deployment workflow -- **Manual Script**: `scripts/rollback.sh` for emergency use -- **Image Tagging**: Previous images tagged with timestamps -- **Auto-cleanup**: Removes old rollback images automatically - -### ✅ Documentation -- **`DEPLOYMENT.md`**: Complete deployment guide -- **`CI-CD-SETUP-COMPLETE.md`**: This summary -- **Inline comments**: Detailed workflow documentation - -### ✅ Git Integration -- Repository: `git@git.cloonar.com:openclawd/docfast.git` -- SSH access configured with key: `/home/openclaw/.ssh/docfast` -- All CI/CD files committed and pushed successfully - -## What Needs Manual Setup (5 minutes) - -### 🔧 Repository Secrets -Go to: https://git.cloonar.com/openclawd/docfast/settings/actions/secrets - -Add these 3 secrets: -1. **SERVER_HOST**: `167.235.156.214` -2. **SERVER_USER**: `root` -3. **SSH_PRIVATE_KEY**: (copy content from `/home/openclaw/.ssh/docfast`) - -### 🧪 Test the Pipeline -1. Once secrets are added, push any change to main branch -2. Check Actions tab: https://git.cloonar.com/openclawd/docfast/actions -3. Watch deployment progress -4. Verify with: `curl http://127.0.0.1:3100/health` - -## How to Trigger Deployments - -- **Automatic**: Any push to `main` branch -- **Manual**: Push a trivial change (already prepared: VERSION file) - -## How to Rollback - -### Automatic Rollback -- Happens automatically if new deployment fails health checks -- No manual intervention required - -### Manual Rollback Options -```bash -# Option 1: Use the rollback script -ssh root@167.235.156.214 -cd /root/docfast -./scripts/rollback.sh - -# Option 2: Manual Docker commands -ssh root@167.235.156.214 -docker compose down -docker images | grep rollback # Find latest rollback image -docker tag docfast-docfast:rollback-YYYYMMDD-HHMMSS docfast-docfast:latest -docker compose up -d -``` - -## Monitoring Commands - -```bash -# Health check -curl http://127.0.0.1:3100/health - -# Service status -docker compose ps - -# View logs -docker compose logs -f docfast - -# Check rollback images available -docker images | grep docfast-docfast -``` - -## Files Added/Modified - -``` -.forgejo/workflows/deploy.yml # Main deployment workflow -scripts/rollback.sh # Emergency rollback script -scripts/setup-secrets.sh # Helper script (API had auth issues) -DEPLOYMENT.md # Deployment documentation -CI-CD-SETUP-COMPLETE.md # This summary -VERSION # Test file for pipeline testing -``` - -## Next Steps - -1. **Set up secrets** in Forgejo (5 minutes) -2. **Test deployment** by making a small change -3. **Verify** the health check endpoint works -4. **Document** any environment-specific adjustments needed - -## Success Criteria ✅ - -- [x] Forgejo Actions available and configured -- [x] Deployment workflow created and tested (syntax) -- [x] Rollback mechanism implemented (automatic + manual) -- [x] Health check integration (`/health` endpoint) -- [x] Git repository integration working -- [x] Documentation complete -- [x] Test change ready for pipeline verification - -**Ready for production use once secrets are configured!** 🚀 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1143405..92c3f39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,37 @@ -FROM node:22-bookworm-slim +# ============================================ +# Stage 1: Builder +# ============================================ +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +# Copy package files for dependency installation +COPY package*.json tsconfig.json ./ + +# Install ALL dependencies (including devDependencies for build) +RUN npm install + +# Copy source code and build scripts +COPY src/ src/ +COPY scripts/ scripts/ +COPY public/ public/ + +# Compile TypeScript +RUN npx tsc + +# Generate OpenAPI spec +RUN node scripts/generate-openapi.mjs + +# Build HTML templates +RUN node scripts/build-html.cjs + +# Create swagger-ui symlink in builder stage +RUN rm -f public/swagger-ui && ln -s /app/node_modules/swagger-ui-dist public/swagger-ui + +# ============================================ +# Stage 2: Production +# ============================================ +FROM node:22-bookworm-slim AS production # Install Chromium and dependencies as root RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -9,20 +42,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN groupadd --gid 1001 docfast \ && useradd --uid 1001 --gid docfast --shell /bin/bash --create-home docfast -# Set environment variables -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - WORKDIR /app + +# Copy package files for production dependency installation COPY package*.json ./ + +# Install ONLY production dependencies RUN npm install --omit=dev -COPY dist/ dist/ -COPY scripts/ scripts/ -COPY public/ public/ -RUN node scripts/build-html.cjs +# Copy compiled artifacts from builder stage +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/public ./public +COPY --from=builder /app/src ./src + +# Recreate swagger-ui symlink in production stage RUN rm -f public/swagger-ui && ln -s /app/node_modules/swagger-ui-dist public/swagger-ui +# Set Puppeteer environment variables +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + # Create data directory and set ownership to docfast user RUN mkdir -p /app/data && chown -R docfast:docfast /app diff --git a/Dockerfile.backup b/Dockerfile.backup deleted file mode 100644 index bdc953a..0000000 --- a/Dockerfile.backup +++ /dev/null @@ -1,19 +0,0 @@ -FROM node:22-bookworm-slim - -# Install Chromium (works on ARM and x86) -RUN apt-get update && apt-get install -y --no-install-recommends \ - chromium fonts-liberation \ - && rm -rf /var/lib/apt/lists/* - -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - -WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY dist/ dist/ -COPY public/ public/ - -ENV PORT=3100 -EXPOSE 3100 -CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 6cd4e54..4052ea8 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,71 @@ # DocFast API -Fast, simple HTML/Markdown to PDF API with built-in invoice templates. +Fast, reliable HTML/Markdown/URL to PDF conversion API. EU-hosted, GDPR compliant. + +**Website:** https://docfast.dev +**Docs:** https://docfast.dev/docs +**Status:** https://docfast.dev/status + +## Features + +- **HTML → PDF** — Full documents or fragments with optional CSS +- **Markdown → PDF** — GitHub-flavored Markdown with syntax highlighting +- **URL → PDF** — Render any public webpage as PDF (SSRF-protected) +- **Invoice Templates** — Built-in professional invoice template +- **PDF Options** — Paper size, orientation, margins, headers/footers, page ranges, scaling ## Quick Start +### 1. Get an API Key + +Sign up at https://docfast.dev — free demo available, Pro plan at €9/month for 5,000 PDFs. + +### 2. Generate a PDF + ```bash -npm install -npm run build -API_KEYS=your-key-here npm start +curl -X POST https://docfast.dev/v1/convert/html \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"html": "

Hello World

Your first PDF.

"}' \ + -o output.pdf ``` -## Endpoints +## API Endpoints ### Convert HTML to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/html \ +curl -X POST https://docfast.dev/v1/convert/html \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"html": "

Hello

World

"}' \ + -d '{"html": "

Hello

", "format": "A4", "margin": {"top": "20mm"}}' \ -o output.pdf ``` ### Convert Markdown to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/markdown \ +curl -X POST https://docfast.dev/v1/convert/markdown \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"markdown": "# Hello\n\nWorld"}' \ + -d '{"markdown": "# Hello\n\nWorld", "css": "body { font-family: sans-serif; }"}' \ + -o output.pdf +``` + +### Convert URL to PDF + +```bash +curl -X POST https://docfast.dev/v1/convert/url \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com", "format": "A4", "landscape": true}' \ -o output.pdf ``` ### Invoice Template + ```bash -curl -X POST http://localhost:3100/v1/templates/invoice/render \ +curl -X POST https://docfast.dev/v1/templates/invoice/render \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -40,23 +73,95 @@ curl -X POST http://localhost:3100/v1/templates/invoice/render \ "date": "2026-02-14", "from": {"name": "Your Company", "email": "you@example.com"}, "to": {"name": "Client", "email": "client@example.com"}, - "items": [{"description": "Service", "quantity": 1, "unitPrice": 100, "taxRate": 20}] + "items": [{"description": "Consulting", "quantity": 10, "unitPrice": 150, "taxRate": 20}] }' \ -o invoice.pdf ``` -### Options -- `format`: Paper size (A4, Letter, Legal, etc.) -- `landscape`: true/false -- `margin`: `{top, right, bottom, left}` in CSS units -- `css`: Custom CSS (for markdown/html fragments) -- `filename`: Suggested filename in Content-Disposition header +### Demo (No Auth Required) -## Auth -Pass API key via `Authorization: Bearer `. Set `API_KEYS` env var (comma-separated for multiple keys). +Try the API without signing up: -## Docker ```bash -docker build -t docfast . -docker run -p 3100:3100 -e API_KEYS=your-key docfast +curl -X POST https://docfast.dev/v1/demo/html \ + -H "Content-Type: application/json" \ + -d '{"html": "

Demo PDF

No API key needed.

"}' \ + -o demo.pdf ``` + +Demo PDFs include a watermark and are rate-limited. + +## PDF Options + +All conversion endpoints accept these options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"A4"` | Paper size: A4, Letter, Legal, A3, etc. | +| `landscape` | boolean | `false` | Landscape orientation | +| `margin` | object | `{top:"0",right:"0",bottom:"0",left:"0"}` | Margins in CSS units (px, mm, in, cm) | +| `printBackground` | boolean | `true` | Include background colors/images | +| `filename` | string | `"document.pdf"` | Suggested filename in Content-Disposition | +| `css` | string | — | Custom CSS (for HTML fragments and Markdown) | +| `scale` | number | `1` | Scale (0.1–2.0) | +| `pageRanges` | string | — | Page ranges, e.g. `"1-3, 5"` | +| `width` | string | — | Custom page width (overrides format) | +| `height` | string | — | Custom page height (overrides format) | +| `headerTemplate` | string | — | HTML template for page header | +| `footerTemplate` | string | — | HTML template for page footer | +| `displayHeaderFooter` | boolean | `false` | Show header/footer | +| `preferCSSPageSize` | boolean | `false` | Use CSS `@page` size over format | + +## Authentication + +Pass your API key via either: +- `Authorization: Bearer ` header +- `X-API-Key: ` header + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run dev + +# Run tests +npm test + +# Build +npm run build + +# Start production server +npm start +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `STRIPE_SECRET_KEY` | Yes | Stripe API key for billing | +| `STRIPE_WEBHOOK_SECRET` | Yes | Stripe webhook signature secret | +| `SMTP_HOST` | Yes | SMTP server hostname | +| `SMTP_PORT` | Yes | SMTP server port | +| `SMTP_USER` | Yes | SMTP username | +| `SMTP_PASS` | Yes | SMTP password | +| `BASE_URL` | No | Base URL (default: https://docfast.dev) | +| `PORT` | No | Server port (default: 3100) | +| `BROWSER_COUNT` | No | Puppeteer browser instances (default: 2) | +| `PAGES_PER_BROWSER` | No | Pages per browser (default: 8) | +| `LOG_LEVEL` | No | Pino log level (default: info) | + +### Architecture + +- **Runtime:** Node.js + Express +- **PDF Engine:** Puppeteer (Chromium) with browser pool +- **Database:** PostgreSQL (via pg) +- **Payments:** Stripe +- **Email:** SMTP (nodemailer) + +## License + +Proprietary — Cloonar Technologies GmbH diff --git a/bugs.md b/bugs.md deleted file mode 100644 index 8ebec70..0000000 --- a/bugs.md +++ /dev/null @@ -1,24 +0,0 @@ -# DocFast Bugs - -## Open - -### BUG-030: Email change backend not implemented -- **Severity:** High -- **Found:** 2026-02-14 QA session -- **Description:** Frontend UI for email change is deployed (modal, form, JS handlers), but no backend routes exist. Frontend calls `/v1/email-change` and `/v1/email-change/verify` which return 404. -- **Impact:** Users see "Change Email" link in footer but the feature doesn't work. -- **Fix:** Implement `src/routes/email-change.ts` with verification code flow similar to signup/recover. - -### BUG-031: Stray file "\001@" in repository -- **Severity:** Low -- **Found:** 2026-02-14 -- **Description:** An accidental file named `\001@` was committed to the repo. -- **Fix:** `git rm "\001@"` and commit. - -### BUG-032: Swagger UI content not rendered via web_fetch -- **Severity:** Low (cosmetic) -- **Found:** 2026-02-14 -- **Description:** /docs page loads (200) and has swagger-ui assets, but content is JS-rendered so web_fetch can't verify full render. Needs browser-based QA for full verification. - -## Fixed -(none yet - this is first QA session) diff --git a/decisions.md b/decisions.md deleted file mode 100644 index a68912d..0000000 --- a/decisions.md +++ /dev/null @@ -1,21 +0,0 @@ -# DocFast Decisions Log - -## 2026-02-14: Mandatory QA After Every Deployment - -**Rule:** Every deployment MUST be followed by a full QA session. No exceptions. - -**QA Checklist:** -- Landing page loads, zero console errors -- Signup flow works (email verification) -- Key recovery flow works -- Email change flow works (when backend is implemented) -- Swagger UI loads at /docs -- API endpoints work (HTML→PDF, Markdown→PDF, URL→PDF) -- Health endpoint returns ok -- All previous features still working - -**Rationale:** Code was deployed to production without verification multiple times, leading to broken features being live. QA catches regressions before users do. - -## 2026-02-14: Code Must Be Committed Before Deployment - -Changes were found uncommitted on the production server. All code changes must be committed and pushed to Forgejo before deploying. diff --git a/dist/__tests__/api.test.js b/dist/__tests__/api.test.js index b99fca3..93deda1 100644 --- a/dist/__tests__/api.test.js +++ b/dist/__tests__/api.test.js @@ -1,24 +1,20 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { app } from "../index.js"; -// Note: These tests require Puppeteer/Chrome to be available -// For CI, use the Dockerfile which includes Chrome const BASE = "http://localhost:3199"; let server; beforeAll(async () => { - process.env.API_KEYS = "test-key"; - process.env.PORT = "3199"; - // Import fresh to pick up env server = app.listen(3199); - // Wait for browser init - await new Promise((r) => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 200)); }); afterAll(async () => { - server?.close(); + await new Promise((resolve) => server?.close(() => resolve())); }); describe("Auth", () => { it("rejects requests without API key", async () => { const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" }); expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBeDefined(); }); it("rejects invalid API key", async () => { const res = await fetch(`${BASE}/v1/convert/html`, { @@ -26,6 +22,8 @@ describe("Auth", () => { headers: { Authorization: "Bearer wrong-key" }, }); expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBeDefined(); }); }); describe("Health", () => { @@ -35,51 +33,243 @@ describe("Health", () => { const data = await res.json(); expect(data.status).toBe("ok"); }); + it("includes database field", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.database).toBeDefined(); + expect(data.database.status).toBeDefined(); + }); + it("includes pool field with size, active, available", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.pool).toBeDefined(); + expect(typeof data.pool.size).toBe("number"); + expect(typeof data.pool.active).toBe("number"); + expect(typeof data.pool.available).toBe("number"); + }); + it("includes version field", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.version).toBeDefined(); + expect(typeof data.version).toBe("string"); + }); }); describe("HTML to PDF", () => { it("converts simple HTML", async () => { const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST", - headers: { - Authorization: "Bearer test-key", - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, body: JSON.stringify({ html: "

Test

" }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("application/pdf"); const buf = await res.arrayBuffer(); - expect(buf.byteLength).toBeGreaterThan(100); - // PDF magic bytes + expect(buf.byteLength).toBeGreaterThan(10); const header = new Uint8Array(buf.slice(0, 5)); expect(String.fromCharCode(...header)).toBe("%PDF-"); }); it("rejects missing html field", async () => { const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST", - headers: { - Authorization: "Bearer test-key", - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); + it("converts HTML with A3 format option", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

A3 Test

", options: { format: "A3" } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + it("converts HTML with landscape option", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

Landscape Test

", options: { landscape: true } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + it("converts HTML with margin options", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

Margin Test

", options: { margin: { top: "2cm" } } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + it("rejects invalid JSON body", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: "invalid json{", + }); + expect(res.status).toBe(400); + }); + it("rejects wrong content-type header", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "text/plain" }, + body: JSON.stringify({ html: "

Test

" }), + }); + expect(res.status).toBe(415); + }); + it("handles empty html string", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "" }), + }); + // Empty HTML should still generate a PDF (just blank) - but validation may reject it + expect([200, 400]).toContain(res.status); + }); }); describe("Markdown to PDF", () => { it("converts markdown", async () => { const res = await fetch(`${BASE}/v1/convert/markdown`, { method: "POST", - headers: { - Authorization: "Bearer test-key", - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, body: JSON.stringify({ markdown: "# Hello\n\nWorld" }), }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("application/pdf"); }); }); +describe("URL to PDF", () => { + it("rejects missing url field", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("url"); + }); + it("blocks private IP addresses (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://127.0.0.1" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + it("blocks localhost (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://localhost" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + it("blocks 0.0.0.0 (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://0.0.0.0" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + it("returns default filename in Content-Disposition for /convert/html", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

hello

" }), + }); + expect(res.status).toBe(200); + const disposition = res.headers.get("content-disposition"); + expect(disposition).toContain('filename="document.pdf"'); + }); + it("rejects invalid protocol (ftp)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "ftp://example.com" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("http"); + }); + it("rejects invalid URL format", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "not-a-url" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Invalid"); + }); + it("converts valid URL to PDF", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(10); + const header = new Uint8Array(buf.slice(0, 5)); + expect(String.fromCharCode(...header)).toBe("%PDF-"); + }); +}); +describe("Demo Endpoints", () => { + it("demo/html converts HTML to PDF without auth", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

Demo Test

" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(10); + const header = new Uint8Array(buf.slice(0, 5)); + expect(String.fromCharCode(...header)).toBe("%PDF-"); + }); + it("demo/markdown converts markdown to PDF without auth", async () => { + const res = await fetch(`${BASE}/v1/demo/markdown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ markdown: "# Demo Markdown\n\nTest content" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + it("demo rejects missing html field", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); + it("demo rejects wrong content-type", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "

Test

", + }); + expect(res.status).toBe(415); + }); +}); describe("Templates", () => { it("lists templates", async () => { const res = await fetch(`${BASE}/v1/templates`, { @@ -93,10 +283,7 @@ describe("Templates", () => { it("renders invoice template", async () => { const res = await fetch(`${BASE}/v1/templates/invoice/render`, { method: "POST", - headers: { - Authorization: "Bearer test-key", - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, body: JSON.stringify({ invoiceNumber: "TEST-001", date: "2026-02-14", @@ -111,12 +298,295 @@ describe("Templates", () => { it("returns 404 for unknown template", async () => { const res = await fetch(`${BASE}/v1/templates/nonexistent/render`, { method: "POST", - headers: { - Authorization: "Bearer test-key", - "Content-Type": "application/json", - }, + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(404); }); }); +// === NEW TESTS: Task 3 === +describe("Signup endpoint (discontinued)", () => { + it("returns 410 Gone", async () => { + const res = await fetch(`${BASE}/v1/signup/free`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "test@example.com" }), + }); + expect(res.status).toBe(410); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); +}); +describe("Recovery endpoint validation", () => { + it("rejects missing email", async () => { + const res = await fetch(`${BASE}/v1/recover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); + it("rejects invalid email format", async () => { + const res = await fetch(`${BASE}/v1/recover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "not-an-email" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); + it("accepts valid email (always returns success)", async () => { + const res = await fetch(`${BASE}/v1/recover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "user@example.com" }), + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("recovery_sent"); + }); + it("verify rejects missing fields", async () => { + const res = await fetch(`${BASE}/v1/recover/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + // May be 400 (validation) or 429 (rate limited from previous recover calls) + expect([400, 429]).toContain(res.status); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); +}); +describe("CORS headers", () => { + it("sets Access-Control-Allow-Origin to * for API routes", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "OPTIONS", + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + it("restricts CORS for signup/billing/demo routes to docfast.dev", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "OPTIONS", + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("https://docfast.dev"); + }); + it("includes correct allowed methods", async () => { + const res = await fetch(`${BASE}/health`, { method: "OPTIONS" }); + const methods = res.headers.get("access-control-allow-methods"); + expect(methods).toContain("GET"); + expect(methods).toContain("POST"); + }); +}); +describe("Error response format consistency", () => { + it("401 returns {error: string}", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" }); + expect(res.status).toBe(401); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + }); + it("403 returns {error: string}", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer bad-key" }, + }); + expect(res.status).toBe(403); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + }); + it("404 API returns {error: string}", async () => { + const res = await fetch(`${BASE}/v1/nonexistent`); + expect(res.status).toBe(404); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + }); + it("410 returns {error: string}", async () => { + const res = await fetch(`${BASE}/v1/signup/free`, { method: "POST" }); + expect(res.status).toBe(410); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + }); +}); +describe("Rate limiting (global)", () => { + it("includes rate limit headers", async () => { + const res = await fetch(`${BASE}/health`); + // express-rate-limit with standardHeaders:true uses RateLimit-* headers + const limit = res.headers.get("ratelimit-limit"); + expect(limit).toBeDefined(); + }); +}); +describe("API root", () => { + it("returns API info", async () => { + const res = await fetch(`${BASE}/api`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.name).toBe("DocFast API"); + expect(data.version).toBeDefined(); + expect(data.endpoints).toBeInstanceOf(Array); + }); +}); +describe("JS minification", () => { + it("serves minified JS files in homepage HTML", async () => { + const res = await fetch(`${BASE}/`); + expect(res.status).toBe(200); + const html = await res.text(); + // Check that HTML references app.js and status.js + expect(html).toContain('src="/app.js"'); + // Fetch the JS file and verify it's minified (no excessive whitespace) + const jsRes = await fetch(`${BASE}/app.js`); + expect(jsRes.status).toBe(200); + const jsContent = await jsRes.text(); + // Minified JS should not have excessive whitespace or comments + // Basic check: line count should be reasonable for minified code + const lineCount = jsContent.split('\n').length; + expect(lineCount).toBeLessThan(50); // Original has ~400+ lines, minified should be much less + // Should not contain developer comments (/* ... */) + expect(jsContent).not.toMatch(/\/\*[\s\S]*?\*\//); + }); +}); +describe("Usage endpoint", () => { + it("requires authentication (401 without key)", async () => { + const res = await fetch(`${BASE}/v1/usage`); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(typeof data.error).toBe("string"); + }); + it("requires admin key (503 when not configured)", async () => { + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer test-key" }, + }); + expect(res.status).toBe(503); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(data.error).toContain("Admin access not configured"); + }); + it("returns usage data with admin key", async () => { + // This test will likely fail since we don't have an admin key set in test environment + // But it documents the expected behavior + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer admin-key" }, + }); + // Could be 503 (admin access not configured) or 403 (admin access required) + expect([403, 503]).toContain(res.status); + }); +}); +describe("Billing checkout", () => { + it("has rate limiting headers", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + // Check rate limit headers are present (express-rate-limit should add these) + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + it("fails when Stripe not configured", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + // Returns 500 due to missing STRIPE_SECRET_KEY in test environment + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); +}); +describe("Rate limit headers on PDF endpoints", () => { + it("includes rate limit headers on HTML conversion", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ html: "

Test

" }), + }); + expect(res.status).toBe(200); + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + it("includes rate limit headers on demo endpoint", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

Demo Test

" }), + }); + expect(res.status).toBe(200); + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); +}); +describe("OpenAPI spec", () => { + it("returns a valid OpenAPI 3.0 spec with paths", async () => { + const res = await fetch(`${BASE}/openapi.json`); + expect(res.status).toBe(200); + const spec = await res.json(); + expect(spec.openapi).toBe("3.0.3"); + expect(spec.info).toBeDefined(); + expect(spec.info.title).toBe("DocFast API"); + expect(Object.keys(spec.paths).length).toBeGreaterThanOrEqual(8); + }); + it("includes all major endpoint groups", async () => { + const res = await fetch(`${BASE}/openapi.json`); + const spec = await res.json(); + const paths = Object.keys(spec.paths); + expect(paths).toContain("/v1/convert/html"); + expect(paths).toContain("/v1/convert/markdown"); + expect(paths).toContain("/health"); + }); + it("PdfOptions schema includes all valid format values and waitUntil field", async () => { + const res = await fetch(`${BASE}/openapi.json`); + const spec = await res.json(); + const pdfOptions = spec.components.schemas.PdfOptions; + expect(pdfOptions).toBeDefined(); + // Check that all 11 format values are included + const expectedFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; + expect(pdfOptions.properties.format.enum).toEqual(expectedFormats); + // Check that waitUntil field exists with correct enum values + expect(pdfOptions.properties.waitUntil).toBeDefined(); + expect(pdfOptions.properties.waitUntil.enum).toEqual(["load", "domcontentloaded", "networkidle0", "networkidle2"]); + // Check that headerTemplate and footerTemplate descriptions mention 100KB limit + expect(pdfOptions.properties.headerTemplate.description).toContain("100KB"); + expect(pdfOptions.properties.footerTemplate.description).toContain("100KB"); + }); +}); +describe("404 handler", () => { + it("returns proper JSON error format for API routes", async () => { + const res = await fetch(`${BASE}/v1/nonexistent-endpoint`); + expect(res.status).toBe(404); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + expect(data.error).toContain("Not Found"); + expect(data.error).toContain("GET"); + expect(data.error).toContain("/v1/nonexistent-endpoint"); + }); + it("returns HTML 404 for non-API routes", async () => { + const res = await fetch(`${BASE}/nonexistent-page`); + expect(res.status).toBe(404); + const html = await res.text(); + expect(html).toContain(""); + expect(html).toContain("404"); + expect(html).toContain("Page Not Found"); + }); +}); diff --git a/dist/index.js b/dist/index.js index aa69275..cf924ba 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,7 @@ import express from "express"; import { randomUUID } from "crypto"; -import compression from "compression"; +import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot +import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; import path from "path"; @@ -9,17 +10,19 @@ import rateLimit from "express-rate-limit"; import { convertRouter } from "./routes/convert.js"; import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; -import { signupRouter } from "./routes/signup.js"; +import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; -import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; -import { getUsageStats } from "./middleware/usage.js"; -import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; +import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; +import { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js"; +import { adminRouter } from "./routes/admin.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; -import { verifyToken, loadVerifications } from "./services/verification.js"; -import { initDatabase, pool } from "./services/db.js"; +import { pagesRouter } from "./routes/pages.js"; +import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; +import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-cleanup.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); @@ -43,14 +46,31 @@ app.use((_req, res, next) => { next(); }); // Compression -app.use(compression()); +app.use(compressionMiddleware); +// Block search engine indexing on staging +app.use((req, res, next) => { + if (req.hostname.includes("staging")) { + res.setHeader("X-Robots-Tag", "noindex, nofollow"); + } + next(); +}); // Differentiated CORS middleware +const ALLOWED_ORIGINS = new Set(["https://docfast.dev", "https://staging.docfast.dev"]); app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || - req.path.startsWith('/v1/billing'); + req.path.startsWith('/v1/billing') || + req.path.startsWith('/v1/demo') || + req.path.startsWith('/v1/email-change'); if (isAuthBillingRoute) { - res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.has(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + else { + res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + } } else { res.setHeader("Access-Control-Allow-Origin", "*"); @@ -66,7 +86,8 @@ app.use((req, res, next) => { }); // Raw body for Stripe webhook signature verification app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); -app.use(express.json({ limit: "2mb" })); +// NOTE: No global express.json() here — route-specific parsers are applied +// per-route below to enforce correct body size limits (BUG-101 fix). app.use(express.text({ limit: "2mb", type: "text/*" })); // Trust nginx proxy app.set("trust proxy", 1); @@ -80,106 +101,54 @@ const limiter = rateLimit({ app.use(limiter); // Public routes app.use("/health", healthRouter); -app.use("/v1/signup", signupRouter); -app.use("/v1/recover", recoverRouter); -app.use("/v1/billing", billingRouter); +app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Account] + * deprecated: true + * summary: Request a free API key (discontinued) + * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. + * responses: + * 410: + * description: Feature discontinued + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * demo_endpoint: + * type: string + * pro_url: + * type: string + */ +app.use("/v1/signup", (_req, res) => { + res.status(410).json({ + error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", + demo_endpoint: "/v1/demo/html", + pro_url: "https://docfast.dev/#pricing" + }); +}); +// Default 2MB JSON parser for standard routes +const defaultJsonParser = express.json({ limit: "2mb" }); +app.use("/v1/recover", defaultJsonParser, recoverRouter); +app.use("/v1/email-change", defaultJsonParser, emailChangeRouter); +app.use("/v1/billing", defaultJsonParser, billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) const convertBodyLimit = express.json({ limit: "500kb" }); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); -app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); -// Admin: usage stats (admin key required) -const adminAuth = (req, res, next) => { - const adminKey = process.env.ADMIN_API_KEY; - if (!adminKey) { - res.status(503).json({ error: "Admin access not configured" }); - return; - } - if (req.apiKeyInfo?.key !== adminKey) { - res.status(403).json({ error: "Admin access required" }); - return; - } - next(); -}; -app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => { - res.json(getUsageStats(req.apiKeyInfo?.key)); -}); -// Admin: concurrency stats (admin key required) -app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => { - res.json(getConcurrencyStats()); -}); -// Email verification endpoint -app.get("/verify", (req, res) => { - const token = req.query.token; - if (!token) { - res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null)); - return; - } - const result = verifyToken(token); - switch (result.status) { - case "ok": - res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification.apiKey)); - break; - case "already_verified": - res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification.apiKey)); - break; - case "expired": - res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null)); - break; - case "invalid": - res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null)); - break; - } -}); -function verifyPage(title, message, apiKey) { - return ` - -${title} — DocFast - - - -
-

${title}

-

${message}

-${apiKey ? ` -
⚠️ Save your API key securely. You can recover it via email if needed.
-
${apiKey}
- -` : ``} -
`; -} -// Landing page +app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); +// Admin + usage routes (extracted to routes/admin.ts) +app.use(adminRouter); +// Pages, favicon, docs, openapi.json, /api (extracted to routes/pages.ts) const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Favicon route -app.get("/favicon.ico", (_req, res) => { - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=604800'); - res.sendFile(path.join(__dirname, "../public/favicon.svg")); -}); -// Docs page (clean URL) -app.get("/docs", (_req, res) => { - // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. - // Override helmet's default CSP to allow 'unsafe-eval' + blob: for Swagger UI. - res.setHeader("Content-Security-Policy", "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/docs.html")); -}); +app.use(pagesRouter); // Static asset cache headers middleware app.use((req, res, next) => { if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { - console.log("CACHE HIT:", req.path); res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); } next(); @@ -188,39 +157,6 @@ app.use(express.static(path.join(__dirname, "../public"), { etag: true, cacheControl: false, })); -// Legal pages (clean URLs) -app.get("/impressum", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/impressum.html")); -}); -app.get("/privacy", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/privacy.html")); -}); -app.get("/terms", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/terms.html")); -}); -app.get("/status", (_req, res) => { - res.setHeader("Cache-Control", "public, max-age=60"); - res.sendFile(path.join(__dirname, "../public/status.html")); -}); -// API root -app.get("/api", (_req, res) => { - res.json({ - name: "DocFast API", - version: "0.2.1", - endpoints: [ - "POST /v1/signup/free — Get a free API key", - "POST /v1/convert/html", - "POST /v1/convert/markdown", - "POST /v1/convert/url", - "POST /v1/templates/:id/render", - "GET /v1/templates", - "POST /v1/billing/checkout — Start Pro subscription", - ], - }); -}); // 404 handler - must be after all routes app.use((req, res) => { // Check if it's an API request @@ -263,22 +199,57 @@ app.use((req, res) => { `); } }); +// Global error handler — must be after all routes +app.use((err, req, res, _next) => { + const reqId = req.requestId || "unknown"; + // Check if this is a JSON parse error from express.json() + if (err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err) { + logger.warn({ err, requestId: reqId, method: req.method, path: req.path }, "Invalid JSON body"); + if (!res.headersSent) { + res.status(400).json({ error: "Invalid JSON in request body" }); + } + return; + } + logger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error"); + if (!res.headersSent) { + const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health"); + if (isApi) { + res.status(500).json({ error: "Internal server error" }); + } + else { + res.status(500).send("Internal server error"); + } + } +}); async function start() { // Initialize PostgreSQL await initDatabase(); // Load data from PostgreSQL await loadKeys(); - await loadVerifications(); await loadUsageData(); await initBrowser(); logger.info(`Loaded ${getAllKeys().length} API keys`); const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); + // Run database cleanup 30 seconds after startup (non-blocking) + setTimeout(async () => { + try { + logger.info("Running scheduled database cleanup..."); + await cleanupStaleData(); + } + catch (err) { + logger.error({ err }, "Startup cleanup failed (non-fatal)"); + } + }, 30_000); + // Run database cleanup every 6 hours (expired verifications, orphaned usage) + startPeriodicCleanup(); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) return; shuttingDown = true; logger.info(`Received ${signal}, starting graceful shutdown...`); + // 0. Stop periodic cleanup timer + stopPeriodicCleanup(); // 1. Stop accepting new connections, wait for in-flight requests (max 10s) await new Promise((resolve) => { const forceTimeout = setTimeout(() => { @@ -291,6 +262,14 @@ async function start() { resolve(); }); }); + // 1.5. Flush dirty usage entries while DB pool is still alive + try { + await flushDirtyEntries(); + logger.info("Usage data flushed"); + } + catch (err) { + logger.error({ err }, "Error flushing usage data during shutdown"); + } // 2. Close Puppeteer browser pool try { await closeBrowser(); @@ -312,9 +291,19 @@ async function start() { }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); + process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception — shutting down"); + process.exit(1); + }); + process.on("unhandledRejection", (reason) => { + logger.fatal({ err: reason }, "Unhandled rejection — shutting down"); + process.exit(1); + }); +} +if (process.env.NODE_ENV !== "test") { + start().catch((err) => { + logger.error({ err }, "Failed to start"); + process.exit(1); + }); } -start().catch((err) => { - logger.error({ err }, "Failed to start"); - process.exit(1); -}); export { app }; diff --git a/dist/middleware/pdfRateLimit.js b/dist/middleware/pdfRateLimit.js index 62bba72..d83c0ae 100644 --- a/dist/middleware/pdfRateLimit.js +++ b/dist/middleware/pdfRateLimit.js @@ -29,17 +29,33 @@ function checkRateLimit(apiKey) { const limit = getRateLimit(apiKey); const entry = rateLimitStore.get(apiKey); if (!entry || now >= entry.resetTime) { + const resetTime = now + RATE_WINDOW_MS; rateLimitStore.set(apiKey, { count: 1, - resetTime: now + RATE_WINDOW_MS + resetTime }); - return true; + return { + allowed: true, + limit, + remaining: limit - 1, + resetTime + }; } if (entry.count >= limit) { - return false; + return { + allowed: false, + limit, + remaining: 0, + resetTime: entry.resetTime + }; } entry.count++; - return true; + return { + allowed: true, + limit, + remaining: limit - entry.count, + resetTime: entry.resetTime + }; } function getQueuedCountForKey(apiKey) { return pdfQueue.filter(w => w.apiKey === apiKey).length; @@ -73,10 +89,16 @@ export function pdfRateLimitMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const apiKey = keyInfo?.key || "unknown"; // Check rate limit first - if (!checkRateLimit(apiKey)) { - const limit = getRateLimit(apiKey); + const rateLimitResult = checkRateLimit(apiKey); + // Set rate limit headers on ALL responses + res.set('X-RateLimit-Limit', String(rateLimitResult.limit)); + res.set('X-RateLimit-Remaining', String(rateLimitResult.remaining)); + res.set('X-RateLimit-Reset', String(Math.ceil(rateLimitResult.resetTime / 1000))); + if (!rateLimitResult.allowed) { const tier = isProKey(apiKey) ? "pro" : "free"; - res.status(429).json({ error: `Rate limit exceeded: ${limit} PDFs/min allowed for ${tier} tier. Retry after 60s.` }); + const retryAfterSeconds = Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000); + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ error: `Rate limit exceeded: ${rateLimitResult.limit} PDFs/min allowed for ${tier} tier. Retry after ${retryAfterSeconds}s.` }); return; } // Add concurrency control to the request (pass apiKey for fairness) diff --git a/dist/middleware/usage.js b/dist/middleware/usage.js index c3251af..6dd72e3 100644 --- a/dist/middleware/usage.js +++ b/dist/middleware/usage.js @@ -1,6 +1,6 @@ import { isProKey } from "../services/keys.js"; import logger from "../services/logger.js"; -import pool from "../services/db.js"; +import { queryWithRetry, connectWithRetry } from "../services/db.js"; const FREE_TIER_LIMIT = 100; const PRO_TIER_LIMIT = 5000; // In-memory cache, periodically synced to PostgreSQL @@ -17,7 +17,7 @@ function getMonthKey() { } export async function loadUsageData() { try { - const result = await pool.query("SELECT key, count, month_key FROM usage"); + const result = await queryWithRetry("SELECT key, count, month_key FROM usage"); usage = new Map(); for (const row of result.rows) { usage.set(row.key, { count: row.count, monthKey: row.month_key }); @@ -30,53 +30,43 @@ export async function loadUsageData() { } } // Batch flush dirty entries to DB (Audit #10 + #12) -async function flushDirtyEntries() { +export async function flushDirtyEntries() { if (dirtyKeys.size === 0) return; const keysToFlush = [...dirtyKeys]; - const client = await pool.connect(); - try { - await client.query("BEGIN"); - for (const key of keysToFlush) { - const record = usage.get(key); - if (!record) - continue; - try { - await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) - ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); + for (const key of keysToFlush) { + const record = usage.get(key); + if (!record) + continue; + const client = await connectWithRetry(); + try { + await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); + dirtyKeys.delete(key); + retryCount.delete(key); + } + catch (error) { + // Audit #12: retry logic for failed writes + const retries = (retryCount.get(key) || 0) + 1; + if (retries >= MAX_RETRIES) { + logger.error({ key: key.slice(0, 8) + "...", retries }, "CRITICAL: Usage write failed after max retries, data may diverge"); dirtyKeys.delete(key); retryCount.delete(key); } - catch (error) { - // Audit #12: retry logic for failed writes - const retries = (retryCount.get(key) || 0) + 1; - if (retries >= MAX_RETRIES) { - logger.error({ key: key.slice(0, 8) + "...", retries }, "CRITICAL: Usage write failed after max retries, data may diverge"); - dirtyKeys.delete(key); - retryCount.delete(key); - } - else { - retryCount.set(key, retries); - logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry"); - } + else { + retryCount.set(key, retries); + logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry"); } } - await client.query("COMMIT"); - } - catch (error) { - await client.query("ROLLBACK").catch(() => { }); - logger.error({ err: error }, "Failed to flush usage batch"); - // Keep all keys dirty for retry - } - finally { - client.release(); + finally { + client.release(); + } } } // Periodic flush setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS); -// Flush on process exit -process.on("SIGTERM", () => { flushDirtyEntries().catch(() => { }); }); -process.on("SIGINT", () => { flushDirtyEntries().catch(() => { }); }); +// Note: SIGTERM/SIGINT flush is handled by the shutdown orchestrator in index.ts +// to avoid race conditions with pool.end(). export function usageMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const key = keyInfo?.key || "unknown"; @@ -93,7 +83,7 @@ export function usageMiddleware(req, res, next) { } const record = usage.get(key); if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) { - res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." }); + res.status(429).json({ error: "Account limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." }); return; } trackUsage(key, monthKey); @@ -113,6 +103,14 @@ function trackUsage(key, monthKey) { flushDirtyEntries().catch((err) => logger.error({ err }, "Threshold flush failed")); } } +export function getUsageForKey(key) { + const monthKey = getMonthKey(); + const record = usage.get(key); + if (record && record.monthKey === monthKey) { + return { count: record.count, monthKey }; + } + return { count: 0, monthKey }; +} export function getUsageStats(apiKey) { const stats = {}; if (apiKey) { diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 0c59405..2c4231d 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -1,23 +1,44 @@ import { Router } from "express"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import Stripe from "stripe"; -import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; +import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; -function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -} +import { renderSuccessPage, renderAlreadyProvisionedPage } from "../utils/billing-templates.js"; let _stripe = null; function getStripe() { if (!_stripe) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error("STRIPE_SECRET_KEY not configured"); + // @ts-expect-error Stripe SDK types lag behind API versions _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); } return _stripe; } const router = Router(); -// Track provisioned session IDs to prevent duplicate key creation -const provisionedSessions = new Set(); +// Track provisioned session IDs with TTL to prevent duplicate key creation and memory leaks +// Map - entries older than 24h are periodically cleaned up +const provisionedSessions = new Map(); +// TTL Configuration +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // Clean up every 1 hour +// Cleanup old provisioned session entries +function cleanupOldSessions() { + const now = Date.now(); + const cutoff = now - SESSION_TTL_MS; + let cleanedCount = 0; + for (const [sessionId, timestamp] of provisionedSessions.entries()) { + if (timestamp < cutoff) { + provisionedSessions.delete(sessionId); + cleanedCount++; + } + } + if (cleanedCount > 0) { + logger.info({ cleanedCount, remainingCount: provisionedSessions.size }, "Cleaned up expired provisioned sessions"); + } +} +// Start periodic cleanup +setInterval(cleanupOldSessions, CLEANUP_INTERVAL_MS); const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; // Returns true if the given Stripe subscription contains a DocFast product. // Used to filter webhook events — this Stripe account is shared with other projects. @@ -39,8 +60,51 @@ async function isDocFastSubscription(subscriptionId) { return false; } } -// Create a Stripe Checkout session for Pro subscription -router.post("/checkout", async (_req, res) => { +// Rate limit checkout: max 3 requests per IP per hour +const checkoutLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + keyGenerator: (req) => ipKeyGenerator(req.ip || req.socket.remoteAddress || "unknown"), + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many checkout requests. Please try again later." }, +}); +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: | + * Creates a Stripe Checkout session for a Pro subscription (€9/month). + * Returns a URL to redirect the user to Stripe's hosted payment page. + * Rate limited to 3 requests per hour per IP. + * responses: + * 200: + * description: Checkout session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe Checkout URL to redirect the user to + * 413: + * description: Request body too large + * 429: + * description: Too many checkout requests + * 500: + * description: Failed to create checkout session + */ +router.post("/checkout", checkoutLimiter, async (req, res) => { + // Reject suspiciously large request bodies (>1KB) + const contentLength = parseInt(req.headers["content-length"] || "0", 10); + if (contentLength > 1024) { + res.status(413).json({ error: "Request body too large" }); + return; + } try { const priceId = await getOrCreateProPrice(); const session = await getStripe().checkout.sessions.create({ @@ -50,6 +114,8 @@ router.post("/checkout", async (_req, res) => { success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`, }); + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; + logger.info({ clientIp, sessionId: session.id }, "Checkout session created"); res.json({ url: session.url }); } catch (err) { @@ -57,13 +123,15 @@ router.post("/checkout", async (_req, res) => { res.status(500).json({ error: "Failed to create checkout session" }); } }); -// Success page — provision Pro API key after checkout +// Success page — provision Pro API key after checkout (browser redirect, not a public API) router.get("/success", async (req, res) => { const sessionId = req.query.session_id; if (!sessionId) { res.status(400).json({ error: "Missing session_id" }); return; } + // Clean up old sessions before checking duplicates + cleanupOldSessions(); // Prevent duplicate provisioning from same session if (provisionedSessions.has(sessionId)) { res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." }); @@ -77,35 +145,23 @@ router.get("/success", async (req, res) => { res.status(400).json({ error: "No customer found" }); return; } + // Check DB for existing key (survives pod restarts, unlike provisionedSessions Map) + const existingKey = await findKeyByCustomerId(customerId); + if (existingKey) { + provisionedSessions.set(session.id, Date.now()); + res.send(renderAlreadyProvisionedPage()); + return; + } const keyInfo = await createProKey(email, customerId); - provisionedSessions.add(session.id); - // Return a nice HTML page instead of raw JSON - res.send(` -Welcome to DocFast Pro! - -
-

🎉 Welcome to Pro!

-

Your API key:

-
${escapeHtml(keyInfo.key)}
-

Save this key! It won't be shown again.

-

5,000 PDFs/month • All endpoints • Priority support

-

View API docs →

-
`); + provisionedSessions.set(session.id, Date.now()); + res.send(renderSuccessPage(keyInfo.key)); } catch (err) { logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } }); -// Stripe webhook for subscription lifecycle events +// Stripe webhook for subscription lifecycle events (internal, not in public API docs) router.post("/webhook", async (req, res) => { const sig = req.headers["stripe-signature"]; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -159,7 +215,7 @@ router.post("/webhook", async (req, res) => { break; } const keyInfo = await createProKey(email, customerId); - provisionedSessions.add(session.id); + provisionedSessions.set(session.id, Date.now()); logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); break; } diff --git a/dist/routes/convert.js b/dist/routes/convert.js index d7b7721..3965751 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -2,154 +2,222 @@ import { Router } from "express"; import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import dns from "node:dns/promises"; -import logger from "../services/logger.js"; -import net from "node:net"; -function isPrivateIP(ip) { - // IPv6 loopback/unspecified - if (ip === "::1" || ip === "::") - return true; - // IPv6 link-local (fe80::/10) - if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || - ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) - return true; - // IPv6 unique local (fc00::/7) - const lower = ip.toLowerCase(); - if (lower.startsWith("fc") || lower.startsWith("fd")) - return true; - // IPv4-mapped IPv6 - if (ip.startsWith("::ffff:")) - ip = ip.slice(7); - if (!net.isIPv4(ip)) - return false; - const parts = ip.split(".").map(Number); - if (parts[0] === 0) - return true; // 0.0.0.0/8 - if (parts[0] === 10) - return true; // 10.0.0.0/8 - if (parts[0] === 127) - return true; // 127.0.0.0/8 - if (parts[0] === 169 && parts[1] === 254) - return true; // 169.254.0.0/16 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) - return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) - return true; // 192.168.0.0/16 - return false; -} -function sanitizeFilename(name) { - // Strip characters dangerous in Content-Disposition headers - return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; -} +import { isPrivateIP } from "../utils/network.js"; +import { sanitizeFilename } from "../utils/sanitize.js"; +import { handlePdfRoute } from "../utils/pdf-handler.js"; export const convertRouter = Router(); -// POST /v1/convert/html +/** + * @openapi + * /v1/convert/html: + * post: + * tags: [Conversion] + * summary: Convert HTML to PDF + * description: Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert. Can be a full document or a fragment. + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (only used when html is a fragment, not a full document) + * example: 'body { font-family: sans-serif; padding: 40px; }' + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type (must be application/json) + * 429: + * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' + * 500: + * description: PDF generation failed + */ convertRouter.post("/html", async (req, res) => { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = typeof req.body === "string" ? { html: req.body } : req.body; if (!body.html) { res.status(400).json({ error: "Missing 'html' field" }); - return; + return null; } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; - } - // Wrap bare HTML fragments const fullHtml = body.html.includes(" { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = typeof req.body === "string" ? { markdown: req.body } : req.body; if (!body.markdown) { res.status(400).json({ error: "Missing 'markdown' field" }); - return; - } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; + return null; } const html = markdownToHtml(body.markdown, body.css); - const pdf = await renderPdf(html, { - format: body.format, - landscape: body.landscape, - margin: body.margin, - printBackground: body.printBackground, - }); - const filename = sanitizeFilename(body.filename || "document.pdf"); - res.setHeader("Content-Type", "application/pdf"); - res.setHeader("Content-Disposition", `inline; filename="${filename}"`); - res.send(pdf); - } - catch (err) { - logger.error({ err }, "Convert MD error"); - if (err.message === "QUEUE_FULL") { - res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); - return; - } - res.status(500).json({ error: `PDF generation failed: ${err.message}` }); - } - finally { - if (slotAcquired && req.releasePdfSlot) { - req.releasePdfSlot(); - } - } + const { pdf, durationMs } = await renderPdf(html, { ...sanitizedOptions }); + return { pdf, durationMs, filename: sanitizeFilename(body.filename || "document.pdf") }; + }); }); -// POST /v1/convert/url +/** + * @openapi + * /v1/convert/url: + * post: + * tags: [Conversion] + * summary: Convert URL to PDF + * description: | + * Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security. + * Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to convert (http or https only) + * example: 'https://example.com' + * waitUntil: + * type: string + * enum: [load, domcontentloaded, networkidle0, networkidle2] + * default: domcontentloaded + * description: When to consider navigation finished + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing/invalid URL or URL resolves to private IP + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' + * 500: + * description: PDF generation failed + */ convertRouter.post("/url", async (req, res) => { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = req.body; if (!body.url) { res.status(400).json({ error: "Missing 'url' field" }); - return; + return null; } // URL validation + SSRF protection let parsed; @@ -157,56 +225,31 @@ convertRouter.post("/url", async (req, res) => { parsed = new URL(body.url); if (!["http:", "https:"].includes(parsed.protocol)) { res.status(400).json({ error: "Only http/https URLs are supported" }); - return; + return null; } } catch { res.status(400).json({ error: "Invalid URL" }); - return; + return null; } - // DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding + // DNS lookup to block private/reserved IPs + pin resolution let resolvedAddress; try { const { address } = await dns.lookup(parsed.hostname); if (isPrivateIP(address)) { res.status(400).json({ error: "URL resolves to a private/internal IP address" }); - return; + return null; } resolvedAddress = address; } catch { res.status(400).json({ error: "DNS lookup failed for URL hostname" }); - return; + return null; } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; - } - const pdf = await renderUrlPdf(body.url, { - format: body.format, - landscape: body.landscape, - margin: body.margin, - printBackground: body.printBackground, - waitUntil: body.waitUntil, + const { pdf, durationMs } = await renderUrlPdf(body.url, { + ...sanitizedOptions, hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, }); - const filename = sanitizeFilename(body.filename || "page.pdf"); - res.setHeader("Content-Type", "application/pdf"); - res.setHeader("Content-Disposition", `inline; filename="${filename}"`); - res.send(pdf); - } - catch (err) { - logger.error({ err }, "Convert URL error"); - if (err.message === "QUEUE_FULL") { - res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); - return; - } - res.status(500).json({ error: `PDF generation failed: ${err.message}` }); - } - finally { - if (slotAcquired && req.releasePdfSlot) { - req.releasePdfSlot(); - } - } + return { pdf, durationMs, filename: sanitizeFilename(body.filename || "page.pdf") }; + }); }); diff --git a/dist/routes/email-change.js b/dist/routes/email-change.js index 3feae38..ab5a9de 100644 --- a/dist/routes/email-change.js +++ b/dist/routes/email-change.js @@ -1,82 +1,188 @@ import { Router } from "express"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import { createPendingVerification, verifyCode } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; -import { getAllKeys, updateKeyEmail } from "../services/keys.js"; +import { queryWithRetry } from "../services/db.js"; import logger from "../services/logger.js"; const router = Router(); -const changeLimiter = rateLimit({ +const emailChangeLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, - message: { error: "Too many attempts. Please try again in 1 hour." }, + message: { error: "Too many email change attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false, + keyGenerator: (req) => req.body?.apiKey || ipKeyGenerator(req.ip || "unknown"), }); -router.post("/", changeLimiter, async (req, res) => { - const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; - const newEmail = req.body?.newEmail; - if (!apiKey || typeof apiKey !== "string") { - res.status(400).json({ error: "API key is required (Authorization header or body)." }); - return; - } - if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { - res.status(400).json({ error: "A valid new email address is required." }); - return; - } - const cleanEmail = newEmail.trim().toLowerCase(); - const keys = getAllKeys(); - const userKey = keys.find((k) => k.key === apiKey); - if (!userKey) { - res.status(401).json({ error: "Invalid API key." }); - return; - } - const existing = keys.find((k) => k.email === cleanEmail); - if (existing) { - res.status(409).json({ error: "This email is already associated with another account." }); - return; - } - const pending = await createPendingVerification(cleanEmail); - sendVerificationEmail(cleanEmail, pending.code).catch((err) => { - logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); - }); - res.json({ status: "verification_sent", message: "Verification code sent to your new email address." }); -}); -router.post("/verify", changeLimiter, async (req, res) => { - const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; - const { newEmail, code } = req.body || {}; - if (!apiKey || !newEmail || !code) { - res.status(400).json({ error: "API key, new email, and code are required." }); - return; - } - const cleanEmail = newEmail.trim().toLowerCase(); - const cleanCode = String(code).trim(); - const keys = getAllKeys(); - const userKey = keys.find((k) => k.key === apiKey); - if (!userKey) { - res.status(401).json({ error: "Invalid API key." }); - return; - } - const result = await verifyCode(cleanEmail, cleanCode); - switch (result.status) { - case "ok": { - const updated = await updateKeyEmail(apiKey, cleanEmail); - if (updated) { - res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail }); - } - else { - res.status(500).json({ error: "Failed to update email." }); - } - break; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +async function validateApiKey(apiKey) { + const result = await queryWithRetry(`SELECT key, email, tier FROM api_keys WHERE key = $1`, [apiKey]); + return result.rows[0] || null; +} +/** + * @openapi + * /v1/email-change: + * post: + * tags: [Account] + * summary: Request email change + * description: | + * Sends a 6-digit verification code to the new email address. + * Rate limited to 3 requests per hour per API key. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * responses: + * 200: + * description: Verification code sent + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verification_sent + * message: + * type: string + * 400: + * description: Missing or invalid fields + * 403: + * description: Invalid API key + * 409: + * description: Email already taken + * 429: + * description: Too many attempts + */ +router.post("/", emailChangeLimiter, async (req, res) => { + try { + const { apiKey, newEmail } = req.body || {}; + if (!apiKey || typeof apiKey !== "string") { + res.status(400).json({ error: "apiKey is required." }); + return; } - case "expired": - res.status(410).json({ error: "Verification code has expired. Please request a new one." }); - break; - case "max_attempts": - res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); - break; - case "invalid": - res.status(400).json({ error: "Invalid verification code." }); - break; + if (!newEmail || typeof newEmail !== "string") { + res.status(400).json({ error: "newEmail is required." }); + return; + } + const cleanEmail = newEmail.trim().toLowerCase(); + if (!EMAIL_RE.test(cleanEmail)) { + res.status(400).json({ error: "Invalid email format." }); + return; + } + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + // Check if email is already taken by another key + const existing = await queryWithRetry(`SELECT key FROM api_keys WHERE email = $1 AND key != $2`, [cleanEmail, apiKey]); + if (existing.rows.length > 0) { + res.status(409).json({ error: "This email is already associated with another account." }); + return; + } + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); + }); + res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." }); + } + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change"); + res.status(500).json({ error: "Internal server error" }); + } +}); +/** + * @openapi + * /v1/email-change/verify: + * post: + * tags: [Account] + * summary: Verify email change code + * description: Verifies the 6-digit code and updates the account email. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail, code] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * responses: + * 200: + * description: Email updated + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * newEmail: + * type: string + * 400: + * description: Missing fields or invalid code + * 403: + * description: Invalid API key + * 410: + * description: Code expired + * 429: + * description: Too many failed attempts + */ +router.post("/verify", async (req, res) => { + try { + const { apiKey, newEmail, code } = req.body || {}; + if (!apiKey || !newEmail || !code) { + res.status(400).json({ error: "apiKey, newEmail, and code are required." }); + return; + } + const cleanEmail = newEmail.trim().toLowerCase(); + const cleanCode = String(code).trim(); + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]); + logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed"); + res.json({ status: "ok", newEmail: cleanEmail }); + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } + } + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change/verify"); + res.status(500).json({ error: "Internal server error" }); } }); export { router as emailChangeRouter }; diff --git a/dist/routes/health.js b/dist/routes/health.js index 700dd4b..da35b67 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -5,33 +5,92 @@ import { pool } from "../services/db.js"; const require = createRequire(import.meta.url); const { version: APP_VERSION } = require("../../package.json"); export const healthRouter = Router(); +const HEALTH_CHECK_TIMEOUT_MS = 3000; +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Health check + * description: Returns service health status including database connectivity and browser pool stats. + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [ok, degraded] + * version: + * type: string + * example: '0.4.0' + * database: + * type: object + * properties: + * status: + * type: string + * enum: [ok, error] + * version: + * type: string + * example: 'PostgreSQL 17.4' + * pool: + * type: object + * properties: + * size: + * type: integer + * active: + * type: integer + * available: + * type: integer + * queueDepth: + * type: integer + * pdfCount: + * type: integer + * restarting: + * type: boolean + * uptimeSeconds: + * type: integer + * 503: + * description: Service is degraded (database issue) + */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); let databaseStatus; let overallStatus = "ok"; let httpStatus = 200; - // Check database connectivity + // Check database connectivity with a real query and timeout try { - const client = await pool.connect(); - try { - const result = await client.query('SELECT version()'); - const version = result.rows[0]?.version || 'Unknown'; - // Extract just the PostgreSQL version number (e.g., "PostgreSQL 15.4") - const versionMatch = version.match(/PostgreSQL ([\d.]+)/); - const shortVersion = versionMatch ? `PostgreSQL ${versionMatch[1]}` : 'PostgreSQL'; - databaseStatus = { - status: "ok", - version: shortVersion - }; - } - finally { - client.release(); - } + const dbCheck = async () => { + const client = await pool.connect(); + try { + // Use SELECT 1 as a lightweight liveness probe + await client.query('SELECT 1'); + const result = await client.query('SELECT version()'); + const version = result.rows[0]?.version || 'Unknown'; + const versionMatch = version.match(/PostgreSQL ([\d.]+)/); + const shortVersion = versionMatch ? `PostgreSQL ${versionMatch[1]}` : 'PostgreSQL'; + client.release(); + return { status: "ok", version: shortVersion }; + } + catch (queryErr) { + // Destroy the bad connection so it doesn't go back to the pool + try { + client.release(true); + } + catch (_) { } + throw queryErr; + } + }; + const timeout = new Promise((_resolve, reject) => setTimeout(() => reject(new Error("Database health check timed out")), HEALTH_CHECK_TIMEOUT_MS)); + databaseStatus = await Promise.race([dbCheck(), timeout]); } catch (error) { databaseStatus = { status: "error", - message: error.message || "Database connection failed" + message: error instanceof Error ? error.message : "Database connection failed" }; overallStatus = "degraded"; httpStatus = 503; diff --git a/dist/routes/recover.js b/dist/routes/recover.js index cf8bc9f..bac89b5 100644 --- a/dist/routes/recover.js +++ b/dist/routes/recover.js @@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit"; import { createPendingVerification, verifyCode } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; import { getAllKeys } from "../services/keys.js"; +import { queryWithRetry } from "../services/db.js"; import logger from "../services/logger.js"; const router = Router(); const recoverLimiter = rateLimit({ @@ -12,63 +13,187 @@ const recoverLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, }); +/** + * @openapi + * /v1/recover: + * post: + * tags: [Account] + * summary: Request API key recovery + * description: | + * Sends a 6-digit verification code to the email address if an account exists. + * Response is always the same regardless of whether the email exists (to prevent enumeration). + * Rate limited to 3 requests per hour. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Email address associated with the API key + * responses: + * 200: + * description: Recovery code sent (or no-op if email not found) + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovery_sent + * message: + * type: string + * 400: + * description: Invalid email format + * 429: + * description: Too many recovery attempts + */ router.post("/", recoverLimiter, async (req, res) => { - const { email } = req.body || {}; - if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - res.status(400).json({ error: "A valid email address is required." }); - return; - } - const cleanEmail = email.trim().toLowerCase(); - const keys = getAllKeys(); - const userKey = keys.find(k => k.email === cleanEmail); - if (!userKey) { - res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); - return; - } - const pending = await createPendingVerification(cleanEmail); - sendVerificationEmail(cleanEmail, pending.code).catch(err => { - logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); - }); - res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); -}); -router.post("/verify", recoverLimiter, async (req, res) => { - const { email, code } = req.body || {}; - if (!email || !code) { - res.status(400).json({ error: "Email and code are required." }); - return; - } - const cleanEmail = email.trim().toLowerCase(); - const cleanCode = String(code).trim(); - const result = await verifyCode(cleanEmail, cleanCode); - switch (result.status) { - case "ok": { - const keys = getAllKeys(); - const userKey = keys.find(k => k.email === cleanEmail); - if (userKey) { - res.json({ - status: "recovered", - apiKey: userKey.key, - tier: userKey.tier, - message: "Your API key has been recovered. Save it securely — it is shown only once.", - }); - } - else { - res.json({ - status: "recovered", - message: "No API key found for this email.", - }); - } - break; + try { + const { email } = req.body || {}; + if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "A valid email address is required." }); + return; } - case "expired": - res.status(410).json({ error: "Verification code has expired. Please request a new one." }); - break; - case "max_attempts": - res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); - break; - case "invalid": - res.status(400).json({ error: "Invalid verification code." }); - break; + const cleanEmail = email.trim().toLowerCase(); + const keys = getAllKeys(); + const userKey = keys.find(k => k.email === cleanEmail); + if (!userKey) { + // DB fallback: cache may be stale in multi-replica setups + const dbResult = await queryWithRetry("SELECT key FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]); + if (dbResult.rows.length > 0) { + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); + }); + logger.info({ email: cleanEmail }, "recover: cache miss, sent recovery via DB fallback"); + } + res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); + return; + } + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); + }); + res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); + } + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover"); + res.status(500).json({ error: "Internal server error" }); + } +}); +/** + * @openapi + * /v1/recover/verify: + * post: + * tags: [Account] + * summary: Verify recovery code and retrieve API key + * description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * description: 6-digit verification code + * responses: + * 200: + * description: API key recovered + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovered + * apiKey: + * type: string + * description: The recovered API key + * tier: + * type: string + * enum: [free, pro] + * 400: + * description: Invalid verification code or missing fields + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ +router.post("/verify", recoverLimiter, async (req, res) => { + try { + const { email, code } = req.body || {}; + if (!email || !code) { + res.status(400).json({ error: "Email and code are required." }); + return; + } + const cleanEmail = email.trim().toLowerCase(); + const cleanCode = String(code).trim(); + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + const keys = getAllKeys(); + let userKey = keys.find(k => k.email === cleanEmail); + // DB fallback: cache may be stale in multi-replica setups + if (!userKey) { + logger.info({ email: cleanEmail }, "recover verify: cache miss, falling back to DB"); + const dbResult = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]); + if (dbResult.rows.length > 0) { + const row = dbResult.rows[0]; + userKey = { + key: row.key, + tier: row.tier, + email: row.email, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + stripeCustomerId: row.stripe_customer_id || undefined, + }; + } + } + if (userKey) { + res.json({ + status: "recovered", + apiKey: userKey.key, + tier: userKey.tier, + message: "Your API key has been recovered. Save it securely — it is shown only once.", + }); + } + else { + res.json({ + status: "recovered", + message: "No API key found for this email.", + }); + } + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } + } + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover/verify"); + res.status(500).json({ error: "Internal server error" }); } }); export { router as recoverRouter }; diff --git a/dist/routes/signup.js b/dist/routes/signup.js index bfa34df..f96f39a 100644 --- a/dist/routes/signup.js +++ b/dist/routes/signup.js @@ -51,6 +51,61 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => { message: "Check your email for the verification code.", }); }); +/** + * @openapi + * /v1/signup/verify: + * post: + * tags: [Account] + * summary: Verify email and get API key (discontinued) + * deprecated: true + * description: | + * **Discontinued.** Free accounts are no longer available. Try the demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev. + * Rate limited to 15 attempts per 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * description: Email address used during signup + * example: user@example.com + * code: + * type: string + * description: 6-digit verification code from email + * example: "123456" + * responses: + * 200: + * description: Email verified, API key issued + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verified + * message: + * type: string + * apiKey: + * type: string + * description: The provisioned API key + * tier: + * type: string + * example: free + * 400: + * description: Missing fields or invalid verification code + * 409: + * description: Email already verified + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ // Step 2: Verify code — creates API key router.post("/verify", verifyLimiter, async (req, res) => { const { email, code } = req.body || {}; diff --git a/dist/routes/templates.js b/dist/routes/templates.js index 5957e83..6e481fd 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -2,11 +2,56 @@ import { Router } from "express"; import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; -function sanitizeFilename(name) { - return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); -} +import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; export const templatesRouter = Router(); -// GET /v1/templates — list available templates +/** + * @openapi + * /v1/templates: + * get: + * tags: [Templates] + * summary: List available templates + * description: Returns a list of all built-in document templates with their required fields. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * responses: + * 200: + * description: List of templates + * content: + * application/json: + * schema: + * type: object + * properties: + * templates: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: invoice + * name: + * type: string + * example: Invoice + * description: + * type: string + * fields: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * required: + * type: boolean + * description: + * type: string + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + */ templatesRouter.get("/", (_req, res) => { const list = Object.entries(templates).map(([id, t]) => ({ id, @@ -16,7 +61,71 @@ templatesRouter.get("/", (_req, res) => { })); res.json({ templates: list }); }); -// POST /v1/templates/:id/render — render template to PDF +/** + * @openapi + * /v1/templates/{id}/render: + * post: + * tags: [Templates] + * summary: Render a template to PDF + * description: | + * Renders a built-in template with the provided data and returns a PDF. + * Use GET /v1/templates to see available templates and their required fields. + * Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename). + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Template ID (e.g. "invoice", "receipt") + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * description: Template data (fields depend on template). Can also be passed at root level. + * _format: + * type: string + * enum: [A4, Letter, Legal, A3, A5, Tabloid] + * default: A4 + * description: Page size override + * _margin: + * type: object + * properties: + * top: { type: string } + * right: { type: string } + * bottom: { type: string } + * left: { type: string } + * description: Page margin override + * _filename: + * type: string + * description: Custom output filename + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing required template fields + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 404: + * description: Template not found + * 500: + * description: Template rendering failed + */ templatesRouter.post("/:id/render", async (req, res) => { try { const id = req.params.id; @@ -38,11 +147,20 @@ templatesRouter.post("/:id/render", async (req, res) => { }); return; } + // Validate PDF options from underscore-prefixed fields (BUG-103) + const pdfOpts = {}; + if (data._format !== undefined) + pdfOpts.format = data._format; + if (data._margin !== undefined) + pdfOpts.margin = data._margin; + const validation = validatePdfOptions(pdfOpts); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + const sanitizedPdf = { format: "A4", ...validation.sanitized }; const html = renderTemplate(id, data); - const pdf = await renderPdf(html, { - format: data._format || "A4", - margin: data._margin, - }); + const { pdf, durationMs } = await renderPdf(html, sanitizedPdf); const filename = sanitizeFilename(data._filename || `${id}.pdf`); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); @@ -50,6 +168,6 @@ templatesRouter.post("/:id/render", async (req, res) => { } catch (err) { logger.error({ err }, "Template render error"); - res.status(500).json({ error: "Template rendering failed", detail: err.message }); + res.status(500).json({ error: "Template rendering failed" }); } }); diff --git a/dist/services/browser.js b/dist/services/browser.js index 2ec7521..1776857 100644 --- a/dist/services/browser.js +++ b/dist/services/browser.js @@ -27,11 +27,14 @@ export function getPoolStats() { })), }; } -async function recyclePage(page) { +export async function recyclePage(page) { try { const client = await page.createCDPSession(); await client.send("Network.clearBrowserCache").catch(() => { }); await client.detach().catch(() => { }); + // Clean up request interception (set by renderUrlPdf for SSRF protection) + page.removeAllListeners("request"); + await page.setRequestInterception(false).catch(() => { }); const cookies = await page.cookies(); if (cookies.length > 0) { await page.deleteCookie(...cookies); @@ -193,28 +196,52 @@ export async function closeBrowser() { } instances.length = 0; } +/** Build a Puppeteer-compatible PDFOptions object from user-supplied render options. */ +export function buildPdfOptions(options) { + const result = { + format: options.format || "A4", + landscape: options.landscape || false, + printBackground: options.printBackground !== false, + margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, + }; + if (options.headerTemplate !== undefined) + result.headerTemplate = options.headerTemplate; + if (options.footerTemplate !== undefined) + result.footerTemplate = options.footerTemplate; + if (options.displayHeaderFooter !== undefined) + result.displayHeaderFooter = options.displayHeaderFooter; + if (options.scale !== undefined) + result.scale = options.scale; + if (options.pageRanges) + result.pageRanges = options.pageRanges; + if (options.preferCSSPageSize !== undefined) + result.preferCSSPageSize = options.preferCSSPageSize; + if (options.width) + result.width = options.width; + if (options.height) + result.height = options.height; + return result; +} export async function renderPdf(html, options = {}) { const { page, instance } = await acquirePage(); try { await page.setJavaScriptEnabled(false); + const startTime = Date.now(); + let timeoutId; const result = await Promise.race([ (async () => { await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" }); - const pdf = await page.pdf({ - format: options.format || "A4", - landscape: options.landscape || false, - printBackground: options.printBackground !== false, - margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, - headerTemplate: options.headerTemplate, - footerTemplate: options.footerTemplate, - displayHeaderFooter: options.displayHeaderFooter || false, - }); + const pdf = await page.pdf(buildPdfOptions(options)); return Buffer.from(pdf); })(), - new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), - ]); - return result; + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000); + }), + ]).finally(() => clearTimeout(timeoutId)); + const durationMs = Date.now() - startTime; + logger.info(`PDF rendered in ${durationMs}ms (html, ${result.length} bytes)`); + return { pdf: result, durationMs }; } finally { releasePage(page, instance); @@ -259,23 +286,24 @@ export async function renderUrlPdf(url, options = {}) { }); } } + const startTime = Date.now(); + let timeoutId; const result = await Promise.race([ (async () => { await page.goto(url, { waitUntil: options.waitUntil || "domcontentloaded", timeout: 30_000, }); - const pdf = await page.pdf({ - format: options.format || "A4", - landscape: options.landscape || false, - printBackground: options.printBackground !== false, - margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, - }); + const pdf = await page.pdf(buildPdfOptions(options)); return Buffer.from(pdf); })(), - new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), - ]); - return result; + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000); + }), + ]).finally(() => clearTimeout(timeoutId)); + const durationMs = Date.now() - startTime; + logger.info(`PDF rendered in ${durationMs}ms (url, ${result.length} bytes)`); + return { pdf: result, durationMs }; } finally { releasePage(page, instance); diff --git a/dist/services/db.js b/dist/services/db.js index 4d4b85a..6e0627f 100644 --- a/dist/services/db.js +++ b/dist/services/db.js @@ -1,5 +1,6 @@ import pg from "pg"; import logger from "./logger.js"; +import { isTransientError, errorMessage, errorCode } from "../utils/errors.js"; const { Pool } = pg; const pool = new Pool({ host: process.env.DATABASE_HOST || "172.17.0.1", @@ -8,13 +9,98 @@ const pool = new Pool({ user: process.env.DATABASE_USER || "docfast", password: process.env.DATABASE_PASSWORD || "docfast", max: 10, - idleTimeoutMillis: 30000, + idleTimeoutMillis: 10000, // Evict idle connections after 10s (was 30s) — faster cleanup of stale sockets + connectionTimeoutMillis: 5000, // Don't wait forever for a connection + allowExitOnIdle: false, + keepAlive: true, // TCP keepalive to detect dead connections + keepAliveInitialDelayMillis: 10000, // Start keepalive probes after 10s idle }); -pool.on("error", (err) => { - logger.error({ err }, "Unexpected PostgreSQL pool error"); +// Handle errors on idle clients — pg.Pool automatically removes the client +// after emitting this event, so we just log it. +pool.on("error", (err, client) => { + logger.error({ err }, "Unexpected error on idle PostgreSQL client — evicted from pool"); }); +export { isTransientError } from "../utils/errors.js"; +/** + * Execute a query with automatic retry on transient errors. + * + * KEY FIX: On transient error, we destroy the bad connection (client.release(true)) + * so the pool creates a fresh TCP connection on the next attempt, instead of + * reusing a dead socket from the pool. + */ +export async function queryWithRetry(queryText, params, maxRetries = 3) { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + let client; + try { + client = await pool.connect(); + const result = await client.query(queryText, params); + client.release(); // Return healthy connection to pool + return result; + } + catch (err) { + // Destroy the bad connection so pool doesn't reuse it + if (client) { + try { + client.release(true); + } + catch (_) { /* already destroyed */ } + } + lastError = err; + if (!isTransientError(err) || attempt === maxRetries) { + throw err; + } + const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s (capped at 5s) + logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying..."); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + throw lastError; +} +/** + * Connect with retry — for operations that need a client (transactions). + * On transient connect errors, waits and retries so the pool can establish + * fresh connections to the new PgBouncer pod. + */ +export async function connectWithRetry(maxRetries = 3) { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const client = await pool.connect(); + // Validate the connection is actually alive + try { + await client.query("SELECT 1"); + } + catch (validationErr) { + // Connection is dead — destroy it and retry + try { + client.release(true); + } + catch (_) { } + if (!isTransientError(validationErr) || attempt === maxRetries) { + throw validationErr; + } + const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); + logger.warn({ err: errorMessage(validationErr), code: errorCode(validationErr), attempt: attempt + 1 }, "Connection validation failed, destroying and retrying..."); + await new Promise(resolve => setTimeout(resolve, delayMs)); + continue; + } + return client; + } + catch (err) { + lastError = err; + if (!isTransientError(err) || attempt === maxRetries) { + throw err; + } + const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); + logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying..."); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + throw lastError; +} export async function initDatabase() { - const client = await pool.connect(); + const client = await connectWithRetry(); try { await client.query(` CREATE TABLE IF NOT EXISTS api_keys ( @@ -26,6 +112,8 @@ export async function initDatabase() { ); CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email); CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_stripe_unique + ON api_keys(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; CREATE TABLE IF NOT EXISTS verifications ( id SERIAL PRIMARY KEY, @@ -58,5 +146,26 @@ export async function initDatabase() { client.release(); } } +/** + * Clean up stale database entries: + * - Expired pending verifications + * - Unverified free-tier API keys (never completed verification) + * - Orphaned usage rows (key no longer exists) + */ +export async function cleanupStaleData() { + const results = { expiredVerifications: 0, orphanedUsage: 0 }; + // 1. Delete expired pending verifications + const pv = await queryWithRetry("DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email"); + results.expiredVerifications = pv.rowCount || 0; + // 2. Delete orphaned usage rows (key no longer exists in api_keys) + const ou = await queryWithRetry(` + DELETE FROM usage + WHERE key NOT IN (SELECT key FROM api_keys) + RETURNING key + `); + results.orphanedUsage = ou.rowCount || 0; + logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.orphanedUsage} orphaned usage rows removed`); + return results; +} export { pool }; export default pool; diff --git a/dist/services/email.js b/dist/services/email.js index ce66697..3fcc21d 100644 --- a/dist/services/email.js +++ b/dist/services/email.js @@ -14,10 +14,8 @@ const transportConfig = { greetingTimeout: 5000, socketTimeout: 10000, tls: { rejectUnauthorized: false }, + ...(smtpUser && smtpPass ? { auth: { user: smtpUser, pass: smtpPass } } : {}), }; -if (smtpUser && smtpPass) { - transportConfig.auth = { user: smtpUser, pass: smtpPass }; -} const transporter = nodemailer.createTransport(transportConfig); export async function sendVerificationEmail(email, code) { try { @@ -25,7 +23,34 @@ export async function sendVerificationEmail(email, code) { from: smtpFrom, to: email, subject: "DocFast - Verify your email", - text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`, + text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.\n\n---\nDocFast — HTML to PDF API\nhttps://docfast.dev`, + html: ` + + + +
+ + + + + + + +
+

DocFast

+
+

Your verification code

+
+
${code}
+
+

This code expires in 15 minutes.

+
+

If you didn't request this, ignore this email.

+
+

DocFast — HTML to PDF API
docfast.dev

+
+
+`, }); logger.info({ email, messageId: info.messageId }, "Verification email sent"); return true; diff --git a/dist/services/keys.js b/dist/services/keys.js index 6b751fc..87736f1 100644 --- a/dist/services/keys.js +++ b/dist/services/keys.js @@ -1,11 +1,25 @@ import { randomBytes } from "crypto"; import logger from "./logger.js"; -import pool from "./db.js"; +import { queryWithRetry } from "./db.js"; // In-memory cache for fast lookups, synced with PostgreSQL let keysCache = []; +/** Look up a key row in the DB by a given column. Returns null if not found. */ +export async function findKeyInCacheOrDb(column, value) { + const result = await queryWithRetry(`SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE ${column} = $1 LIMIT 1`, [value]); + if (result.rows.length === 0) + return null; + const r = result.rows[0]; + return { + key: r.key, + tier: r.tier, + email: r.email, + createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, + stripeCustomerId: r.stripe_customer_id || undefined, + }; +} export async function loadKeys() { try { - const result = await pool.query("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys"); + const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys"); keysCache = result.rows.map((r) => ({ key: r.key, tier: r.tier, @@ -25,7 +39,7 @@ export async function loadKeys() { const entry = { key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() }; keysCache.push(entry); // Upsert into DB - await pool.query(`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4) + await queryWithRetry(`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (key) DO NOTHING`, [k, "pro", "seed@docfast.dev", new Date().toISOString()]).catch(() => { }); } } @@ -55,53 +69,107 @@ export async function createFreeKey(email) { email: email || "", createdAt: new Date().toISOString(), }; - await pool.query("INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)", [entry.key, entry.tier, entry.email, entry.createdAt]); + await queryWithRetry("INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)", [entry.key, entry.tier, entry.email, entry.createdAt]); keysCache.push(entry); return entry; } export async function createProKey(email, stripeCustomerId) { + // Check in-memory cache first (fast path) const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId); if (existing) { existing.tier = "pro"; - await pool.query("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]); + await queryWithRetry("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]); return existing; } + // UPSERT: handles duplicate webhooks across pods via DB unique index + const newKey = generateKey("df_pro"); + const now = new Date().toISOString(); + const result = await queryWithRetry(`INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL + DO UPDATE SET tier = 'pro' + RETURNING key, tier, email, created_at, stripe_customer_id`, [newKey, "pro", email, now, stripeCustomerId]); + const row = result.rows[0]; const entry = { - key: generateKey("df_pro"), - tier: "pro", - email, - createdAt: new Date().toISOString(), - stripeCustomerId, + key: row.key, + tier: row.tier, + email: row.email, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + stripeCustomerId: row.stripe_customer_id || undefined, }; - await pool.query("INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)", [entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]); - keysCache.push(entry); + // Refresh in-memory cache + const cacheIdx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId); + if (cacheIdx >= 0) { + keysCache[cacheIdx] = entry; + } + else { + keysCache.push(entry); + } return entry; } export async function downgradeByCustomer(stripeCustomerId) { const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId); if (entry) { entry.tier = "free"; - await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); + await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); return true; } - return false; + // DB fallback: key may exist on another pod's cache or after a restart + logger.info({ stripeCustomerId }, "downgradeByCustomer: cache miss, falling back to DB"); + const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId); + if (!dbKey) { + logger.warn({ stripeCustomerId }, "downgradeByCustomer: customer not found in cache or DB"); + return false; + } + await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); + dbKey.tier = "free"; + keysCache.push(dbKey); + logger.info({ stripeCustomerId, key: dbKey.key }, "downgradeByCustomer: downgraded via DB fallback"); + return true; +} +export async function findKeyByCustomerId(stripeCustomerId) { + return findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId); } export function getAllKeys() { return [...keysCache]; } export async function updateKeyEmail(apiKey, newEmail) { const entry = keysCache.find((k) => k.key === apiKey); - if (!entry) + if (entry) { + entry.email = newEmail; + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + return true; + } + // DB fallback: key may exist on another pod's cache or after a restart + logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB"); + const dbKey = await findKeyInCacheOrDb("key", apiKey); + if (!dbKey) { + logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB"); return false; - entry.email = newEmail; - await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + } + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + dbKey.email = newEmail; + keysCache.push(dbKey); + logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback"); return true; } export async function updateEmailByCustomer(stripeCustomerId, newEmail) { const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId); - if (!entry) + if (entry) { + entry.email = newEmail; + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + return true; + } + // DB fallback: key may exist on another pod's cache or after a restart + logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB"); + const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId); + if (!dbKey) { + logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB"); return false; - entry.email = newEmail; - await pool.query("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + } + await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + dbKey.email = newEmail; + keysCache.push(dbKey); + logger.info({ stripeCustomerId, key: dbKey.key }, "updateEmailByCustomer: updated via DB fallback"); return true; } diff --git a/dist/services/templates.js b/dist/services/templates.js index 585387e..c1376bd 100644 --- a/dist/services/templates.js +++ b/dist/services/templates.js @@ -35,7 +35,8 @@ function esc(s) { .replace(/&/g, "&") .replace(//g, ">") - .replace(/"/g, """); + .replace(/"/g, """) + .replace(/'/g, "'"); } function renderInvoice(d) { const cur = esc(d.currency || "€"); diff --git a/dist/services/verification.js b/dist/services/verification.js index 92e36d8..f7e0237 100644 --- a/dist/services/verification.js +++ b/dist/services/verification.js @@ -1,66 +1,9 @@ -import { randomBytes, randomInt, timingSafeEqual } from "crypto"; -import logger from "./logger.js"; -import pool from "./db.js"; -const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; +import { randomInt, timingSafeEqual } from "crypto"; +import { queryWithRetry } from "./db.js"; const CODE_EXPIRY_MS = 15 * 60 * 1000; const MAX_ATTEMPTS = 3; -export async function createVerification(email, apiKey) { - // Check for existing unexpired, unverified - const existing = await pool.query("SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email]); - if (existing.rows.length > 0) { - const r = existing.rows[0]; - return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null }; - } - // Remove old unverified - await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]); - const token = randomBytes(32).toString("hex"); - const now = new Date().toISOString(); - await pool.query("INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now]); - return { email, token, apiKey, createdAt: now, verifiedAt: null }; -} -export function verifyToken(token) { - // Synchronous wrapper — we'll make it async-compatible - // Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor. - // For simplicity, we'll cache verifications in memory too. - return verifyTokenSync(token); -} -// In-memory cache for verifications (loaded on startup, updated on changes) -let verificationsCache = []; -export async function loadVerifications() { - const result = await pool.query("SELECT * FROM verifications"); - verificationsCache = result.rows.map((r) => ({ - email: r.email, - token: r.token, - apiKey: r.api_key, - createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, - verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null, - })); - // Cleanup expired entries every 15 minutes - setInterval(() => { - const cutoff = Date.now() - 24 * 60 * 60 * 1000; - const before = verificationsCache.length; - verificationsCache = verificationsCache.filter((v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff); - const removed = before - verificationsCache.length; - if (removed > 0) - logger.info({ removed }, "Cleaned expired verification cache entries"); - }, 15 * 60 * 1000); -} -function verifyTokenSync(token) { - const v = verificationsCache.find((v) => v.token === token); - if (!v) - return { status: "invalid" }; - if (v.verifiedAt) - return { status: "already_verified", verification: v }; - const age = Date.now() - new Date(v.createdAt).getTime(); - if (age > TOKEN_EXPIRY_MS) - return { status: "expired" }; - v.verifiedAt = new Date().toISOString(); - // Update DB async - pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification")); - return { status: "ok", verification: v }; -} export async function createPendingVerification(email) { - await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]); + await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [email]); const now = new Date(); const pending = { email, @@ -69,38 +12,30 @@ export async function createPendingVerification(email) { expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(), attempts: 0, }; - await pool.query("INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)", [pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]); + await queryWithRetry("INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)", [pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]); return pending; } export async function verifyCode(email, code) { const cleanEmail = email.trim().toLowerCase(); - const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]); + const result = await queryWithRetry("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]); const pending = result.rows[0]; if (!pending) return { status: "invalid" }; if (new Date() > new Date(pending.expires_at)) { - await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "expired" }; } if (pending.attempts >= MAX_ATTEMPTS) { - await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "max_attempts" }; } - await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]); + await queryWithRetry("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]); const a = Buffer.from(pending.code, "utf8"); const b = Buffer.from(code, "utf8"); const codeMatch = a.length === b.length && timingSafeEqual(a, b); if (!codeMatch) { return { status: "invalid" }; } - await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "ok" }; } -export async function isEmailVerified(email) { - const result = await pool.query("SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]); - return result.rows.length > 0; -} -export async function getVerifiedApiKey(email) { - const result = await pool.query("SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]); - return result.rows[0]?.api_key ?? null; -} diff --git a/package-lock.json b/package-lock.json index f9dad1c..6452b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,36 +1,85 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docfast-api", - "version": "0.2.1", + "version": "0.5.2", "dependencies": { "compression": "^1.8.1", - "express": "^4.21.0", - "express-rate-limit": "^7.5.0", - "helmet": "^8.0.0", - "marked": "^15.0.0", - "nanoid": "^5.0.0", - "nodemailer": "^8.0.1", - "pg": "^8.13.0", + "express": "^5.1.0", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", + "marked": "^17.0.4", + "nanoid": "^5.1.6", + "nodemailer": "^8.0.2", + "pg": "^8.20.0", "pino": "^10.3.1", - "puppeteer": "^24.0.0", - "stripe": "^20.3.1", - "swagger-ui-dist": "^5.31.0" + "puppeteer": "^24.39.1", + "stripe": "^20.4.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-dist": "^5.32.0" }, "devDependencies": { "@types/compression": "^1.8.1", - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", - "@types/nodemailer": "^7.0.9", - "@types/pg": "^8.11.0", + "@types/express": "^5.0.6", + "@types/node": "^25.5.0", + "@types/nodemailer": "^7.0.11", + "@types/pg": "^8.18.0", + "@types/supertest": "^7.2.0", + "@types/swagger-jsdoc": "^6.0.4", + "@vitest/coverage-v8": "^4.1.0", + "supertest": "^7.2.2", "terser": "^5.46.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" } }, "node_modules/@babel/code-frame": { @@ -47,6 +96,16 @@ "node": ">=6.9.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", @@ -56,6 +115,80 @@ "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/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/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -548,15 +681,82 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" }, "node_modules/@puppeteer/browsers": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz", - "integrity": "sha512-fXa6uXLxfslBlus3MEpW8S6S9fe5RwmAE5Gd8u3krqOwnkZJV3/lQJiY3LaFdTctLLqJtyMgEUGkbDnRNf6vbQ==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -597,24 +797,10 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], @@ -623,12 +809,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], @@ -637,12 +826,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], @@ -651,26 +843,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], @@ -679,12 +860,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], @@ -693,26 +877,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], @@ -721,12 +894,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], @@ -735,40 +911,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", "cpu": [ "ppc64" ], @@ -777,54 +928,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", "cpu": [ "s390x" ], @@ -833,12 +945,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ "x64" ], @@ -847,12 +962,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], @@ -861,26 +979,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ "arm64" ], @@ -889,12 +996,32 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], @@ -903,26 +1030,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], @@ -931,27 +1047,31 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", @@ -959,6 +1079,17 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "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/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -986,6 +1117,7 @@ "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*", "@types/node": "*" @@ -1001,6 +1133,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1047,29 +1186,43 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1113,6 +1266,37 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -1123,87 +1307,92 @@ "@types/node": "*" } }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" }, "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { + "@vitest/browser": { "optional": true } } }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1211,50 +1400,72 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "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": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, + "node_modules/accepts/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/accepts/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/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1303,10 +1514,11 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, "license": "MIT" }, "node_modules/assertion-error": { @@ -1331,18 +1543,45 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/b4a": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", - "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1353,6 +1592,12 @@ } } }, + "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/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", @@ -1368,11 +1613,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -1393,11 +1637,10 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -1407,19 +1650,18 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "streamx": "^2.21.0", + "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", @@ -1439,42 +1681,74 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } }, "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/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/body-parser/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/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/buffer-crc32": { @@ -1502,16 +1776,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "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", @@ -1541,6 +1805,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1551,32 +1821,15 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", @@ -1622,17 +1875,43 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" }, @@ -1644,6 +1923,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", @@ -1657,24 +1937,23 @@ "node": ">= 0.8.0" } }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "engines": { - "node": ">= 0.6" - } + "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": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -1686,6 +1965,13 @@ "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", @@ -1696,15 +1982,25 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "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/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -1745,16 +2041,6 @@ "ms": "2.0.0" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -1769,6 +2055,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1778,22 +2074,45 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", + "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==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=8" } }, "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1875,9 +2194,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1893,6 +2212,22 @@ "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/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2041,45 +2376,42 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "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": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -2087,10 +2419,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, "engines": { "node": ">= 16" }, @@ -2101,6 +2436,45 @@ "express": ">= 4.11" } }, + "node_modules/express/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/express/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/express/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/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2150,14 +2524,12 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", @@ -2178,21 +2550,82 @@ } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "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": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/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/finalhandler/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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/forwarded": { @@ -2205,14 +2638,20 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "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.6" + "node": ">= 0.8" } }, + "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.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2348,6 +2787,27 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "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.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2360,6 +2820,16 @@ "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-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2372,6 +2842,22 @@ "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", @@ -2393,6 +2879,13 @@ "node": ">=18.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2486,15 +2979,19 @@ "license": "MIT" }, "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==", + "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" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/import-fresh": { @@ -2513,6 +3010,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -2552,6 +3060,51 @@ "node": ">=8" } }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2576,17 +3129,291 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "license": "MIT" }, "node_modules/lru-cache": { @@ -2608,16 +3435,44 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -2630,19 +3485,22 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "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.6" + "node": ">= 0.8" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "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" } @@ -2651,27 +3509,16 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "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" @@ -2681,6 +3528,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -2689,6 +3537,28 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2702,9 +3572,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", "funding": [ { "type": "github", @@ -2720,9 +3590,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2738,9 +3608,10 @@ } }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz", + "integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } @@ -2757,10 +3628,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2781,6 +3664,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2794,6 +3678,13 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -2888,11 +3779,24 @@ "node": ">= 0.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-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -2901,16 +3805,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -2918,14 +3812,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -2952,9 +3846,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -2967,18 +3861,18 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "license": "MIT" }, "node_modules/pg-types": { @@ -3029,6 +3923,7 @@ "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -3050,6 +3945,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", "dependencies": { "split2": "^4.0.0" } @@ -3057,12 +3953,13 @@ "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3159,7 +4056,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -3232,9 +4130,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "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", @@ -3242,18 +4140,18 @@ } }, "node_modules/puppeteer": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.3.tgz", - "integrity": "sha512-AUGGWq0BhPM+IOS2U9A+ZREH3HDFkV1Y5HERYGDg5cbGXjoGsTCT7/A6VZRfNU0UJJdCclyEimZICkZW6pqJyw==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz", + "integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.3", - "typed-query-selector": "^2.12.0" + "devtools-protocol": "0.0.1581282", + "puppeteer-core": "24.39.1", + "typed-query-selector": "^2.12.1" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" @@ -3263,16 +4161,16 @@ } }, "node_modules/puppeteer-core": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.3.tgz", - "integrity": "sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg==", + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz", + "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" }, @@ -3321,7 +4219,8 @@ "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" }, "node_modules/range-parser": { "version": "1.2.1", @@ -3333,24 +4232,25 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "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.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", "engines": { "node": ">= 12.13.0" } @@ -3383,51 +4283,79 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, + "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/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/router/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/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3452,6 +4380,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", "engines": { "node": ">=10" } @@ -3475,27 +4404,62 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "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": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/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/send/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/send/node_modules/ms": { @@ -3505,18 +4469,22 @@ "license": "MIT" }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -3669,6 +4637,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } @@ -3730,9 +4699,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -3773,30 +4742,10 @@ "node": ">=8" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stripe": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", - "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", "license": "MIT", "engines": { "node": ">=16" @@ -3810,18 +4759,138 @@ } } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "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/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "version": "5.32.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz", + "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==", + "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -3833,20 +4902,30 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3862,10 +4941,17 @@ "node": ">=10" } }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-decoder": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", - "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3875,6 +4961,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", "dependencies": { "real-require": "^0.2.0" }, @@ -3890,11 +4977,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -3913,30 +5003,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3979,22 +5049,39 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" } }, + "node_modules/type-is/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/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", "license": "MIT" }, "node_modules/typescript": { @@ -4012,9 +5099,9 @@ } }, "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==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "devOptional": true, "license": "MIT" }, @@ -4027,13 +5114,13 @@ "node": ">= 0.8" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", "license": "MIT", "engines": { - "node": ">= 0.4.0" + "node": ">= 0.10" } }, "node_modules/vary": { @@ -4045,18 +5132,127 @@ "node": ">= 0.8" } }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "bin": { @@ -4073,9 +5269,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4088,15 +5285,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -4120,152 +5320,24 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, + "license": "ISC", + "optional": true, + "peer": true, "bin": { - "vite-node": "vite-node.mjs" + "yaml": "bin.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": ">= 14.6" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/vite-node/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vite-node/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==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vitest/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==", - "dev": true, - "license": "MIT" - }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", @@ -4351,6 +5423,15 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -4379,13 +5460,46 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz", + "integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==", "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" } }, "node_modules/zod": { diff --git a/package.json b/package.json index 0616cf2..78a4fe4 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,48 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.5.2", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { - "build:pages": "node scripts/build-pages.js && npx terser public/app.js -o public/app.min.js --compress --mangle", - "build": "npm run build:pages && tsc", + "build:pages": "node scripts/build-html.cjs", + "build": "node scripts/generate-openapi.mjs && npm run build:pages && tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", - "test": "vitest run" + "test": "vitest run", + "generate-openapi": "node scripts/generate-openapi.mjs" }, "dependencies": { "compression": "^1.8.1", - "express": "^4.21.0", - "express-rate-limit": "^7.5.0", - "helmet": "^8.0.0", - "marked": "^15.0.0", - "nanoid": "^5.0.0", - "nodemailer": "^8.0.1", - "pg": "^8.13.0", + "express": "^5.1.0", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", + "marked": "^17.0.4", + "nanoid": "^5.1.6", + "nodemailer": "^8.0.2", + "pg": "^8.20.0", "pino": "^10.3.1", - "puppeteer": "^24.0.0", - "stripe": "^20.3.1", - "swagger-ui-dist": "^5.31.0" + "puppeteer": "^24.39.1", + "stripe": "^20.4.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-dist": "^5.32.0" }, "devDependencies": { "@types/compression": "^1.8.1", - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", - "@types/nodemailer": "^7.0.9", - "@types/pg": "^8.11.0", + "@types/express": "^5.0.6", + "@types/node": "^25.5.0", + "@types/nodemailer": "^7.0.11", + "@types/pg": "^8.18.0", + "@types/supertest": "^7.2.0", + "@types/swagger-jsdoc": "^6.0.4", + "@vitest/coverage-v8": "^4.1.0", + "supertest": "^7.2.2", "terser": "^5.46.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.1.0" }, - "type": "module" + "type": "module", + "overrides": { + "yauzl": "3.2.1" + } } diff --git a/public/01307a31c610d7b99e537f814b88da44.txt b/public/01307a31c610d7b99e537f814b88da44.txt new file mode 100644 index 0000000..7e8c637 --- /dev/null +++ b/public/01307a31c610d7b99e537f814b88da44.txt @@ -0,0 +1 @@ +01307a31c610d7b99e537f814b88da44 \ No newline at end of file diff --git a/public/app.js b/public/app.js index 0f526cf..6ddc5a1 100644 --- a/public/app.js +++ b/public/app.js @@ -1,515 +1 @@ -var signupEmail = ''; -var recoverEmail = ''; - -function showState(state) { - ['signupInitial', 'signupLoading', 'signupVerify', 'signupResult'].forEach(function(id) { - var el = document.getElementById(id); - if (el) el.classList.remove('active'); - }); - document.getElementById(state).classList.add('active'); -} - -function showRecoverState(state) { - ['recoverInitial', 'recoverLoading', 'recoverVerify', 'recoverResult'].forEach(function(id) { - var el = document.getElementById(id); - if (el) el.classList.remove('active'); - }); - document.getElementById(state).classList.add('active'); -} - -function openSignup() { - document.getElementById('signupModal').classList.add('active'); - showState('signupInitial'); - document.getElementById('signupError').style.display = 'none'; - document.getElementById('verifyError').style.display = 'none'; - document.getElementById('signupEmail').value = ''; - document.getElementById('verifyCode').value = ''; - signupEmail = ''; -} - -function closeSignup() { - document.getElementById('signupModal').classList.remove('active'); -} - -function openRecover() { - closeSignup(); - document.getElementById('recoverModal').classList.add('active'); - showRecoverState('recoverInitial'); - var errEl = document.getElementById('recoverError'); - if (errEl) errEl.style.display = 'none'; - var verifyErrEl = document.getElementById('recoverVerifyError'); - if (verifyErrEl) verifyErrEl.style.display = 'none'; - document.getElementById('recoverEmailInput').value = ''; - document.getElementById('recoverCode').value = ''; - recoverEmail = ''; -} - -function closeRecover() { - document.getElementById('recoverModal').classList.remove('active'); -} - -async function submitSignup() { - var errEl = document.getElementById('signupError'); - var btn = document.getElementById('signupBtn'); - var emailInput = document.getElementById('signupEmail'); - var email = emailInput.value.trim(); - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errEl.textContent = 'Please enter a valid email address.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - showState('signupLoading'); - - try { - var res = await fetch('/v1/signup/free', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }) - }); - var data = await res.json(); - - if (!res.ok) { - showState('signupInitial'); - errEl.textContent = data.error || 'Something went wrong. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - signupEmail = email; - document.getElementById('verifyEmailDisplay').textContent = email; - showState('signupVerify'); - document.getElementById('verifyCode').focus(); - btn.disabled = false; - } catch (err) { - showState('signupInitial'); - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -async function submitVerify() { - var errEl = document.getElementById('verifyError'); - var btn = document.getElementById('verifyBtn'); - var codeInput = document.getElementById('verifyCode'); - var code = codeInput.value.trim(); - - if (!code || !/^\d{6}$/.test(code)) { - errEl.textContent = 'Please enter a 6-digit code.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - - try { - var res = await fetch('/v1/signup/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: signupEmail, code: code }) - }); - var data = await res.json(); - - if (!res.ok) { - errEl.textContent = data.error || 'Verification failed.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - document.getElementById('apiKeyText').textContent = data.apiKey; - showState('signupResult'); - } catch (err) { - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -async function submitRecover() { - var errEl = document.getElementById('recoverError'); - var btn = document.getElementById('recoverBtn'); - var emailInput = document.getElementById('recoverEmailInput'); - var email = emailInput.value.trim(); - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errEl.textContent = 'Please enter a valid email address.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - showRecoverState('recoverLoading'); - - try { - var res = await fetch('/v1/recover', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }) - }); - var data = await res.json(); - - if (!res.ok) { - showRecoverState('recoverInitial'); - errEl.textContent = data.error || 'Something went wrong.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - recoverEmail = email; - document.getElementById('recoverEmailDisplay').textContent = email; - showRecoverState('recoverVerify'); - document.getElementById('recoverCode').focus(); - btn.disabled = false; - } catch (err) { - showRecoverState('recoverInitial'); - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -async function submitRecoverVerify() { - var errEl = document.getElementById('recoverVerifyError'); - var btn = document.getElementById('recoverVerifyBtn'); - var codeInput = document.getElementById('recoverCode'); - var code = codeInput.value.trim(); - - if (!code || !/^\d{6}$/.test(code)) { - errEl.textContent = 'Please enter a 6-digit code.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - - try { - var res = await fetch('/v1/recover/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: recoverEmail, code: code }) - }); - var data = await res.json(); - - if (!res.ok) { - errEl.textContent = data.error || 'Verification failed.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - if (data.apiKey) { - document.getElementById('recoveredKeyText').textContent = data.apiKey; - showRecoverState('recoverResult'); - } else { - errEl.textContent = data.message || 'No key found for this email.'; - errEl.style.display = 'block'; - btn.disabled = false; - } - } catch (err) { - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -function copyKey() { - var key = document.getElementById('apiKeyText').textContent; - var btn = document.getElementById('copyBtn'); - doCopy(key, btn); -} - -function copyRecoveredKey() { - var key = document.getElementById('recoveredKeyText').textContent; - var btn = document.getElementById('copyRecoveredBtn'); - doCopy(key, btn); -} - -function doCopy(text, btn) { - function showCopied() { - btn.textContent = '\u2713 Copied!'; - setTimeout(function() { btn.textContent = 'Copy'; }, 2000); - } - function showFailed() { - btn.textContent = 'Failed'; - setTimeout(function() { btn.textContent = 'Copy'; }, 2000); - } - try { - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(showCopied).catch(function() { - // Fallback to execCommand - try { - var ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - ta.style.top = '-9999px'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - var success = document.execCommand('copy'); - document.body.removeChild(ta); - if (success) { - showCopied(); - } else { - showFailed(); - } - } catch (err) { - showFailed(); - } - }); - } else { - // Direct fallback for non-secure contexts - var ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - ta.style.top = '-9999px'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - var success = document.execCommand('copy'); - document.body.removeChild(ta); - if (success) { - showCopied(); - } else { - showFailed(); - } - } - } catch(e) { - showFailed(); - } -} - -async function checkout() { - try { - var res = await fetch('/v1/billing/checkout', { method: 'POST' }); - var data = await res.json(); - if (data.url) window.location.href = data.url; - else alert('Checkout is not available yet. Please try again later.'); - } catch (err) { - alert('Something went wrong. Please try again.'); - } -} - -document.addEventListener('DOMContentLoaded', function() { - document.getElementById('btn-signup').addEventListener('click', openSignup); - document.getElementById('btn-signup-2').addEventListener('click', openSignup); - document.getElementById('btn-checkout').addEventListener('click', checkout); - document.getElementById('btn-close-signup').addEventListener('click', closeSignup); - document.getElementById('signupBtn').addEventListener('click', submitSignup); - document.getElementById('verifyBtn').addEventListener('click', submitVerify); - document.getElementById('copyBtn').addEventListener('click', copyKey); - - // Recovery modal - document.getElementById('btn-close-recover').addEventListener('click', closeRecover); - document.getElementById('recoverBtn').addEventListener('click', submitRecover); - document.getElementById('recoverVerifyBtn').addEventListener('click', submitRecoverVerify); - document.getElementById('copyRecoveredBtn').addEventListener('click', copyRecoveredKey); - document.getElementById('recoverModal').addEventListener('click', function(e) { - if (e.target === this) closeRecover(); - }); - - // Open recovery from links - document.querySelectorAll('.open-recover').forEach(function(el) { - el.addEventListener('click', function(e) { e.preventDefault(); openRecover(); }); - }); - - document.getElementById('signupModal').addEventListener('click', function(e) { - if (e.target === this) closeSignup(); - }); - document.querySelectorAll('a[href^="#"]').forEach(function(a) { - a.addEventListener('click', function(e) { - e.preventDefault(); - var el = document.querySelector(this.getAttribute('href')); - if (el) el.scrollIntoView({ behavior: 'smooth' }); - }); - }); -}); - -// --- Email Change --- -var emailChangeApiKey = ''; -var emailChangeNewEmail = ''; - -function showEmailChangeState(state) { - ['emailChangeInitial', 'emailChangeLoading', 'emailChangeVerify', 'emailChangeResult'].forEach(function(id) { - var el = document.getElementById(id); - if (el) el.classList.remove('active'); - }); - document.getElementById(state).classList.add('active'); -} - -function openEmailChange() { - closeSignup(); - closeRecover(); - document.getElementById('emailChangeModal').classList.add('active'); - showEmailChangeState('emailChangeInitial'); - var errEl = document.getElementById('emailChangeError'); - if (errEl) errEl.style.display = 'none'; - var verifyErrEl = document.getElementById('emailChangeVerifyError'); - if (verifyErrEl) verifyErrEl.style.display = 'none'; - document.getElementById('emailChangeApiKey').value = ''; - document.getElementById('emailChangeNewEmail').value = ''; - document.getElementById('emailChangeCode').value = ''; - emailChangeApiKey = ''; - emailChangeNewEmail = ''; -} - -function closeEmailChange() { - document.getElementById('emailChangeModal').classList.remove('active'); -} - -async function submitEmailChange() { - var errEl = document.getElementById('emailChangeError'); - var btn = document.getElementById('emailChangeBtn'); - var apiKey = document.getElementById('emailChangeApiKey').value.trim(); - var newEmail = document.getElementById('emailChangeNewEmail').value.trim(); - - if (!apiKey) { - errEl.textContent = 'Please enter your API key.'; - errEl.style.display = 'block'; - return; - } - if (!newEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { - errEl.textContent = 'Please enter a valid email address.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - showEmailChangeState('emailChangeLoading'); - - try { - var res = await fetch('/v1/email-change', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiKey: apiKey, newEmail: newEmail }) - }); - var data = await res.json(); - - if (!res.ok) { - showEmailChangeState('emailChangeInitial'); - errEl.textContent = data.error || 'Something went wrong.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - emailChangeApiKey = apiKey; - emailChangeNewEmail = newEmail; - document.getElementById('emailChangeEmailDisplay').textContent = newEmail; - showEmailChangeState('emailChangeVerify'); - document.getElementById('emailChangeCode').focus(); - btn.disabled = false; - } catch (err) { - showEmailChangeState('emailChangeInitial'); - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -async function submitEmailChangeVerify() { - var errEl = document.getElementById('emailChangeVerifyError'); - var btn = document.getElementById('emailChangeVerifyBtn'); - var code = document.getElementById('emailChangeCode').value.trim(); - - if (!code || !/^\d{6}$/.test(code)) { - errEl.textContent = 'Please enter a 6-digit code.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - - try { - var res = await fetch('/v1/email-change/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiKey: emailChangeApiKey, newEmail: emailChangeNewEmail, code: code }) - }); - var data = await res.json(); - - if (!res.ok) { - errEl.textContent = data.error || 'Verification failed.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - document.getElementById('emailChangeNewDisplay').textContent = data.newEmail || emailChangeNewEmail; - showEmailChangeState('emailChangeResult'); - } catch (err) { - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -// Add event listeners for email change (append to DOMContentLoaded) -document.addEventListener('DOMContentLoaded', function() { - var closeBtn = document.getElementById('btn-close-email-change'); - if (closeBtn) closeBtn.addEventListener('click', closeEmailChange); - - var changeBtn = document.getElementById('emailChangeBtn'); - if (changeBtn) changeBtn.addEventListener('click', submitEmailChange); - - var verifyBtn = document.getElementById('emailChangeVerifyBtn'); - if (verifyBtn) verifyBtn.addEventListener('click', submitEmailChangeVerify); - - var modal = document.getElementById('emailChangeModal'); - if (modal) modal.addEventListener('click', function(e) { if (e.target === this) closeEmailChange(); }); - - document.querySelectorAll('.open-email-change').forEach(function(el) { - el.addEventListener('click', function(e) { e.preventDefault(); openEmailChange(); }); - }); -}); - -// === Accessibility: Escape key closes modals, focus trapping === -(function() { - function getActiveModal() { - var modals = ['signupModal', 'recoverModal', 'emailChangeModal']; - for (var i = 0; i < modals.length; i++) { - var m = document.getElementById(modals[i]); - if (m && m.classList.contains('active')) return m; - } - return null; - } - - function closeActiveModal() { - var m = getActiveModal(); - if (!m) return; - m.classList.remove('active'); - } - - document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') closeActiveModal(); - - // Focus trap inside active modal - if (e.key === 'Tab') { - var modal = getActiveModal(); - if (!modal) return; - var focusable = modal.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'); - if (focusable.length === 0) return; - var first = focusable[0], last = focusable[focusable.length - 1]; - if (e.shiftKey) { - if (document.activeElement === first) { e.preventDefault(); last.focus(); } - } else { - if (document.activeElement === last) { e.preventDefault(); first.focus(); } - } - } - }); -})(); +var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="✓ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}var pgTemplates={invoice:'\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t
+ Home + Docs + Examples + API Status + Support + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/examples.html b/public/examples.html new file mode 100644 index 0000000..92c562a --- /dev/null +++ b/public/examples.html @@ -0,0 +1,456 @@ + + + + + +Code Examples — DocFast HTML to PDF API + + + + + + + + + + + + + + + + + + +
+
+ +
+

Code Examples

+

Practical examples for generating PDFs with the DocFast API — invoices, reports, receipts, and integration guides.

+
+ + + + +
+

Generate an Invoice PDF

+

Create a professional invoice with inline CSS and convert it to PDF with a single API call.

+ +
+ HTML — invoice.html +
<html>
+<body style="font-family: sans-serif; padding: 40px; color: #333;">
+  <div style="display: flex; justify-content: space-between;">
+    <div>
+      <h1 style="margin: 0; color: #111;">INVOICE</h1>
+      <p style="color: #666;">#INV-2026-0042</p>
+    </div>
+    <div style="text-align: right;">
+      <strong>Acme Corp</strong><br>
+      123 Main St<br>
+      hello@acme.com
+    </div>
+  </div>
+
+  <table style="width: 100%; border-collapse: collapse; margin-top: 40px;">
+    <tr style="border-bottom: 2px solid #111;">
+      <th style="text-align: left; padding: 8px 0;">Item</th>
+      <th style="text-align: right; padding: 8px 0;">Qty</th>
+      <th style="text-align: right; padding: 8px 0;">Price</th>
+    </tr>
+    <tr style="border-bottom: 1px solid #eee;">
+      <td style="padding: 12px 0;">API Pro Plan (monthly)</td>
+      <td style="text-align: right;">1</td>
+      <td style="text-align: right;">$49.00</td>
+    </tr>
+    <tr>
+      <td style="padding: 12px 0;">Extra PDF renders (500)</td>
+      <td style="text-align: right;">500</td>
+      <td style="text-align: right;">$15.00</td>
+    </tr>
+  </table>
+
+  <p style="text-align: right; font-size: 1.4em; margin-top: 24px;">
+    <strong>Total: $64.00</strong>
+  </p>
+</body>
+</html>
+
+ +
+ curl +
curl -X POST https://docfast.dev/v1/convert/html \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"html": "<html>...your invoice HTML...</html>"}' \
+  --output invoice.pdf
+
+
+ + +
+

Convert Markdown to PDF

+

Send Markdown content directly — DocFast renders it with clean typography and outputs a styled PDF.

+ +
+ curl +
curl -X POST https://docfast.dev/v1/convert/markdown \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "markdown": "# Project Report\n\n## Summary\n\nQ4 revenue grew **32%** year-over-year.\n\n## Key Metrics\n\n| Metric | Value |\n|--------|-------|\n| Revenue | $1.2M |\n| Users | 45,000 |\n| Uptime | 99.97% |\n\n## Next Steps\n\n1. Launch mobile SDK\n2. Expand EU infrastructure\n3. SOC 2 certification"
+  }' \
+  --output report.pdf
+
+
+ + +
+

HTML Report with Charts

+

Embed inline SVG charts in your HTML for data-driven reports — no JavaScript or external libraries needed.

+ +
+ HTML — report with SVG bar chart +
<html>
+<body style="font-family: sans-serif; padding: 40px;">
+  <h1>Quarterly Revenue</h1>
+
+  <svg width="400" height="200" viewBox="0 0 400 200">
+    <!-- Bars -->
+    <rect x="20"  y="120" width="60" height="80"  fill="#34d399"/>
+    <rect x="110" y="80"  width="60" height="120" fill="#34d399"/>
+    <rect x="200" y="50"  width="60" height="150" fill="#34d399"/>
+    <rect x="290" y="20"  width="60" height="180" fill="#34d399"/>
+    <!-- Labels -->
+    <text x="50"  y="115" text-anchor="middle" font-size="12">$80k</text>
+    <text x="140" y="75"  text-anchor="middle" font-size="12">$120k</text>
+    <text x="230" y="45"  text-anchor="middle" font-size="12">$150k</text>
+    <text x="320" y="15"  text-anchor="middle" font-size="12">$180k</text>
+  </svg>
+</body>
+</html>
+
+ +
+ curl +
curl -X POST https://docfast.dev/v1/convert/html \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d @report.json \
+  --output chart-report.pdf
+
+
+ + +
+

Receipt / Confirmation PDF

+

Generate a simple receipt or order confirmation — perfect for e-commerce and SaaS billing.

+ +
+ HTML — receipt template +
<html>
+<body style="font-family: sans-serif; max-width: 400px; margin: 0 auto; padding: 40px;">
+  <div style="text-align: center; margin-bottom: 24px;">
+    <h2 style="margin: 0;">Payment Receipt</h2>
+    <p style="color: #888;">Feb 20, 2026</p>
+  </div>
+
+  <hr style="border: none; border-top: 1px dashed #ccc;">
+
+  <p><strong>Order:</strong> #ORD-98712</p>
+  <p><strong>Customer:</strong> jane@example.com</p>
+
+  <table style="width: 100%; margin: 16px 0;">
+    <tr>
+      <td>Pro Plan</td>
+      <td style="text-align: right;">$29.00</td>
+    </tr>
+    <tr>
+      <td>Tax</td>
+      <td style="text-align: right;">$2.90</td>
+    </tr>
+  </table>
+
+  <hr style="border: none; border-top: 1px dashed #ccc;">
+
+  <p style="text-align: right; font-size: 1.3em;">
+    <strong>Total: $31.90</strong>
+  </p>
+  <p style="text-align: center; color: #34d399; margin-top: 24px;">
+    ✓ Payment successful
+  </p>
+</body>
+</html>
+
+
+ + +
+

URL to PDF

+

Capture a live webpage and convert it to PDF. Send a URL to the /v1/convert/url endpoint and get a rendered PDF back. JavaScript is disabled for security (SSRF protection), and private/internal URLs are blocked.

+ +
+ curl — basic +
curl -X POST https://docfast.dev/v1/convert/url \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"url": "https://example.com"}' \
+  --output page.pdf
+
+ +
+ curl — with options +
curl -X POST https://docfast.dev/v1/convert/url \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "url": "https://example.com",
+    "format": "A4",
+    "margin": { "top": "20mm", "bottom": "20mm" },
+    "scale": 0.8,
+    "printBackground": true
+  }' \
+  --output page.pdf
+
+
+ + +
+

Node.js Integration

+

A complete Node.js script to generate a PDF and save it to disk. Works with Node 18+ using native fetch.

+ +
+ JavaScript — generate-pdf.mjs +
const html = `
+  <h1>Hello from Node.js</h1>
+  <p>Generated at ${new Date().toISOString()}</p>
+`;
+
+const res = await fetch("https://docfast.dev/v1/convert/html", {
+  method: "POST",
+  headers: {
+    "Authorization": `Bearer ${process.env.DOCFAST_API_KEY}`,
+    "Content-Type": "application/json",
+  },
+  body: JSON.stringify({ html }),
+});
+
+if (!res.ok) throw new Error(`API error: ${res.status}`);
+
+const buffer = Buffer.from(await res.arrayBuffer());
+await import("fs").then(fs =>
+  fs.writeFileSync("output.pdf", buffer)
+);
+
+console.log("✓ Saved output.pdf");
+
+
+ + +
+

Python Integration

+

Generate a PDF from Python using the requests library. Drop this into any Flask, Django, or FastAPI app.

+ +
+ Python — generate_pdf.py +
import os
+import requests
+
+html = """
+<h1>Hello from Python</h1>
+<p>This PDF was generated via the DocFast API.</p>
+<ul>
+  <li>Fast rendering</li>
+  <li>Pixel-perfect output</li>
+  <li>Simple REST API</li>
+</ul>
+"""
+
+response = requests.post(
+    "https://docfast.dev/v1/convert/html",
+    headers={
+        "Authorization": f"Bearer {os.environ['DOCFAST_API_KEY']}",
+        "Content-Type": "application/json",
+    },
+    json={"html": html},
+)
+
+response.raise_for_status()
+
+with open("output.pdf", "wb") as f:
+    f.write(response.content)
+
+print("✓ Saved output.pdf")
+
+
+ + +
+

Go Integration

+

SDK coming soon. In the meantime, use the HTTP example below — it works with any HTTP client.

+
+ Go — generate-pdf.go +
package main
+
+import (
+    "bytes"
+    "encoding/json"
+    "io"
+    "net/http"
+    "os"
+)
+
+func main() {
+    body, _ := json.Marshal(map[string]string{
+        "html": "<h1>Hello</h1><p>Generated with DocFast</p>",
+    })
+
+    req, _ := http.NewRequest("POST", "https://docfast.dev/v1/convert/html", bytes.NewReader(body))
+    req.Header.Set("Authorization", "Bearer "+os.Getenv("DOCFAST_API_KEY"))
+    req.Header.Set("Content-Type", "application/json")
+
+    resp, err := http.DefaultClient.Do(req)
+    if err != nil { panic(err) }
+    defer resp.Body.Close()
+
+    pdf, _ := io.ReadAll(resp.Body)
+    os.WriteFile("output.pdf", pdf, 0644)
+}
+
+
+ + +
+

PHP Integration

+

SDK coming soon. In the meantime, use the HTTP example below — it works with any HTTP client. Laravel: Use this in any controller or Artisan command.

+
+ PHP — generate-pdf.php +
<?php
+$html = '<h1>Hello</h1><p>Generated with DocFast</p>';
+
+$options = [
+    'http' => [
+        'method'  => 'POST',
+        'header'  => implode("\r\n", [
+            'Authorization: Bearer ' . getenv('DOCFAST_API_KEY'),
+            'Content-Type: application/json',
+        ]),
+        'content' => json_encode(['html' => $html]),
+    ],
+];
+
+$pdf = file_get_contents('https://docfast.dev/v1/convert/html', false, stream_context_create($options));
+file_put_contents('output.pdf', $pdf);
+echo "✓ Saved output.pdf\n";
+
+
+ +
+
+ + + + + diff --git a/public/impressum.html b/public/impressum.html index 5907197..beb51d6 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - +
-
+

Impressum

Legal notice according to § 5 ECG and § 25 MedienG (Austrian law)

@@ -103,8 +108,10 @@ footer .container { display: flex; justify-content: space-between; align-items: -
+

Privacy Policy

Last updated: February 16, 2026

@@ -185,8 +190,10 @@ footer .container { display: flex; justify-content: space-between; align-items:
- +
@@ -96,7 +451,7 @@

Everything you need

-

A complete PDF generation API. No SDKs, no dependencies, no setup.

+

Code examples for Node.js, Python, Go, PHP, and cURL. Official SDKs coming soon.

@@ -126,7 +481,75 @@

Secure by Default

-

HTTPS only. Rate limiting. No data stored. PDFs stream directly — nothing touches disk.

+

HTTPS only. No data stored. PDFs stream directly to you — nothing touches disk.

+
+
+
+
+ +
+
+

Try it — right now

+

Pick a template or write your own HTML. Generate a real PDF in seconds.

+ + +
+ + + +
+ + +
+
+
+ + HTML +
+ +
+
+
+ Live Preview + Updates as you type +
+
+ +
+
+
+ + +
+ + +
+ + + +
+
+
+
+

PDF generated in 0.4s

+ Download PDF → +
+
+
+
+
🆓 Free Demo
+
Watermarked output
+
+
+
+
⚡ Pro
+
Clean, production-ready
+
+
+
+
@@ -135,54 +558,52 @@

Simple, transparent pricing

-

Start free. Upgrade when you're ready. No surprise charges.

-
-
-
Free
-
€0 /mo
-
Perfect for side projects and testing
-
    -
  • 100 PDFs per month
  • -
  • All conversion endpoints
  • -
  • All templates included
  • -
  • Rate limiting: 10 req/min
  • -
- -
+

One plan. Everything included. No surprises.

+
-
- -{{> footer}} - -{{> modals}} - + - @@ -218,7 +638,43 @@
- + + + + diff --git a/public/src/status.html b/public/src/status.html index f616ed9..9a1fcd2 100644 --- a/public/src/status.html +++ b/public/src/status.html @@ -49,6 +49,6 @@ {{> footer}} - + diff --git a/public/src/terms.html b/public/src/terms.html index 1a7abca..e4e8f03 100644 --- a/public/src/terms.html +++ b/public/src/terms.html @@ -41,18 +41,19 @@

2. Service Plans

-

2.1 Free Tier

+

2.1 Demo (Free)

    -
  • Monthly limit: 100 PDF conversions
  • -
  • Rate limit: 10 requests per minute
  • -
  • Fair use policy: Personal and small business use
  • -
  • Support: Community documentation
  • +
  • No account required
  • +
  • Rate limit: 5 requests per hour
  • +
  • Purpose: Testing and evaluation only
  • +
  • Endpoints: /v1/demo/html and /v1/demo/markdown
  • +
  • Support: Documentation only, no SLA

2.2 Pro Tier

  • Price: €9 per month
  • -
  • Monthly limit: 10,000 PDF conversions
  • +
  • Monthly limit: 5,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • @@ -97,7 +98,7 @@

    5.1 Uptime

      -
    • Target: 99.5% uptime (best effort, no SLA for free tier)
    • +
    • Target: 99.5% uptime (best effort, no SLA for demo usage)
    • Maintenance: Scheduled maintenance with advance notice
    • Status page: https://docfast.dev/status
    diff --git a/public/status.html b/public/status.html index dfaa6d7..5915eeb 100644 --- a/public/status.html +++ b/public/status.html @@ -87,6 +87,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Features Pricing Docs + Examples @@ -103,7 +104,10 @@ footer .container { display: flex; justify-content: space-between; align-items:
- + diff --git a/public/status.js b/public/status.js index bd8a0b2..40617dd 100644 --- a/public/status.js +++ b/public/status.js @@ -1,48 +1 @@ -async function fetchStatus() { - const el = document.getElementById("status-content"); - try { - const res = await fetch("/health"); - const d = await res.json(); - const isOk = d.status === "ok"; - const isDegraded = d.status === "degraded"; - const dotClass = isOk ? "ok" : isDegraded ? "degraded" : "error"; - const label = isOk ? "All Systems Operational" : isDegraded ? "Degraded Performance" : "Service Disruption"; - const now = new Date().toLocaleTimeString(); - - el.innerHTML = - "
" + - "
" + label + "
" + - "
Version " + d.version + " · Last checked " + now + " · Auto-refreshes every 30s
" + - "
" + - "
" + - "
" + - "

🗄️ Database

" + - "
Status" + (d.database && d.database.status === "ok" ? "Connected" : "Error") + "
" + - "
Engine" + (d.database ? d.database.version : "Unknown") + "
" + - "
" + - "
" + - "

🖨️ PDF Engine

" + - "
Status 0 ? "ok" : "warn") + "\">" + (d.pool && d.pool.available > 0 ? "Ready" : "Busy") + "
" + - "
Available" + (d.pool ? d.pool.available : 0) + " / " + (d.pool ? d.pool.size : 0) + "
" + - "
Queue 0 ? "warn" : "ok") + "\">" + (d.pool ? d.pool.queueDepth : 0) + " waiting
" + - "
PDFs Generated" + (d.pool ? d.pool.pdfCount.toLocaleString() : "0") + "
" + - "
Uptime" + formatUptime(d.pool ? d.pool.uptimeSeconds : 0) + "
" + - "
" + - "
" + - ""; - } catch (e) { - el.innerHTML = "
Unable to reach API
The service may be temporarily unavailable. Please try again shortly.
"; - } -} - -function formatUptime(s) { - if (!s && s !== 0) return "Unknown"; - if (s < 60) return s + "s"; - if (s < 3600) return Math.floor(s/60) + "m " + (s%60) + "s"; - var h = Math.floor(s/3600); - var m = Math.floor((s%3600)/60); - return h + "h " + m + "m"; -} - -fetchStatus(); -setInterval(fetchStatus, 30000); +async function fetchStatus(){const s=document.getElementById("status-content");try{const a=await fetch("/health"),t=await a.json(),e="ok"===t.status,l="degraded"===t.status,o=e?"ok":l?"degraded":"error",n=e?"All Systems Operational":l?"Degraded Performance":"Service Disruption",i=(new Date).toLocaleTimeString();s.innerHTML='
'+n+'
Version '+t.version+" · Last checked "+i+' · Auto-refreshes every 30s

🗄️ Database

Status'+(t.database&&"ok"===t.database.status?"Connected":"Error")+'
Engine'+(t.database?t.database.version:"Unknown")+'

🖨️ PDF Engine

Status'+(t.pool&&t.pool.available>0?"Ready":"Busy")+'
Available'+(t.pool?t.pool.available:0)+" / "+(t.pool?t.pool.size:0)+'
Queue'+(t.pool?t.pool.queueDepth:0)+' waiting
PDFs Generated'+(t.pool?t.pool.pdfCount.toLocaleString():"0")+'
Uptime'+formatUptime(t.pool?t.pool.uptimeSeconds:0)+'
'}catch(a){s.innerHTML='
Unable to reach API
The service may be temporarily unavailable. Please try again shortly.
'}}function formatUptime(s){return s||0===s?s<60?s+"s":s<3600?Math.floor(s/60)+"m "+s%60+"s":Math.floor(s/3600)+"h "+Math.floor(s%3600/60)+"m":"Unknown"}fetchStatus(),setInterval(fetchStatus,3e4); \ No newline at end of file diff --git a/public/terms.html b/public/terms.html index 1340bb2..7684a22 100644 --- a/public/terms.html +++ b/public/terms.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
+

Terms of Service

Last updated: February 16, 2026

@@ -87,18 +92,19 @@ footer .container { display: flex; justify-content: space-between; align-items:

2. Service Plans

-

2.1 Free Tier

+

2.1 Demo (Free)

    -
  • Monthly limit: 100 PDF conversions
  • -
  • Rate limit: 10 requests per minute
  • -
  • Fair use policy: Personal and small business use
  • -
  • Support: Community documentation
  • +
  • No account required
  • +
  • Rate limit: 5 requests per hour
  • +
  • Purpose: Testing and evaluation only
  • +
  • Endpoints: /v1/demo/html and /v1/demo/markdown
  • +
  • Support: Documentation only, no SLA

2.2 Pro Tier

  • Price: €9 per month
  • -
  • Monthly limit: 10,000 PDF conversions
  • +
  • Monthly limit: 5,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • @@ -143,9 +149,9 @@ footer .container { display: flex; justify-content: space-between; align-items:

    5.1 Uptime

    5.2 Performance

    @@ -257,8 +263,10 @@ footer .container { display: flex; justify-content: space-between; align-items: