diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 30ffa89..63510fd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", - "name": "glance-agent-plugins", + "name": "glance", "version": "1.0.0", "description": "glance.sh plugins for coding agents", "metadata": { diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 627c66a..cfad982 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,11 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: - test: + plugins: runs-on: ubuntu-latest steps: @@ -18,11 +21,41 @@ jobs: node-version: 22 cache: npm - - name: Install dependencies + - name: Install plugin dependencies run: npm ci - - name: Run test coverage - run: npm run test:coverage + - name: Run plugin coverage + run: npm run test:plugins:coverage + + - name: Run plugin type check + run: npm run typecheck:plugins + + web: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: apps/web + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/web/package-lock.json + + - name: Install web dependencies + run: npm ci + + - name: Run web tests + run: npm test + + - name: Run web type check + run: npm run typecheck - - name: Run type check - run: npm exec -- tsc --noEmit + - name: Build web app + run: npm run build diff --git a/.gitignore b/.gitignore index f9e7a8c..a4a72d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ node_modules/ coverage/ .DS_Store + +# Web app local/deploy artifacts +apps/web/node_modules/ +apps/web/.next/ +apps/web/coverage/ +apps/web/.env +apps/web/.env.* +!apps/web/.env.example +apps/web/.vercel/ diff --git a/AGENTS.md b/AGENTS.md index ea5e7de..25fe5d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,28 +4,34 @@ Guidance for coding agents working in this repository. ## Purpose -This repo contains small, self-contained [glance.sh](https://glance.sh) integrations for different coding agents. Each plugin should be easy to read, easy to install, and easy to test locally. +This repo is the public Glance monorepo. It contains: + +- `apps/web/` — the Next.js web app behind glance.sh. +- `claude/`, `codex/`, `opencode/`, `pi/` — agent integrations. + +Glance provides temporary image sharing for coding agents and terminal workflows. ## Repository shape -- Each agent integration lives in its own top-level directory such as `pi/` or `opencode/`. -- Each plugin directory should contain: - - `glance.ts`: the plugin implementation - - `glance.test.ts`: the Vitest suite for that plugin - - `README.md`: install and usage instructions for that plugin -- Shared test-only helpers and runtime stubs belong under `test/`. +- Keep the existing plugin directories at the repository root during the first + monorepo migration to preserve install flows. +- The web app has its own package and lockfile under `apps/web/` so Vercel can + deploy it with Root Directory = `apps/web`. +- Public CI must not require production secrets. +- The hosted service deploys from a separate private deployment repo, not from + untrusted public PRs. ## Plugin expectations - Keep plugins self-contained inside their own directory. -- Prefer platform-native APIs such as `fetch`, `AbortController`, and `ReadableStream` over adding new dependencies. -- Preserve the style already used in the plugin you are editing instead of forcing a repo-wide formatting style. -- Match the repo's writing style: concise, direct, and low on filler in code comments, README copy, and PR text. -- Keep runtime-specific code minimal and explicit. Document runtime assumptions in the plugin `README.md`. -- Update the plugin `README.md` whenever install steps, behavior, supported commands/tools, or runtime requirements change. -- If a plugin has internal async/session logic that is hard to verify from the public API alone, expose a small `__testing` surface rather than making production code more coupled. - -## Behavior requirements +- Prefer platform-native APIs such as `fetch`, `AbortController`, and + `ReadableStream` over adding new dependencies. +- Preserve the style already used in the plugin you are editing. +- Update the plugin `README.md` whenever install steps, behavior, supported + commands/tools, or runtime requirements change. +- If a plugin has internal async/session logic that is hard to verify from the + public API alone, expose a small `__testing` surface rather than making + production code more coupled. Every plugin should cover the same core lifecycle: @@ -35,49 +41,55 @@ Every plugin should cover the same core lifecycle: - handle reconnects, timeouts, expiry, and cancellation - return or inject the received image URL in the host agent's expected format -If you fix a behavior bug in one plugin, check the other plugins for the same pattern before stopping. +If you fix a behavior bug in one plugin, check the other plugins for the same +pattern before stopping. -## Testing requirements +## Web app expectations -- Every plugin must have a Vitest suite in the same directory as the plugin file. -- Tests should cover both the happy path and failure modes, not just basic registration. -- At minimum, cover: - - session creation - - reuse or refresh of persistent sessions - - SSE image delivery - - timeout or expiry handling - - cancellation or shutdown behavior - - user-facing command/tool responses -- Prefer deterministic tests with mocked `fetch`, streams, timers, and runtime APIs. -- Keep tests local to the plugin. Put only reusable stubs or fixtures in `test/`. +Read `apps/web/AGENTS.md` before changing the web app. -## Tooling and config +Important invariants: -When adding a new plugin directory or new test-only runtime stub, update the root tooling so it stays in sync: +- Blob storage is private. +- Shared links point to app routes such as `/.`, never raw Blob URLs. +- Expiry is enforced by the app route on every request. +- Client components must not import server-only modules. +- The session API contract is consumed by the root plugins; coordinate changes. +- Sentry and analytics must remain opt-in for self-hosters. -- `vitest.config.ts` -- `tsconfig.json` -- `package.json` if new scripts are genuinely needed -- `.github/workflows/test.yml` if CI inputs change +## Validation -Do not add a build step unless the repository actually needs one. +Plugin validation from the repository root: -## Validation +```bash +npm ci +npm run test:plugins +npm run typecheck:plugins +``` + +Web validation: + +```bash +npm --prefix apps/web ci +npm run test:web +npm run typecheck:web +npm run build:web +``` -Before opening or updating a PR, run: +Full validation: ```bash npm test -npm run test:coverage -npm exec -- tsc --noEmit +npm run typecheck +npm run build:web ``` If a command cannot be run, say so explicitly and explain why. ## PR checklist -- The plugin implementation, tests, and README are all updated together. -- New behavior is covered by tests. -- Root test/typecheck config includes any new plugin or stub paths. -- CI still runs the repo validation commands on pull requests. -- The PR description calls out user-visible behavior changes and any runtime-specific caveats in concise language. +- Plugin implementation, tests, and README are updated together when plugin behavior changes. +- Web app behavior changes include route/component tests where practical. +- Public CI does not depend on production secrets. +- No hard-coded deployment telemetry, org IDs, or private repo paths are added. +- User-visible behavior changes are called out in the PR description. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d23a62 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented here. + +## Unreleased + +- Begin migration from plugin-only repository to public Glance monorepo. +- Add `apps/web/` with the glance.sh web app. +- Keep hosted-service deployment separate from the public repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fc93b63 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +Thanks for helping improve Glance. + +## Development setup + +```bash +git clone https://github.com/modem-dev/glance.git +cd glance +npm ci +npm --prefix apps/web ci +``` + +## Validate changes + +Plugins: + +```bash +npm run test:plugins +npm run typecheck:plugins +``` + +Web app: + +```bash +npm run test:web +npm run typecheck:web +npm run build:web +``` + +Full local check: + +```bash +npm test +npm run typecheck +npm run build:web +``` + +## Pull requests + +- Keep changes focused. +- Update docs when install steps, env vars, routes, or user-visible behavior change. +- Add or update tests for behavior changes. +- Do not add production secrets, deployment-only state, `.env` files, or hard-coded telemetry DSNs. +- Public PRs should be safe to run without secrets. + +## Deployment + +The hosted `glance.sh` service deploys from a private deployment repository after +reviewed public commits are synced. Public PRs do not deploy to production and +must never require production env vars. diff --git a/README.md b/README.md index 843ce2e..99c237e 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,65 @@ -# glance.sh agent plugins +# Glance -[![Integration](https://github.com/modem-dev/glance-agent-plugins/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/modem-dev/glance-agent-plugins/actions/workflows/test.yml) +[![Test](https://github.com/modem-dev/glance/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/modem-dev/glance/actions/workflows/test.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Supported Agents](https://img.shields.io/badge/agents-pi%20%7C%20OpenCode%20%7C%20Claude%20Code%20%7C%20Codex-blue)](#available-plugins) +[![Supported Agents](https://img.shields.io/badge/agents-pi%20%7C%20OpenCode%20%7C%20Claude%20Code%20%7C%20Codex-blue)](#agent-plugins) -Agent integrations for [glance.sh](https://glance.sh) — temporary image sharing for coding agents. +Glance is temporary image sharing for coding agents and terminal workflows. +Paste a screenshot in your browser, get a short-lived URL, and hand it to an +agent that cannot receive images directly. -Paste a screenshot in your browser, your agent gets the URL instantly. +The hosted service runs at [glance.sh](https://glance.sh). This repository +contains both the web app and agent integrations. -## Available plugins +## Repository layout + +```text +apps/web/ Next.js app behind glance.sh +claude/ Claude Code plugin +codex/ Codex MCP integration +opencode/ OpenCode plugin +pi/ pi coding-agent extension +``` + +The plugin directories intentionally stay at the repository root for now so +existing install flows keep working. They may move under `plugins/` in a future +major cleanup. + +## Web app + +```bash +cd apps/web +npm ci +cp .env.example .env.local +npm run dev +``` + +Required services for a production-like deployment: + +- Vercel Blob private store (`BLOB_READ_WRITE_TOKEN`) +- Upstash Redis (`UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`) +- Google Generative AI API key for OCR (`GOOGLE_GENERATIVE_AI_API_KEY`) +- `CRON_SECRET` for cleanup cron authentication + +Optional app settings use the `GLANCE_*` prefix. Legacy `AGENTPASTE_*` names are +accepted for compatibility. + +Telemetry is opt-in for self-hosters. Leave `NEXT_PUBLIC_SENTRY_DSN`, +`SENTRY_DSN`, and `NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED` unset/disabled to run +without Sentry or Vercel Analytics. + +## Agent plugins | Agent | Directory | npm package | Install | |---|---|---|---| | [pi](https://github.com/mariozechner/pi) | [`pi/`](pi/) | `@modemdev/glance-pi` | `pi install npm:@modemdev/glance-pi` | | [OpenCode](https://github.com/anomalyco/opencode) | [`opencode/`](opencode/) | `@modemdev/glance-opencode` | Add `"@modemdev/glance-opencode"` to `opencode.json` `plugin` list | -| [Claude Code](https://github.com/anthropics/claude-code) | [`claude/`](claude/) | `@modemdev/glance-claude` | `/plugin marketplace add modem-dev/glance-agent-plugins` then `/plugin install glance-claude@glance-agent-plugins` | +| [Claude Code](https://github.com/anthropics/claude-code) | [`claude/`](claude/) | `@modemdev/glance-claude` | `/plugin marketplace add modem-dev/glance` then `/plugin install glance-claude@glance` | | [Codex](https://developers.openai.com/codex) | [`codex/`](codex/) | `@modemdev/glance-codex` | `codex mcp add glance -- npx -y @modemdev/glance-codex` | -## How it works - -Each plugin creates a live session on glance.sh, gives you a URL to open, and waits for you to paste an image. The image URL is returned to the agent over SSE — no manual copy-paste needed. +Each plugin creates a live session on glance.sh, gives you a URL to open, and +waits for you to paste an image. The image URL is returned to the agent over +SSE — no manual copy-paste needed. ```text agent ──POST /api/session──▶ { id, url } @@ -27,23 +67,35 @@ agent ──GET /api/session//events──▶ SSE (waiting…) user ──opens /s/, pastes image──▶ agent receives URL ``` -Sessions are anonymous and ephemeral (10-minute TTL). Images expire after 30 minutes. +## Development -## Packaging policy +Root plugin checks: -New plugins should be published as installable packages (npm where possible) with a one-command install path in their README. +```bash +npm ci +npm test +npm run typecheck +``` -Each plugin directory should include: +Web checks: -1. Integration code -2. `README.md` with install / verify / update / remove steps -3. `package.json` (if the target agent supports package-based install) -4. Release automation (GitHub Actions workflow + documented version/tag convention) +```bash +npm --prefix apps/web ci +npm run test:web +npm run build:web +npm run typecheck:web +``` -## Adding a new plugin +## Deployment safety -Create a directory for your agent (e.g. `cursor/`, `cline/`) with the files above and open a PR. +The public repository is not connected to the production Vercel project. The +hosted `glance.sh` service deploys from a separate private deployment repository +that only receives reviewed public commits. Public pull requests should never +run with production secrets. ## License -MIT +MIT. See [LICENSE](LICENSE). + +The MIT license covers the code. The `glance.sh` domain, hosted service, and +associated branding remain operated by Modem Labs. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..db1a4c3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Reporting vulnerabilities + +Please report security issues privately to [support@modem.dev](mailto:support@modem.dev). +Do not open a public issue for suspected vulnerabilities. + +Include as much detail as possible: affected route/plugin, reproduction steps, +impact, and whether you believe any user data or hosted-service secrets are at +risk. + +## Deployment and secrets + +The public repository must be safe for untrusted pull requests: + +- no production secrets in the repo +- no `.env` files committed +- no public PR preview deployments with production Vercel env vars +- no hard-coded Sentry DSNs or telemetry endpoints that run by default + +The hosted `glance.sh` deployment runs from a separate private deployment repo. + +## Hosted service abuse reports + +For content removal or abuse reports related to the hosted service, contact +[support@modem.dev](mailto:support@modem.dev). diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..2712d26 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,31 @@ +# Public app URL used to mint live-session URLs and validate allowed origins. +NEXT_PUBLIC_BASE_URL=https://glance.sh + +# Added by Vercel when you connect a private Blob store to the project. +BLOB_READ_WRITE_TOKEN= + +# Required for OCR. +GOOGLE_GENERATIVE_AI_API_KEY= + +# Required for distributed rate limiting and live sessions. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# Required for securing cron routes. +CRON_SECRET= + +# Optional app overrides. Legacy AGENTPASTE_* names are still accepted. +GLANCE_TTL_MINUTES=30 +GLANCE_MAX_UPLOAD_MB=15 +GLANCE_UPLOAD_TOKEN_TTL_SECONDS=300 +GLANCE_UPLOAD_PROOF_SECRET= +GLANCE_CLEANUP_BATCH_SIZE=100 +GLANCE_ALLOW_UNAUTHENTICATED_CRON=0 # local dev escape hatch only; keep 0 in deployed envs + +# Optional telemetry. Leave unset for telemetry-free self-hosting. +NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=0 +NEXT_PUBLIC_SENTRY_DSN= +SENTRY_DSN= +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_AUTH_TOKEN= diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..4391cae --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,12 @@ +.next +node_modules +*.tsbuildinfo +.env +.env.local +.env.*.local +npm-debug.log* +.vercel +coverage + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..6b7606d --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,140 @@ +# glance.sh web app agent guide + +## Purpose + +`glance.sh` is a small Next.js app for handing image uploads to terminal-only workflows. +The app accepts a pasted image or chosen file, uploads it to a private Vercel Blob store, +and returns a temporary app-served URL like `https://glance.sh/.`. + +The short app URL is the public interface. The blob itself stays private. + +## Current UX + +- `/` is the primary upload surface. +- successful uploads stay on `/` and append result cards below the paste box. +- users can paste multiple images in a row without navigating back. +- `/p/[token]` is a secondary result/share page, not the default success flow. + +## Request flow + +```text +paste image / choose file + | + v + POST /api/issue + | + v +mint token + expiry + upload proof + | + v +browser upload() -> /api/upload -> private Vercel Blob + | + v +stay on / + | + v +append share card with /. + | + v +client / agent fetches image through app route +``` + +## Live session flow + +```text +agent calls POST /api/session + | + v +returns { id, url: "/s/" } + | + v +agent shows url to user / opens browser + | + v +agent connects GET /api/session//events (SSE) + | + v +user opens /s/, pastes image + | + v +browser uploads to Blob, then POST /api/session//push + | + v +SSE emits "image" event with { url, expiresAt } + | + v +agent receives image URL +``` + +Sessions are stored in Upstash Redis with a 10-minute TTL. They are lightweight +coordination channels; image bytes still live in Vercel Blob and are served +through the normal `/` app route. + +## Important routes + +- `/` — single-page uploader and recent-results list. +- `/s/[id]` — session upload page. +- `/p/[token]` — secondary share page with preview/copy actions. +- `/[token]` — image streaming route; validates expiry and streams the private blob. +- `/api/session` — create a live session. +- `/api/session/[id]/push` — push an uploaded image URL into a session. +- `/api/session/[id]/events` — SSE stream for session events. +- `/api/issue` — mint token, expiry timestamp, and upload proof. +- `/api/upload` — Vercel Blob client-upload token exchange and validation. +- `/api/ocr` — OCR helper. +- `/api/cron/cleanup` — delete expired blobs from storage. + +## Important files + +- `app/page.tsx` — home page shell. +- `components/paste-uploader.tsx` — main client upload workflow. +- `components/session-uploader.tsx` — session upload workflow. +- `components/share-actions.tsx` — copy actions. +- `app/[token]/route.ts` — private blob streaming + expiry enforcement. +- `lib/tokens.ts` — token issuance and parsing; server-only Node crypto. +- `lib/share.ts` — client-safe URL/filename/expiry helpers. +- `lib/config.ts` — TTL, size limits, and config parsing. +- `lib/sessions.ts` — session CRUD backed by Upstash Redis. + +## Invariants + +- Blob storage is private. Do not switch uploads or reads to public access unless explicitly requested. +- Shared links should point to `/.`, not raw Blob URLs. +- True expiry is enforced by the app route, not by cleanup timing. +- The extension in `/.` is for usability; token validity comes from the token itself. +- Client components must not import server-only modules. +- The session API contract is consumed by plugins at the repository root; coordinate request/response changes. +- Telemetry must be opt-in for self-hosters; do not add hard-coded DSNs or analytics that run by default. + +## Environment + +Required in deployed environments: + +- `BLOB_READ_WRITE_TOKEN` +- `CRON_SECRET` +- `GOOGLE_GENERATIVE_AI_API_KEY` +- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` + +Optional: + +- `NEXT_PUBLIC_BASE_URL` +- `GLANCE_TTL_MINUTES` +- `GLANCE_MAX_UPLOAD_MB` +- `GLANCE_UPLOAD_TOKEN_TTL_SECONDS` +- `GLANCE_UPLOAD_PROOF_SECRET` +- `GLANCE_CLEANUP_BATCH_SIZE` +- `GLANCE_ALLOW_UNAUTHENTICATED_CRON` +- `NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED` +- `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` + +Legacy `AGENTPASTE_*` names are accepted where implemented for compatibility. + +## Local commands + +```bash +npm ci +npm test +npm run typecheck +npm run build +npm run dev +``` diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..63a91b0 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,104 @@ +# glance.sh web app + +Temporary image sharing for coding agents. Paste a screenshot, get a URL your agent can fetch. + +**[glance.sh](https://glance.sh)** · [𝕏 @modemdev](https://x.com/modemdev) + +## Why glance.sh + +- Your agent cannot see your screen — give it a URL it can `curl` +- Paste, drag, or choose an image → get a link in seconds +- OCR built in — extract text from screenshots with one click +- Short-lived URLs — images auto-expire and are permanently deleted +- No accounts, no auth, no signup + +## Live sessions + +For agents that can open a browser tab, glance.sh supports live sessions: the +agent creates a session, gives you a URL, and waits. You paste an image and the +agent receives it instantly over SSE. + +```text +agent ──POST /api/session──▶ { id, url } +agent ──GET /api/session//events──▶ (SSE, waiting…) +user ──opens /s/, pastes image──▶ agent receives URL +``` + +Agent integrations live at the repository root (`claude/`, `codex/`, +`opencode/`, and `pi/`). + +## Development + +```bash +npm ci +cp .env.example .env.local +npm run dev +``` + +## Environment + +Required for production-like use: + +```text +NEXT_PUBLIC_BASE_URL= # public app URL, e.g. https://glance.sh +BLOB_READ_WRITE_TOKEN= # Vercel Blob private store +CRON_SECRET= # authenticates cleanup cron +GOOGLE_GENERATIVE_AI_API_KEY= # Gemini Flash for OCR +UPSTASH_REDIS_REST_URL= # Upstash Redis sessions/rate limits +UPSTASH_REDIS_REST_TOKEN= # Upstash Redis sessions/rate limits +``` + +Optional app settings: + +```text +GLANCE_TTL_MINUTES=30 +GLANCE_MAX_UPLOAD_MB=15 +GLANCE_UPLOAD_TOKEN_TTL_SECONDS=300 +GLANCE_UPLOAD_PROOF_SECRET= +GLANCE_CLEANUP_BATCH_SIZE=100 +GLANCE_ALLOW_UNAUTHENTICATED_CRON=0 +``` + +Legacy `AGENTPASTE_*` names are still accepted for compatibility. + +Optional telemetry, disabled by default: + +```text +NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=0 +NEXT_PUBLIC_SENTRY_DSN= +SENTRY_DSN= +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_AUTH_TOKEN= +``` + +## Testing + +```bash +npm test +npm run test:coverage +npm run typecheck +npm run build +``` + +## Architecture + +- Next.js App Router on Vercel +- Vercel Blob private storage for images +- Upstash Redis for live sessions and rate limits +- Gemini 2.5 Flash for OCR +- Token expiry is encoded in the URL and enforced on every request +- Cleanup cron deletes expired physical blobs; expiry does not depend on cleanup timing + +## Deployment safety + +The hosted `glance.sh` service deploys from a separate private deployment repo. +Do not connect the public OSS repository to a Vercel project with production +secrets, and do not run untrusted PR previews with production env vars. + +## License + +MIT. See the repository root [LICENSE](../../LICENSE). + +For abuse reports or content removal on the hosted service: +[support@modem.dev](mailto:support@modem.dev) diff --git a/apps/web/app/JetBrainsMono-Bold.ttf b/apps/web/app/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..1305efc Binary files /dev/null and b/apps/web/app/JetBrainsMono-Bold.ttf differ diff --git a/apps/web/app/[token]/route.ts b/apps/web/app/[token]/route.ts new file mode 100644 index 0000000..dfa0b70 --- /dev/null +++ b/apps/web/app/[token]/route.ts @@ -0,0 +1,80 @@ +import { get } from '@vercel/blob'; +import { NextResponse } from 'next/server'; + +import { decrypt } from '@/lib/encryption.server'; +import { + assetFilenameForToken, + extensionForContentType, + parseAssetSlug, + parseToken, + pathForToken, +} from '@/lib/tokens'; + +type AssetRouteContext = { + params: Promise<{ token: string }>; +}; + +const SECURITY_HEADERS = { + 'Cache-Control': 'private, no-store, max-age=0, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + 'X-Content-Type-Options': 'nosniff', + 'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex', +} as const; + +export async function GET( + _request: Request, + { params }: AssetRouteContext, +): Promise { + const { token: assetSlug } = await params; + const parsedSlug = parseAssetSlug(assetSlug); + + if (!parsedSlug) { + return new NextResponse('Not found', { status: 404 }); + } + + const parsed = parseToken(parsedSlug.token); + + if (parsed?.expired) { + return new NextResponse('Expired', { + status: 410, + headers: { + 'Cache-Control': 'private, no-store, max-age=0, must-revalidate', + 'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex', + }, + }); + } + + const blob = await get(pathForToken(parsedSlug.token), { + access: 'private', + }); + + if (!blob) { + return new NextResponse('Not found', { status: 404 }); + } + + // Buffer the encrypted blob and decrypt it. + let contentType: string; + let data: ArrayBuffer; + + try { + const response = new Response(blob.stream); + const encrypted = await response.arrayBuffer(); + const result = await decrypt(encrypted, parsedSlug.token); + contentType = result.contentType; + data = result.data; + } catch { + return new NextResponse('Not found', { status: 404 }); + } + + const extension = extensionForContentType(contentType); + const filename = assetFilenameForToken(parsedSlug.token, extension); + + return new NextResponse(data, { + headers: { + ...SECURITY_HEADERS, + 'Content-Disposition': `inline; filename="${filename}"`, + 'Content-Type': contentType, + }, + }); +} diff --git a/apps/web/app/api/cron/cleanup/route.ts b/apps/web/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..7431b1d --- /dev/null +++ b/apps/web/app/api/cron/cleanup/route.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/nextjs'; +import { del, list } from '@vercel/blob'; +import { NextResponse } from 'next/server'; + +import { authorizeCronRequest } from '@/lib/cron-auth'; +import { CLEANUP_BATCH_SIZE, UPLOADS_PREFIX } from '@/lib/config'; +import { parseToken, tokenFromPathname } from '@/lib/tokens'; +async function deleteBatch(pathnames: string[]): Promise { + await del(pathnames); +} + +export async function GET(request: Request): Promise { + const unauthorized = authorizeCronRequest(request); + if (unauthorized) { + return unauthorized; + } + + let cursor: string | undefined; + let deleted = 0; + let scanned = 0; + const toDelete: string[] = []; + + do { + const page = await list({ + cursor, + limit: 1000, + prefix: UPLOADS_PREFIX, + }); + + scanned += page.blobs.length; + + for (const blob of page.blobs) { + const token = tokenFromPathname(blob.pathname); + if (!token) { + continue; + } + + const parsed = parseToken(token); + if (!parsed?.expired) { + continue; + } + + toDelete.push(blob.pathname); + + if (toDelete.length >= CLEANUP_BATCH_SIZE) { + const batch = toDelete.splice(0, toDelete.length); + await deleteBatch(batch); + deleted += batch.length; + } + } + + cursor = page.cursor; + + if (!page.hasMore) { + break; + } + } while (cursor); + + if (toDelete.length > 0) { + deleted += toDelete.length; + await deleteBatch(toDelete); + } + + Sentry.metrics.count('cleanup.run', 1); + Sentry.metrics.count('cleanup.scanned', scanned); + Sentry.metrics.count('cleanup.deleted', deleted); + + return NextResponse.json({ + deleted, + now: new Date().toISOString(), + scanned, + }); +} diff --git a/apps/web/app/api/issue/route.ts b/apps/web/app/api/issue/route.ts new file mode 100644 index 0000000..26ed30a --- /dev/null +++ b/apps/web/app/api/issue/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { checkRateLimit } from '@/lib/rate-limit'; +import { issueToken, pathForToken } from '@/lib/tokens'; +import { createUploadProof } from '@/lib/upload-proof'; + +export async function POST(request: NextRequest): Promise { + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown'; + + const { allowed, retryAfterSeconds } = await checkRateLimit(ip, { + bucket: 'upload', + maxRequests: 30, + windowMs: 3_600_000, + }); + + if (!allowed) { + return NextResponse.json( + { error: 'Upload limit reached. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds ?? 3600) }, + }, + ); + } + + const { token, expiresAt } = issueToken(); + const pathname = pathForToken(token); + + let uploadProof: string; + try { + uploadProof = createUploadProof({ + token, + pathname, + tokenExpiresAt: expiresAt, + }); + } catch { + return NextResponse.json( + { error: 'Upload service is misconfigured.' }, + { status: 500 }, + ); + } + + return NextResponse.json( + { + token, + pathname, + expiresAt, + uploadProof, + }, + { + headers: { + 'Cache-Control': 'no-store', + }, + }, + ); +} diff --git a/apps/web/app/api/ocr/route.ts b/apps/web/app/api/ocr/route.ts new file mode 100644 index 0000000..c2ce080 --- /dev/null +++ b/apps/web/app/api/ocr/route.ts @@ -0,0 +1,152 @@ +import { google } from '@ai-sdk/google'; +import * as Sentry from '@sentry/nextjs'; +import { generateText } from 'ai'; +import { get } from '@vercel/blob'; +import { NextRequest, NextResponse } from 'next/server'; + +import { decrypt } from '@/lib/encryption.server'; +import { parseToken, pathForToken } from '@/lib/tokens'; +import { checkRateLimit } from '@/lib/rate-limit'; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const CACHE_TTL_MS = 30 * 60 * 1000; // match image TTL + +type CacheEntry = { text: string; expiresAt: number }; +const cache = new Map(); + +let lastPrune = Date.now(); +function pruneCache(now: number) { + if (now - lastPrune < 60_000) return; + for (const [k, v] of cache) { + if (now >= v.expiresAt) cache.delete(k); + } + lastPrune = now; +} + +function getCached(token: string): string | null { + const now = Date.now(); + pruneCache(now); + const entry = cache.get(token); + if (!entry || now >= entry.expiresAt) return null; + return entry.text; +} + +export async function POST(request: NextRequest): Promise { + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown'; + + const { allowed, retryAfterSeconds } = await checkRateLimit(ip, { + bucket: 'ocr', + maxRequests: 20, + windowMs: 3_600_000, + }); + + if (!allowed) { + return NextResponse.json( + { error: 'Rate limit exceeded. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds ?? 3600) }, + }, + ); + } + + let body: { token?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 }); + } + + const token = body.token; + if (!token || typeof token !== 'string') { + return NextResponse.json({ error: 'Missing token.' }, { status: 400 }); + } + + const parsed = parseToken(token); + if (!parsed) { + return NextResponse.json({ error: 'Invalid token.' }, { status: 400 }); + } + + if (parsed.expired) { + return NextResponse.json({ error: 'Token expired.' }, { status: 410 }); + } + + const cached = getCached(token); + if (cached !== null) { + return NextResponse.json({ text: cached }); + } + + let blob; + try { + blob = await get(pathForToken(token), { access: 'private' }); + } catch { + return NextResponse.json({ error: 'Image not found.' }, { status: 404 }); + } + + if (!blob) { + return NextResponse.json({ error: 'Image not found.' }, { status: 404 }); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + const reader = (blob.stream as ReadableStream).getReader(); + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_IMAGE_BYTES) { + reader.cancel(); + return NextResponse.json({ error: 'Image too large for OCR.' }, { status: 413 }); + } + chunks.push(value); + } + + const encryptedBuffer = Buffer.concat(chunks); + + let imageBuffer: Buffer; + let contentType: string; + try { + const encryptedArray = encryptedBuffer.buffer.slice( + encryptedBuffer.byteOffset, + encryptedBuffer.byteOffset + encryptedBuffer.byteLength, + ); + const decrypted = decrypt(encryptedArray, token); + imageBuffer = Buffer.from(decrypted.data); + contentType = decrypted.contentType; + } catch { + return NextResponse.json({ error: 'Image not found.' }, { status: 404 }); + } + + try { + const result = await generateText({ + model: google('gemini-2.5-flash'), + messages: [ + { + role: 'user', + content: [ + { type: 'file', data: imageBuffer, mediaType: contentType }, + { + type: 'text', + text: 'Extract all visible text from this image exactly as shown. Include code, terminal output, UI labels, error messages, and any other readable text. Preserve formatting and line breaks. Return only the extracted text, no commentary.', + }, + ], + }, + ], + }); + + cache.set(token, { text: result.text, expiresAt: Date.now() + CACHE_TTL_MS }); + return NextResponse.json({ text: result.text }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + Sentry.captureException(err, { + tags: { route: 'api/ocr' }, + extra: { detail }, + }); + console.error('OCR failed:', detail); + return NextResponse.json({ error: 'OCR processing failed.' }, { status: 502 }); + } +} diff --git a/apps/web/app/api/session/[id]/events/route.ts b/apps/web/app/api/session/[id]/events/route.ts new file mode 100644 index 0000000..c05b731 --- /dev/null +++ b/apps/web/app/api/session/[id]/events/route.ts @@ -0,0 +1,161 @@ +import { checkRateLimit } from '@/lib/rate-limit'; +import { getRequestIp } from '@/lib/request-ip'; +import { getEvents, sessionExists, type SessionEvent } from '@/lib/sessions'; + +const EVENTS_RATE_LIMIT = { + bucket: 'session-events', + maxRequests: 120, + windowMs: 3_600_000, +}; + +function eventCursorKey(event: SessionEvent): string { + return `${event.url}\u0000${event.expiresAt}`; +} + +function nextEventIndex( + events: SessionEvent[], + lastSentEventKey: string | null, + lastSentEventOrdinal: number, +): number { + if (!lastSentEventKey || lastSentEventOrdinal <= 0) { + return 0; + } + + let seen = 0; + + // Find the same occurrence (ordinal) of the last-sent key. + for (let index = 0; index < events.length; index += 1) { + if (eventCursorKey(events[index]) !== lastSentEventKey) { + continue; + } + + seen += 1; + if (seen === lastSentEventOrdinal) { + return index + 1; + } + } + + // History window shifted past our cursor (e.g. capped list): send current window. + return 0; +} + +export const maxDuration = 300; + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const ip = getRequestIp(req); + const { allowed, retryAfterSeconds } = await checkRateLimit( + ip, + EVENTS_RATE_LIMIT, + ); + + if (!allowed) { + return Response.json( + { error: 'Too many session stream connections. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds ?? 3600) }, + }, + ); + } + + const { id } = await params; + + if (!(await sessionExists(id))) { + return Response.json({ error: 'Session not found' }, { status: 404 }); + } + + const encoder = new TextEncoder(); + let cancelled = false; + + const stream = new ReadableStream({ + async start(controller) { + const send = (data: string) => { + try { + controller.enqueue(encoder.encode(data)); + } catch { + // If enqueue fails (closed stream), stop polling immediately. + cancelled = true; + } + }; + + send('event: connected\ndata: {}\n\n'); + + let lastSentEventKey: string | null = null; + let lastSentEventOrdinal = 0; + const startedAt = Date.now(); + const TIMEOUT_MS = 295_000; // close before maxDuration (300s) + const POLL_MS = 500; + const PING_MS = 15_000; + let lastPing = Date.now(); + + let expired = false; + + while (!cancelled && Date.now() - startedAt < TIMEOUT_MS) { + const events = await getEvents(id); + + if (events === null) { + // Session expired / deleted + send('event: expired\ndata: {}\n\n'); + expired = true; + break; + } + + // Send any new events, resilient to capped/shifted history windows. + const startIndex = nextEventIndex( + events, + lastSentEventKey, + lastSentEventOrdinal, + ); + + for (let index = startIndex; index < events.length; index += 1) { + const event = events[index]; + send(`event: image\ndata: ${JSON.stringify(event)}\n\n`); + + const key = eventCursorKey(event); + let ordinal = 0; + for (let seenIndex = 0; seenIndex <= index; seenIndex += 1) { + if (eventCursorKey(events[seenIndex]) === key) { + ordinal += 1; + } + } + + lastSentEventKey = key; + lastSentEventOrdinal = ordinal; + } + + // Keep-alive ping + if (Date.now() - lastPing >= PING_MS) { + send(': ping\n\n'); + lastPing = Date.now(); + } + + // Wait before next poll + await new Promise((r) => setTimeout(r, POLL_MS)); + } + + if (!cancelled && !expired) { + send('event: timeout\ndata: {}\n\n'); + } + + try { + controller.close(); + } catch { + // already closed + } + }, + cancel() { + cancelled = true; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/apps/web/app/api/session/[id]/push/route.ts b/apps/web/app/api/session/[id]/push/route.ts new file mode 100644 index 0000000..2ef18fe --- /dev/null +++ b/apps/web/app/api/session/[id]/push/route.ts @@ -0,0 +1,169 @@ +import { MAX_ALLOWED_EXPIRY_AHEAD_MS } from '@/lib/config'; +import { checkRateLimit } from '@/lib/rate-limit'; +import { getRequestIp } from '@/lib/request-ip'; +import { pushEvent, sessionExists } from '@/lib/sessions'; +import { parseAssetSlug, parseToken } from '@/lib/tokens'; + +const PUSH_RATE_LIMIT = { + bucket: 'session-push', + maxRequests: 240, + windowMs: 3_600_000, +}; + +function canonicalOrigin(url: URL): string { + const host = url.host.replace(/^www\./i, ''); + return `${url.protocol}//${host}`; +} + +function firstHeaderValue(value: string | null): string | null { + if (!value) { + return null; + } + + const first = value.split(',')[0]?.trim(); + return first || null; +} + +function requestOrigin(request: Request): string { + const requestUrl = new URL(request.url); + const host = + firstHeaderValue(request.headers.get('x-forwarded-host')) ?? + firstHeaderValue(request.headers.get('host')) ?? + requestUrl.host; + const normalizedHost = host.replace(/^www\./i, ''); + + const forwardedProto = firstHeaderValue( + request.headers.get('x-forwarded-proto'), + )?.toLowerCase(); + + const proto = + forwardedProto === 'http' || forwardedProto === 'https' + ? forwardedProto + : normalizedHost.startsWith('localhost') || normalizedHost.startsWith('127.0.0.1') + ? 'http' + : 'https'; + + return `${proto}://${normalizedHost}`; +} + +function isAllowedOrigin(url: URL, currentRequestOrigin: string): boolean { + const allowedOrigins = new Set([ + canonicalOrigin(new URL(currentRequestOrigin)), + ]); + + if (process.env.NEXT_PUBLIC_BASE_URL) { + try { + allowedOrigins.add( + canonicalOrigin(new URL(process.env.NEXT_PUBLIC_BASE_URL)), + ); + } catch { + // ignore malformed base URL env + } + } + + return allowedOrigins.has(canonicalOrigin(url)); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const ip = getRequestIp(req); + const { allowed, retryAfterSeconds } = await checkRateLimit( + ip, + PUSH_RATE_LIMIT, + ); + + if (!allowed) { + return Response.json( + { error: 'Too many session updates. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds ?? 3600) }, + }, + ); + } + + const { id } = await params; + + if (!(await sessionExists(id))) { + return Response.json({ error: 'Session not found' }, { status: 404 }); + } + + let body: { url?: string; expiresAt?: number }; + try { + body = (await req.json()) as { url?: string; expiresAt?: number }; + } catch { + return Response.json({ error: 'Invalid request body' }, { status: 400 }); + } + + if (!body.url || typeof body.url !== 'string') { + return Response.json({ error: 'Missing url' }, { status: 400 }); + } + + if (!body.expiresAt || typeof body.expiresAt !== 'number') { + return Response.json({ error: 'Missing expiresAt' }, { status: 400 }); + } + + const currentOrigin = requestOrigin(req); + + let parsedUrl: URL; + try { + parsedUrl = new URL(body.url, currentOrigin); + } catch { + return Response.json({ error: 'Invalid url' }, { status: 400 }); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return Response.json({ error: 'Invalid url protocol' }, { status: 400 }); + } + + if (!isAllowedOrigin(parsedUrl, currentOrigin)) { + return Response.json({ error: 'URL must be a first-party glance link' }, { status: 400 }); + } + + if (parsedUrl.search || parsedUrl.hash) { + return Response.json({ error: 'URL must not include query/hash' }, { status: 400 }); + } + + const slug = parsedUrl.pathname.replace(/^\/+/, ''); + if (!slug || slug.includes('/')) { + return Response.json({ error: 'URL must target a share token route' }, { status: 400 }); + } + + const parsedSlug = parseAssetSlug(slug); + if (!parsedSlug) { + return Response.json({ error: 'URL must target a share token route' }, { status: 400 }); + } + + const parsedToken = parseToken(parsedSlug.token)!; + + if (parsedToken.expired) { + return Response.json({ error: 'Token expired.' }, { status: 410 }); + } + + if (body.expiresAt <= Date.now()) { + return Response.json({ error: 'expiresAt must be in the future' }, { status: 400 }); + } + + if (body.expiresAt > Date.now() + MAX_ALLOWED_EXPIRY_AHEAD_MS) { + return Response.json( + { error: 'expiresAt exceeds allowed lifetime' }, + { status: 400 }, + ); + } + + if (body.expiresAt !== parsedToken.expiresAt) { + return Response.json( + { error: 'expiresAt does not match token expiry' }, + { status: 400 }, + ); + } + + const ok = await pushEvent(id, { url: parsedUrl.toString(), expiresAt: body.expiresAt }); + if (!ok) { + return Response.json({ error: 'Session expired' }, { status: 410 }); + } + + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/session/route.ts b/apps/web/app/api/session/route.ts new file mode 100644 index 0000000..d2bfac8 --- /dev/null +++ b/apps/web/app/api/session/route.ts @@ -0,0 +1,32 @@ +import { checkRateLimit } from '@/lib/rate-limit'; +import { getRequestIp } from '@/lib/request-ip'; +import { createSession } from '@/lib/sessions'; + +const CREATE_SESSION_RATE_LIMIT = { + bucket: 'session-create', + maxRequests: 60, + windowMs: 3_600_000, +}; + +export async function POST(request: Request): Promise { + const ip = getRequestIp(request); + const { allowed, retryAfterSeconds } = await checkRateLimit( + ip, + CREATE_SESSION_RATE_LIMIT, + ); + + if (!allowed) { + return Response.json( + { error: 'Too many sessions created. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds ?? 3600) }, + }, + ); + } + + const id = await createSession(); + const base = process.env.NEXT_PUBLIC_BASE_URL || 'https://glance.sh'; + + return Response.json({ id, url: `${base}/s/${id}` }); +} diff --git a/apps/web/app/api/upload/route.ts b/apps/web/app/api/upload/route.ts new file mode 100644 index 0000000..5b5f56b --- /dev/null +++ b/apps/web/app/api/upload/route.ts @@ -0,0 +1,65 @@ +import { type HandleUploadBody, handleUpload } from '@vercel/blob/client'; +import { NextResponse } from 'next/server'; + +import { + ALLOWED_IMAGE_TYPES, + MAX_ALLOWED_EXPIRY_AHEAD_MS, + MAX_UPLOAD_BYTES, + UPLOAD_TOKEN_TTL_MS, +} from '@/lib/config'; +import { parseToken, tokenFromPathname } from '@/lib/tokens'; +import { verifyUploadProof } from '@/lib/upload-proof'; + +export async function POST(request: Request): Promise { + const body = (await request.json()) as HandleUploadBody; + + try { + const response = await handleUpload({ + body, + request, + onBeforeGenerateToken: async (pathname, clientPayload) => { + const token = tokenFromPathname(pathname); + if (!token) { + throw new Error('Invalid upload pathname.'); + } + + const parsed = parseToken(token)!; + + if (parsed.expired) { + throw new Error('Upload token has already expired.'); + } + + if (parsed.expiresAt > Date.now() + MAX_ALLOWED_EXPIRY_AHEAD_MS) { + throw new Error('Upload token exceeds the allowed lifetime.'); + } + + const proofCheck = verifyUploadProof(clientPayload, { + token, + pathname, + tokenExpiresAt: parsed.expiresAt, + }); + + if (!proofCheck.ok) { + throw new Error(proofCheck.error); + } + + return { + addRandomSuffix: false, + allowedContentTypes: [...ALLOWED_IMAGE_TYPES, 'application/octet-stream'], + allowOverwrite: false, + cacheControlMaxAge: 60, + maximumSizeInBytes: MAX_UPLOAD_BYTES, + validUntil: Date.now() + UPLOAD_TOKEN_TTL_MS, + }; + }, + onUploadCompleted: async () => {}, + }); + + return NextResponse.json(response); + } catch (caught) { + const message = + caught instanceof Error ? caught.message : 'Could not upload image.'; + + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/apps/web/app/docs/opengraph-image.tsx b/apps/web/app/docs/opengraph-image.tsx new file mode 100644 index 0000000..09d01ca --- /dev/null +++ b/apps/web/app/docs/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Docs — Plugin setup for Claude Code, Codex CLI, OpenCode, and Pi.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function OGImage() { + return renderSocialImage({ + section: 'Docs', + title: 'Plugin setup for coding agents.', + description: 'Install glance for Claude Code, Codex CLI, OpenCode, and Pi.', + }); +} diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx new file mode 100644 index 0000000..060d53f --- /dev/null +++ b/apps/web/app/docs/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from 'next'; + +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { DocsCopyBlock } from '@/components/docs-copy-block'; +import { SiteFooter } from '@/components/site-footer'; + +export const metadata: Metadata = { + title: 'Plugins — glance.sh', + description: 'Install glance plugins for Claude Code, Codex CLI, OpenCode, and Pi.', + openGraph: { + title: 'Plugins — glance.sh', + description: 'Install glance plugins for Claude Code, Codex CLI, OpenCode, and Pi.', + url: 'https://glance.sh/docs', + }, + twitter: { + title: 'Plugins — glance.sh', + description: 'Install glance plugins for Claude Code, Codex CLI, OpenCode, and Pi.', + }, +}; + +export default function DocsPage() { + return ( +
+
+ +

Plugins

+

Share images directly with your active coding agent session.

+
+ +
+
+
+ Claude Code logo +

Claude Code

+
+
+
+

Add the marketplace:

+ +
+
+

Install the plugin:

+ +
+

Restart, then ask Claude Code to use the glance tool.

+
+
+ +
+
+ Codex logo +

Codex

+
+
+
+

Install the MCP server:

+ +
+
+

Verify setup:

+ +
+

Ask Codex to use the glance tool.

+
+
+ +
+
+ OpenCode logo +

OpenCode

+
+
+
+

+ Add the plugin to your opencode.json: +

+ +
+

Restart, then ask OpenCode to use the glance tool.

+
+
+ +
+
+ Pi logo +

Pi

+
+
+
+

Install the extension package:

+ +
+

Reload or restart pi, then use /glance.

+
+
+
+ + +
+ ); +} diff --git a/apps/web/app/docs/twitter-image.tsx b/apps/web/app/docs/twitter-image.tsx new file mode 100644 index 0000000..7385016 --- /dev/null +++ b/apps/web/app/docs/twitter-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Docs — Plugin setup for Claude Code, Codex CLI, OpenCode, and Pi.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function TwitterImage() { + return renderSocialImage({ + section: 'Docs', + title: 'Plugin setup for coding agents.', + description: 'Install glance for Claude Code, Codex CLI, OpenCode, and Pi.', + }); +} diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 0000000..4f9c8a9 --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..64a6a68 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,745 @@ +:root { + --bg: #09090b; + --surface: #18181b; + --surface-hover: #1c1c20; + --border: #27272a; + --border-strong: #3f3f46; + --text: #fafafa; + --text-secondary: #a1a1aa; + --text-muted: #71717a; + --accent: #22c55e; + --accent-hover: #16a34a; + --accent-text: #052e16; + --accent-soft: rgba(34, 197, 94, 0.06); + --accent-border: rgba(34, 197, 94, 0.15); + --error-soft: rgba(239, 68, 68, 0.06); + --error-border: rgba(239, 68, 68, 0.18); + --radius: 10px; + --radius-sm: 6px; +} + +* { + box-sizing: border-box; +} + +html { + color-scheme: dark; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-mono), ui-monospace, SFMono-Regular, "SF Mono", + Menlo, Consolas, monospace; + font-size: 15px; + line-height: 1.6; + margin: 0; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +pre, +code { + font: inherit; +} + +/* ---- Layout ---- */ + +.shell { + display: flex; + flex-direction: column; + gap: 28px; + margin: 0 auto; + max-width: 580px; + min-height: 100vh; + padding: 48px 24px 40px; +} + +/* ---- Typography ---- */ + +.eyebrow { + align-items: center; + color: var(--text-muted); + display: inline-flex; + font-size: 12px; + font-weight: 500; + gap: 6px; + letter-spacing: 0.12em; + margin: 0; + text-transform: uppercase; +} + +a.eyebrow { + transition: color 150ms; +} + +a.eyebrow:hover { + color: var(--text-secondary); +} + +.headline, +.result-title { + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.25; + margin: 0; +} + +.headline { + font-size: clamp(29px, 5.5vw, 43px); +} + +.lede, +.copy-muted { + color: var(--text-muted); + font-size: 14px; + line-height: 1.7; + margin: 0; +} + +.lede { +} + +/* ---- Hero ---- */ + +.hero-copy { + animation: rise 280ms ease-out both; + display: grid; + gap: 10px; +} + +.agent-plugin-callout { + animation: rise 280ms ease-out 30ms both; + background: var(--accent-soft); + border: 1px solid var(--accent-border); + border-radius: var(--radius); + padding: 10px 14px; +} + +.agent-plugin-callout-link { + align-items: center; + color: color-mix(in srgb, var(--accent) 70%, var(--text) 30%); + display: inline-flex; + font-size: 13px; + font-weight: 500; + gap: 8px; + line-height: 1.5; + transition: color 120ms; +} + +.agent-plugin-callout-link:hover { + color: var(--accent); +} + +/* ---- Dropzone ---- */ + +.uploader { + display: grid; + gap: 14px; + width: 100%; +} + +.dropzone { + animation: rise 280ms ease-out 60ms both; + background: var(--surface); + border: 1px dashed var(--border-strong); + transition: border-color 120ms, background 120ms; + border-radius: var(--radius); + display: grid; + gap: 12px; + min-height: 220px; + padding: 24px; + transition: border-color 200ms; +} + +.dropzone:hover { + border-color: var(--text-muted); +} + +.dropzone-active { + border-color: var(--text-primary); + background: color-mix(in srgb, var(--surface) 90%, var(--text-primary)); +} + +.dropzone-title { + font-size: clamp(19px, 3.5vw, 23px); + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; + margin: 0; +} + +.dropzone-copy { + color: var(--text-muted); + font-size: 13px; + letter-spacing: 0.06em; + margin: 0; +} + +.dropzone-meta { + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.08em; + line-height: 1.8; + margin: auto 0 0; + padding-top: 12px; + text-transform: uppercase; +} + +/* ---- Buttons ---- */ + +.button { + align-items: center; + background: var(--accent); + border: 0; + border-radius: var(--radius-sm); + color: var(--accent-text); + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 600; + gap: 6px; + justify-content: center; + min-height: 38px; + padding: 0 16px; + transition: + background-color 150ms, + opacity 150ms; +} + +.button:hover { + background: var(--accent-hover); +} + +.button:disabled { + cursor: wait; + opacity: 0.5; +} + +.button-secondary { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text); +} + +.button-secondary:hover { + background: var(--surface-hover); + border-color: var(--text-muted); +} + +.hidden-input { + display: none; +} + +/* ---- Status / Progress ---- */ + +.status-row { + background: var(--accent-soft); + border: 1px solid var(--accent-border); + border-radius: var(--radius-sm); + display: grid; + gap: 8px; + padding: 12px 14px; +} + +.status-error { + background: var(--error-soft); + border-color: var(--error-border); +} + +.status-label { + font-size: 14px; + line-height: 1.5; +} + +.progress-track { + background: var(--border); + border-radius: 999px; + height: 4px; + overflow: hidden; +} + +.progress-fill { + background: var(--accent); + border-radius: inherit; + height: 100%; + transition: width 200ms ease; +} + +/* ---- Upload list ---- */ + +.upload-list { + display: grid; + gap: 12px; +} + +.upload-list-head { + align-items: baseline; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 0 2px; +} + +/* ---- Upload cards ---- */ + +.upload-card-flagged { + background: var(--error-soft); + border-color: var(--error-border); +} + +.upload-card-removed { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 12px; + height: 100%; + justify-content: center; + letter-spacing: 0.06em; + min-height: 72px; + text-transform: uppercase; +} + +.upload-card { + align-items: stretch; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: grid; + gap: 14px; + grid-template-columns: 120px minmax(0, 1fr); + padding: 14px; +} + +.upload-card-preview { + background: #000; + border-radius: var(--radius-sm); + display: block; + min-height: 72px; + overflow: hidden; + align-self: stretch; +} + +.upload-card-image { + display: block; + height: 100%; + min-height: 100%; + object-fit: cover; + width: 100%; +} + +.upload-card-body { + display: grid; + gap: 10px; + min-width: 0; +} + +.url-bar { + align-items: stretch; + display: flex; + gap: 0; +} + +.url-bar .upload-card-url { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + flex: 1; + font-size: 12px; + min-width: 0; + overflow: hidden; + padding: 8px 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.url-bar-copy { + align-items: center; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-left: 0; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--text-muted); + cursor: pointer; + display: flex; + padding: 0 10px; + transition: color 120ms; +} + +.url-bar-copy:hover { + color: var(--text); +} + +.upload-card-expiry { + font-size: 12px; +} + +/* ---- Share ---- */ + +.share-url, +.share-command { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; + margin: 0; + overflow-wrap: anywhere; + padding: 12px 14px; + white-space: pre-wrap; +} + +.share-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* ---- Result page ---- */ + +.result-grid { + display: grid; + gap: 20px; + width: 100%; +} + +.preview-frame { + background: #000; + border: 1px solid var(--border); + border-radius: var(--radius); + min-height: 280px; + overflow: hidden; + padding: 12px; +} + +.preview-image { + border-radius: var(--radius-sm); + display: block; + max-height: 500px; + object-fit: contain; + width: 100%; +} + +.empty-preview { + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + display: flex; + justify-content: center; + min-height: 200px; + padding: 24px; + text-align: center; +} + +.share-card { + animation: rise 280ms ease-out 60ms both; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: grid; + gap: 14px; + padding: 20px; +} + +.result-title { + font-size: clamp(21px, 3.5vw, 27px); +} + +/* ---- Footer ---- */ + +.site-footer { + align-items: flex-start; + border-top: 1px solid var(--border); + display: flex; + gap: 18px; + justify-content: space-between; + margin-top: auto; + padding-top: 20px; +} + +.footer-copy { + display: grid; + flex: 1; + gap: 4px; + min-width: 0; +} + +.footer-nav { + align-items: center; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + font-size: 13px; + gap: 8px; + line-height: 1.6; +} + +.footer-link { + color: var(--text-secondary); + transition: color 120ms; +} + +.footer-link:hover { + color: var(--text); +} + +.footer-sep { + color: var(--text-muted); +} + +.footer-note { + color: var(--text-muted); + font-size: 13px; + line-height: 1.6; + margin: 0; +} + +.footer-icons { + align-items: center; + display: inline-flex; + flex: 0 0 auto; + gap: 12px; + justify-content: flex-end; + white-space: nowrap; +} + +.footer-icon { + opacity: 0.5; + transition: opacity 120ms; +} + +.footer-icon:hover { + opacity: 1; +} + +/* ---- Terms / docs page ---- */ + +.terms-body { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.7; +} + +.terms-body h2 { + color: var(--text); + font-size: 15px; + margin-top: 24px; + margin-bottom: 8px; +} + +.terms-body p { + margin-bottom: 12px; +} + +.terms-body ul { + margin: 0 0 12px 20px; + padding: 0; +} + +.terms-body li { + margin-bottom: 4px; +} + +.terms-body a { + color: var(--text); + text-decoration: underline; + text-underline-offset: 2px; +} + +.docs-body { + display: grid; + gap: 24px; +} + +.docs-section { + padding: 0; +} + +.docs-section + .docs-section { + padding-top: 24px; +} + +.docs-section-head { + align-items: center; + display: flex; + gap: 12px; + justify-content: flex-start; + margin-bottom: 10px; +} + +.docs-section h2 { + margin: 0; +} + +.docs-agent-logo { + display: block; + flex: 0 0 auto; + height: 24px; + width: auto; +} + +.docs-steps { + display: grid; + gap: 10px; + line-height: 1.85; + margin: 0; + padding: 0; +} + +.docs-step { + margin: 0; +} + +.docs-step p { + margin: 0; +} + +.docs-command-wrap { + margin-top: 10px; + position: relative; +} + +.docs-command-block { + margin-top: 0; + padding-right: 46px; +} + +.docs-command-copy { + align-items: center; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + display: inline-flex; + height: 28px; + justify-content: center; + position: absolute; + right: 10px; + top: 10px; + transition: color 120ms; + width: 28px; +} + +.docs-command-copy:hover { + color: var(--text); +} + +.docs-section code { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + color: var(--text); + padding: 0 4px; +} + +/* ---- Session ---- */ + +.eyebrow-row { + align-items: center; + display: flex; + justify-content: space-between; +} + +.session-live-badge { + align-items: center; + color: #22c55e; + display: inline-flex; + font-size: 12px; + gap: 6px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.session-live-dot { + background: #22c55e; + border-radius: 50%; + display: inline-block; + height: 8px; + width: 8px; + animation: pulse-dot 2s ease-in-out infinite; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.6); +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.session-sent { + color: #22c55e; +} + +.session-return-hint { + color: var(--text); +} + + +/* ---- Not found ---- */ + +.not-found { + align-items: center; + display: grid; + min-height: 100vh; + padding: 24px; +} + +/* ---- Animation ---- */ + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.spinner { + animation: spin 0.8s linear infinite; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ---- Responsive ---- */ + +@media (max-width: 560px) { + .shell { + gap: 24px; + padding: 32px 16px 28px; + } + + .upload-card { + grid-template-columns: 1fr; + } + + .upload-card-preview { + max-height: 180px; + } + + .share-actions { + display: grid; + grid-template-columns: 1fr; + } + + .button { + width: 100%; + } +} diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg new file mode 100644 index 0000000..dca7c84 --- /dev/null +++ b/apps/web/app/icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..3888fbb --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,47 @@ +import { Analytics } from '@vercel/analytics/next'; +import type { Metadata } from 'next'; +import { JetBrains_Mono } from 'next/font/google'; + +import '@/app/globals.css'; + +const mono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', + weight: ['400', '500', '600', '700'], +}); + +export const metadata: Metadata = { + metadataBase: new URL('https://glance.sh'), + title: 'glance.sh', + description: 'Share an image with your coding agent. For terminal environments where copy/pasting images is hard.', + referrer: 'no-referrer', + openGraph: { + title: 'glance.sh — Share an image with your coding agent', + description: 'Paste a screenshot, get a temporary URL. Built for terminal environments, coding agents, and CLI workflows where copy/pasting images is hard.', + siteName: 'glance.sh', + url: 'https://glance.sh', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'glance.sh — Share an image with your coding agent', + description: 'Paste a screenshot, get a temporary URL. Built for terminal environments, coding agents, and CLI workflows where copy/pasting images is hard.', + }, +}; + +const analyticsEnabled = process.env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED === '1'; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + {analyticsEnabled ? : null} + + + ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..16146f2 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+
+

404

+

Not here.

+

Bad token, missing upload, or already cleaned up.

+
+ + New paste + +
+
+
+ ); +} diff --git a/apps/web/app/opengraph-image.tsx b/apps/web/app/opengraph-image.tsx new file mode 100644 index 0000000..32e0c91 --- /dev/null +++ b/apps/web/app/opengraph-image.tsx @@ -0,0 +1,13 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh — Share an image with your coding agent.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function OGImage() { + return renderSocialImage({ + title: 'Share an image with your coding agent.', + description: 'For terminal environments where copy/pasting images is hard.', + }); +} diff --git a/apps/web/app/p/[token]/page.tsx b/apps/web/app/p/[token]/page.tsx new file mode 100644 index 0000000..cc84c0c --- /dev/null +++ b/apps/web/app/p/[token]/page.tsx @@ -0,0 +1,93 @@ +import { head } from '@vercel/blob'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { ShareActions } from '@/components/share-actions'; +import { + assetFilenameForToken, + describeExpiry, + extensionForContentType, + formatExpiryUtc, + parseToken, + pathForToken, + sharePathForToken, +} from '@/lib/tokens'; +import { getBaseUrl } from '@/lib/url'; + +type ResultPageProps = { + params: Promise<{ token: string }>; +}; + +export default async function ResultPage({ params }: ResultPageProps) { + const { token } = await params; + const parsed = parseToken(token); + + if (!parsed) { + notFound(); + } + + let extension: string | null = null; + + if (!parsed.expired) { + try { + const blob = await head(pathForToken(token)); + extension = extensionForContentType(blob.contentType); + } catch { + extension = null; + } + } + + const baseUrl = await getBaseUrl(); + const sharePath = sharePathForToken(token, extension); + const shareUrl = baseUrl ? `${baseUrl}${sharePath}` : sharePath; + const curlCommand = `curl -fsSL "${shareUrl}" -o /tmp/${assetFilenameForToken(token, extension)}`; + + return ( +
+ + +
+
+ {!parsed.expired ? ( + Uploaded image preview + ) : ( +
+

This link has expired. Paste a new image to mint a fresh URL.

+
+ )} +
+ +
+
+

{parsed.expired ? 'expired' : 'ready'}

+

+ {parsed.expired ? 'Expired.' : 'Share.'} +

+

+ {parsed.expired + ? `Expired ${formatExpiryUtc(parsed.expiresAt)}` + : `${describeExpiry(parsed.expiresAt)} · ${formatExpiryUtc(parsed.expiresAt)}`} +

+
+ + {!parsed.expired ? ( + <> +
{shareUrl}
+
{curlCommand}
+ + + ) : ( + + New paste + + )} +
+
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..fab0b26 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,28 @@ +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { PasteUploader } from '@/components/paste-uploader'; +import { SiteFooter } from '@/components/site-footer'; + +export default function HomePage() { + return ( +
+
+ +

Share an image with your coding agent.

+

+ For agent environments where copy/pasting images is hard. +

+
+ + + + + + +
+ ); +} diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts new file mode 100644 index 0000000..d540770 --- /dev/null +++ b/apps/web/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from 'next'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: '*', + allow: '/', + disallow: ['/p/'], + }, + ], + }; +} diff --git a/apps/web/app/s/[id]/page.tsx b/apps/web/app/s/[id]/page.tsx new file mode 100644 index 0000000..0d4b5c0 --- /dev/null +++ b/apps/web/app/s/[id]/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from 'next/navigation'; + +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { SessionUploader } from '@/components/session-uploader'; +import { SiteFooter } from '@/components/site-footer'; +import { sessionExists } from '@/lib/sessions'; + +export default async function SessionPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + if (!(await sessionExists(id))) { + notFound(); + } + + return ( +
+
+
+ + + + Live session + +
+

Paste an image for your agent.

+

+ Your coding agent is waiting. Paste or drop an image and it'll receive the link instantly. +

+
+ + + + +
+ ); +} diff --git a/apps/web/app/security/opengraph-image.tsx b/apps/web/app/security/opengraph-image.tsx new file mode 100644 index 0000000..660f517 --- /dev/null +++ b/apps/web/app/security/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Security — Encryption at rest, private storage, and automatic deletion.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function OGImage() { + return renderSocialImage({ + section: 'Security', + title: 'Private by default, ephemeral by design.', + description: 'See how glance.sh handles storage, expiry, encryption, and infrastructure.', + }); +} diff --git a/apps/web/app/security/page.tsx b/apps/web/app/security/page.tsx new file mode 100644 index 0000000..3bc28e4 --- /dev/null +++ b/apps/web/app/security/page.tsx @@ -0,0 +1,105 @@ +import type { Metadata } from 'next'; + +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { SiteFooter } from '@/components/site-footer'; + +const SECURITY_DESCRIPTION = + 'Private storage, encryption at rest, short-lived links, and automatic deletion for glance.sh uploads.'; + +export const metadata: Metadata = { + title: 'Security — glance.sh', + description: SECURITY_DESCRIPTION, + openGraph: { + title: 'Security — glance.sh', + description: SECURITY_DESCRIPTION, + url: 'https://glance.sh/security', + }, + twitter: { + title: 'Security — glance.sh', + description: SECURITY_DESCRIPTION, + }, +}; + +export default function SecurityPage() { + return ( +
+
+ +

Security

+
+ +
+

+ glance.sh is designed to be ephemeral. Images are temporary, + storage is private, and data is automatically deleted. Here is how + we protect your uploads. +

+ +

Private storage

+

+ All uploads are stored in a private Vercel Blob store. Blobs are + never publicly accessible. Every request is validated and streamed + through the application, which checks expiry before serving any + content. +

+ +

Encryption at rest

+

+ Every image is encrypted in your browser before it leaves your + device. The encryption key is derived from the share token embedded + in the URL, so only someone with the exact link can decrypt the + image. The server and blob store never see plaintext image data. +

+ +

Automatic expiry & deletion

+

+ Every upload is assigned a short-lived token that expires within + 30 minutes. After expiry, the image can no longer be accessed. + Expired blobs are permanently deleted shortly after expiration. +

+ +

No accounts, no tracking

+

+ glance.sh does not require sign-up, does not set cookies, and does + not collect personal information. +

+ +

Link security

+

+ Uploaded images are only accessible via a URL with an embedded + unique token, generated and displayed once. Each token is created + using node:crypto with over 768 quadrillion + possible combinations. Only someone with the exact link can access + an image, and only before it expires. +

+ +

Infrastructure

+

+ glance.sh runs on Vercel with TLS everywhere. All traffic between + your browser, the application, and storage is encrypted in transit. +

+ +

Reporting issues

+

+ If you discover a security vulnerability or have concerns, contact{' '} + security@modem.dev. +

+ +

Sub-processors

+

+ The following third-party services process data on our behalf: +

+
    +
  • Vercel — hosting, edge network, and blob storage
  • +
  • Upstash — Redis for live-session coordination
  • +
+ +

+ Last updated: March 2026 +

+
+ + +
+ ); +} diff --git a/apps/web/app/security/twitter-image.tsx b/apps/web/app/security/twitter-image.tsx new file mode 100644 index 0000000..ab40533 --- /dev/null +++ b/apps/web/app/security/twitter-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Security — Encryption at rest, private storage, and automatic deletion.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function TwitterImage() { + return renderSocialImage({ + section: 'Security', + title: 'Private by default, ephemeral by design.', + description: 'See how glance.sh handles storage, expiry, encryption, and infrastructure.', + }); +} diff --git a/apps/web/app/terms/opengraph-image.tsx b/apps/web/app/terms/opengraph-image.tsx new file mode 100644 index 0000000..b699a6a --- /dev/null +++ b/apps/web/app/terms/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Terms — Acceptable use and service terms.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function OGImage() { + return renderSocialImage({ + section: 'Terms', + title: 'Terms of Service', + description: 'Acceptable use and legal terms for glance.sh.', + }); +} diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx new file mode 100644 index 0000000..7754e41 --- /dev/null +++ b/apps/web/app/terms/page.tsx @@ -0,0 +1,96 @@ +import type { Metadata } from 'next'; + +import { BrandEyebrow } from '@/components/brand-eyebrow'; +import { SiteFooter } from '@/components/site-footer'; + +export const metadata: Metadata = { + title: 'Terms of Service — glance.sh', + description: 'Acceptable use policy and service terms for glance.sh.', + openGraph: { + title: 'Terms of Service — glance.sh', + description: 'Acceptable use policy and service terms for glance.sh.', + url: 'https://glance.sh/terms', + }, + twitter: { + title: 'Terms of Service — glance.sh', + description: 'Acceptable use policy and service terms for glance.sh.', + }, +}; + +export default function TermsPage() { + return ( +
+
+ +

Terms of Service

+
+ +
+

+ glance.sh is operated by{' '} + + Modem Labs Inc. + {' '} + ("Modem"). By using this service you agree to these terms + and to{' '} + + Modem's Terms of Service + + . +

+ +

What this service does

+

+ glance.sh accepts image uploads and provides temporary, shareable + URLs. All uploads auto-expire within 30 minutes and are permanently + deleted. +

+ +

Acceptable use

+

You may not upload content that is:

+
    +
  • Illegal, abusive, or harmful
  • +
  • Sexually explicit or exploitative
  • +
  • Infringing on intellectual property rights
  • +
  • Malware, phishing, or deceptive material
  • +
+

+ Uploads are automatically screened. Content that violates this policy + is deleted immediately and may result in your access being + restricted. +

+ +

No warranty

+

+ This service is provided "as is" without warranty of any + kind. Modem may modify, suspend, or discontinue glance.sh at any + time without notice. We are not responsible for any data loss. +

+ +

Content removal & abuse

+

+ Modem reserves the right to delete any uploaded content at any time + for any reason. To report abuse or request content removal, contact{' '} + support@modem.dev. +

+ +

Liability

+

+ To the maximum extent permitted by law, Modem shall not be liable + for any indirect, incidental, special, or consequential damages + arising from your use of this service. +

+ +

+ Last updated: March 2026 +

+
+ + +
+ ); +} diff --git a/apps/web/app/terms/twitter-image.tsx b/apps/web/app/terms/twitter-image.tsx new file mode 100644 index 0000000..7314416 --- /dev/null +++ b/apps/web/app/terms/twitter-image.tsx @@ -0,0 +1,14 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh Terms — Acceptable use and service terms.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function TwitterImage() { + return renderSocialImage({ + section: 'Terms', + title: 'Terms of Service', + description: 'Acceptable use and legal terms for glance.sh.', + }); +} diff --git a/apps/web/app/twitter-image.tsx b/apps/web/app/twitter-image.tsx new file mode 100644 index 0000000..d3c98dd --- /dev/null +++ b/apps/web/app/twitter-image.tsx @@ -0,0 +1,13 @@ +import { renderSocialImage } from '@/lib/social-image'; + +export const runtime = 'nodejs'; +export const alt = 'glance.sh — Share an image with your coding agent.'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function TwitterImage() { + return renderSocialImage({ + title: 'Share an image with your coding agent.', + description: 'For terminal environments where copy/pasting images is hard.', + }); +} diff --git a/apps/web/components/brand-eyebrow.tsx b/apps/web/components/brand-eyebrow.tsx new file mode 100644 index 0000000..43e0cda --- /dev/null +++ b/apps/web/components/brand-eyebrow.tsx @@ -0,0 +1,52 @@ +import Link from 'next/link'; + +type BrandEyebrowProps = { + href?: string; +}; + +function BrandIcon() { + return ( + + ); +} + +export function BrandEyebrow({ href }: BrandEyebrowProps) { + const content = ( + <> + + glance.sh + + ); + + if (href) { + return ( + + {content} + + ); + } + + return

{content}

; +} diff --git a/apps/web/components/docs-copy-block.tsx b/apps/web/components/docs-copy-block.tsx new file mode 100644 index 0000000..b6d139d --- /dev/null +++ b/apps/web/components/docs-copy-block.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useState } from 'react'; + +import { CheckIcon, CopyIcon } from '@/components/share-actions'; + +type DocsCopyBlockProps = { + code: string; + copyLabel?: string; +}; + +export function DocsCopyBlock({ code, copyLabel = 'Copy snippet' }: DocsCopyBlockProps) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await navigator.clipboard.writeText(code); + setCopied(true); + + window.setTimeout(() => { + setCopied(false); + }, 1_500); + } + + return ( +
+
{code}
+ +
+ ); +} diff --git a/apps/web/components/paste-uploader.tsx b/apps/web/components/paste-uploader.tsx new file mode 100644 index 0000000..73e95f6 --- /dev/null +++ b/apps/web/components/paste-uploader.tsx @@ -0,0 +1,341 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import { upload } from '@vercel/blob/client'; +import { useEffect, useRef, useState } from 'react'; + +import { CheckIcon, CopyIcon, ShareActions } from '@/components/share-actions'; +import { encrypt } from '@/lib/encryption'; +import { MAX_UPLOAD_MB, SHARE_TTL_LABEL } from '@/lib/config'; +import { + assetFilenameForToken, + describeExpiry, + extensionForContentType, + formatExpiryUtc, + sharePathForToken, +} from '@/lib/share'; + +type IssueResponse = { + expiresAt: number; + pathname: string; + token: string; + uploadProof: string; +}; + +type UploadState = 'idle' | 'issuing' | 'uploading' | 'error'; + +type UploadRecord = { + curlCommand: string; + expiresAt: number; + filename: string; + sharePath: string; + shareUrl: string; + token: string; +}; + +const ACCEPTED_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif', +]; + +export function PasteUploader() { + const inputRef = useRef(null); + const [pasteHint, setPasteHint] = useState('Paste (⌘V)'); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [activeFileName, setActiveFileName] = useState(null); + const [uploads, setUploads] = useState([]); + const [copiedUrl, setCopiedUrl] = useState(null); + + useEffect(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + setPasteHint(isMac ? 'Paste (⌘V)' : 'Paste (Ctrl+V)'); + }, []); + + type UploadSource = 'paste' | 'drag' | 'choose'; + + async function handleFile(file: File, source: UploadSource = 'choose') { + if (status === 'issuing' || status === 'uploading') { + return; + } + + if (!ACCEPTED_TYPES.includes(file.type)) { + setError('Only PNG, JPG, WebP, GIF, and AVIF images are allowed.'); + setStatus('error'); + return; + } + + if (file.size > MAX_UPLOAD_MB * 1024 * 1024) { + setError(`Images must be ${MAX_UPLOAD_MB} MB or smaller.`); + setStatus('error'); + return; + } + + setError(null); + setProgress(0); + setActiveFileName(file.name || 'clipboard-image'); + setStatus('issuing'); + + const issueResponse = await fetch('/api/issue', { + method: 'POST', + cache: 'no-store', + }); + + if (!issueResponse.ok) { + const payload = (await issueResponse.json().catch(() => null)) as + | { error?: string } + | null; + + setError(payload?.error ?? 'Could not prepare the upload.'); + setStatus('error'); + return; + } + + const issued = (await issueResponse.json()) as IssueResponse; + + try { + setStatus('uploading'); + + const plaintext = await file.arrayBuffer(); + const ciphertext = await encrypt(plaintext, file.type, issued.token); + const encryptedBlob = new Blob([ciphertext], { + type: 'application/octet-stream', + }); + + await upload(issued.pathname, encryptedBlob, { + access: 'private', + clientPayload: issued.uploadProof, + contentType: 'application/octet-stream', + handleUploadUrl: '/api/upload', + onUploadProgress(event) { + setProgress(event.percentage); + }, + }); + + const extension = extensionForContentType(file.type); + const sharePath = sharePathForToken(issued.token, extension); + const origin = window.location.origin.replace(/\/\/www\./i, '//'); + const shareUrl = `${origin}${sharePath}`; + const filename = assetFilenameForToken(issued.token, extension); + + setUploads((current) => [ + { + token: issued.token, + expiresAt: issued.expiresAt, + filename, + sharePath, + shareUrl, + curlCommand: `curl -fsSL "${shareUrl}" -o /tmp/${filename}`, + }, + ...current, + ]); + + setStatus('idle'); + setProgress(0); + setActiveFileName(null); + + Sentry.metrics.count('upload.completed', 1, { + attributes: { source }, + }); + } catch (caught) { + const message = + caught instanceof Error ? caught.message : 'Upload failed.'; + + setError(message); + setStatus('error'); + } + } + + useEffect(() => { + function onPaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items || status === 'issuing' || status === 'uploading') { + return; + } + + for (const item of items) { + if (!item.type.startsWith('image/')) { + continue; + } + + const file = item.getAsFile(); + if (!file) { + continue; + } + + event.preventDefault(); + void handleFile(file, 'paste'); + return; + } + } + + window.addEventListener('paste', onPaste); + return () => { + window.removeEventListener('paste', onPaste); + }; + }, [status]); + + const [dragging, setDragging] = useState(false); + const dragCounter = useRef(0); + + useEffect(() => { + function onDragEnter(e: DragEvent) { + e.preventDefault(); + dragCounter.current += 1; + setDragging(true); + } + + function onDragOver(e: DragEvent) { + e.preventDefault(); + } + + function onDragLeave(e: DragEvent) { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current <= 0) { + dragCounter.current = 0; + setDragging(false); + } + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + dragCounter.current = 0; + setDragging(false); + const file = e.dataTransfer?.files[0]; + if (file) void handleFile(file, 'drag'); + } + + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [status]); + + return ( +
+
+

{pasteHint}

+

or drag & drop / choose an image

+ + + + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + + if (!file) { + return; + } + + void handleFile(file); + }} + ref={inputRef} + type="file" + /> + +

+ PNG JPG WEBP GIF AVIF · {MAX_UPLOAD_MB} MB · {SHARE_TTL_LABEL} TTL +

+
+ + {status !== 'idle' && ( +
+ + {status === 'issuing' && 'Preparing upload token...'} + {status === 'uploading' && + `Uploading ${activeFileName ?? 'image'}... ${Math.round(progress)}%`} + {status === 'error' && error} + + + {status === 'uploading' && ( + + )} + + {uploads.length > 0 ? ( +
+
+

Recent

+

+ {uploads.length} link{uploads.length === 1 ? '' : 's'} +

+
+ + {uploads.map((uploadItem) => ( +
+ + Uploaded image preview + + +
+
+
{uploadItem.shareUrl}
+ +
+ +

+ {describeExpiry(uploadItem.expiresAt)} · {formatExpiryUtc(uploadItem.expiresAt)} +

+
+
+ ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/components/session-uploader.tsx b/apps/web/components/session-uploader.tsx new file mode 100644 index 0000000..14bebc4 --- /dev/null +++ b/apps/web/components/session-uploader.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { upload } from '@vercel/blob/client'; +import { useEffect, useRef, useState } from 'react'; + +import { CheckIcon, CopyIcon, ShareActions } from '@/components/share-actions'; +import { encrypt } from '@/lib/encryption'; +import { MAX_UPLOAD_MB, SHARE_TTL_LABEL } from '@/lib/config'; +import { + assetFilenameForToken, + describeExpiry, + extensionForContentType, + formatExpiryUtc, + sharePathForToken, +} from '@/lib/share'; + +const ACCEPTED_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif', +]; + +type UploadState = 'idle' | 'issuing' | 'uploading' | 'sent' | 'error'; + +type IssueResponse = { + expiresAt: number; + pathname: string; + token: string; + uploadProof: string; +}; + +type SentRecord = { + curlCommand: string; + expiresAt: number; + sharePath: string; + shareUrl: string; + token: string; +}; + +export function SessionUploader({ sessionId }: { sessionId: string }) { + const inputRef = useRef(null); + const [pasteHint, setPasteHint] = useState('Paste (⌘V)'); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [sent, setSent] = useState([]); + const [copiedUrl, setCopiedUrl] = useState(null); + + useEffect(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + setPasteHint(isMac ? 'Paste (⌘V)' : 'Paste (Ctrl+V)'); + }, []); + + async function handleFile(file: File) { + if (status === 'issuing' || status === 'uploading') return; + + if (!ACCEPTED_TYPES.includes(file.type)) { + setError('Only PNG, JPG, WebP, GIF, and AVIF images are allowed.'); + setStatus('error'); + return; + } + + if (file.size > MAX_UPLOAD_MB * 1024 * 1024) { + setError(`Images must be ${MAX_UPLOAD_MB} MB or smaller.`); + setStatus('error'); + return; + } + + setError(null); + setProgress(0); + setStatus('issuing'); + + try { + const issueRes = await fetch('/api/issue', { + method: 'POST', + cache: 'no-store', + }); + + if (!issueRes.ok) { + const payload = (await issueRes.json().catch(() => null)) as + | { error?: string } + | null; + setError(payload?.error ?? 'Could not prepare the upload.'); + setStatus('error'); + return; + } + + const issued = (await issueRes.json()) as IssueResponse; + setStatus('uploading'); + + const plaintext = await file.arrayBuffer(); + const ciphertext = await encrypt(plaintext, file.type, issued.token); + const encryptedBlob = new Blob([ciphertext], { + type: 'application/octet-stream', + }); + + await upload(issued.pathname, encryptedBlob, { + access: 'private', + clientPayload: issued.uploadProof, + contentType: 'application/octet-stream', + handleUploadUrl: '/api/upload', + onUploadProgress(event) { + setProgress(event.percentage); + }, + }); + + const extension = extensionForContentType(file.type); + const sharePath = sharePathForToken(issued.token, extension); + const origin = window.location.origin.replace(/\/\/www\./i, '//'); + const shareUrl = `${origin}${sharePath}`; + + // Push to session + const pushRes = await fetch(`/api/session/${sessionId}/push`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: shareUrl, expiresAt: issued.expiresAt }), + }); + + if (!pushRes.ok) { + setError('Session expired or not found.'); + setStatus('error'); + return; + } + + const filename = assetFilenameForToken(issued.token, extension); + + setSent((prev) => [ + { + token: issued.token, + sharePath, + shareUrl, + expiresAt: issued.expiresAt, + curlCommand: `curl -fsSL "${shareUrl}" -o /tmp/${filename}`, + }, + ...prev, + ]); + setStatus('sent'); + setProgress(0); + + // Reset to idle after a moment so user can paste again + setTimeout(() => setStatus('idle'), 2000); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Upload failed.'); + setStatus('error'); + } + } + + // Global paste listener + useEffect(() => { + function onPaste(event: ClipboardEvent) { + const items = event.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (!item.type.startsWith('image/')) continue; + const file = item.getAsFile(); + if (!file) continue; + event.preventDefault(); + void handleFile(file); + return; + } + } + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }, [status]); + + // Global drag & drop + const [dragging, setDragging] = useState(false); + const dragCounter = useRef(0); + + useEffect(() => { + function onDragEnter(e: DragEvent) { + e.preventDefault(); + dragCounter.current += 1; + setDragging(true); + } + function onDragOver(e: DragEvent) { + e.preventDefault(); + } + function onDragLeave(e: DragEvent) { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current <= 0) { + dragCounter.current = 0; + setDragging(false); + } + } + function onDrop(e: DragEvent) { + e.preventDefault(); + dragCounter.current = 0; + setDragging(false); + const file = e.dataTransfer?.files[0]; + if (file) void handleFile(file); + } + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [status]); + + return ( +
+
+ {status === 'sent' ? ( + <> +

✓ Sent to agent

+

+ Switch back to your agent — it received the image url +

+ + ) : ( + <> +

{pasteHint}

+

or drag & drop / choose an image

+ + )} + + + + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (file) void handleFile(file); + }} + ref={inputRef} + type="file" + /> + +

+ PNG JPG WEBP GIF AVIF · {MAX_UPLOAD_MB} MB · {SHARE_TTL_LABEL} TTL +

+
+ + {(status === 'issuing' || status === 'uploading' || status === 'error') && ( +
+ + {status === 'issuing' && 'Preparing upload...'} + {status === 'uploading' && `Uploading... ${Math.round(progress)}%`} + {status === 'error' && error} + + + {status === 'uploading' && ( + + )} + + {sent.length > 0 && ( +
+
+

Sent

+

+ {sent.length} image{sent.length === 1 ? '' : 's'} +

+
+ + {sent.map((item) => ( +
+ + Uploaded image preview + + +
+
+
{item.shareUrl}
+ +
+ + + +

+ {describeExpiry(item.expiresAt)} · {formatExpiryUtc(item.expiresAt)} +

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/share-actions.tsx b/apps/web/components/share-actions.tsx new file mode 100644 index 0000000..6d43372 --- /dev/null +++ b/apps/web/components/share-actions.tsx @@ -0,0 +1,135 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +type CopyTarget = 'curl' | 'ocr' | 'prompt' | 'url' | null; +type OcrState = 'idle' | 'loading' | 'done' | 'error'; + +type ShareActionsProps = { + curlCommand: string; + homeHref?: string; + shareUrl: string; + token?: string; +}; + +export function CopyIcon() { + return ( + + ); +} + +export function CheckIcon() { + return ( + + ); +} + +function ScanIcon() { + return ( + + ); +} + +function SpinnerIcon() { + return ( + + ); +} + +export function ShareActions({ + curlCommand, + homeHref, + shareUrl, + token, +}: ShareActionsProps) { + const [copied, setCopied] = useState(null); + const [ocrState, setOcrState] = useState('idle'); + + async function copyText(value: string, target: Exclude) { + await navigator.clipboard.writeText(value); + setCopied(target); + + window.setTimeout(() => { + setCopied((current) => (current === target ? null : current)); + }, 1_500); + } + + async function handleOcr() { + if (!token || ocrState === 'loading') return; + + setOcrState('loading'); + + try { + const res = await fetch('/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!res.ok) { + const payload = await res.json().catch(() => null) as { error?: string } | null; + throw new Error(payload?.error ?? 'OCR failed'); + } + + const { text } = await res.json() as { text: string }; + await navigator.clipboard.writeText(text); + setOcrState('done'); + setCopied('ocr'); + + window.setTimeout(() => { + setCopied((current) => (current === 'ocr' ? null : current)); + setOcrState('idle'); + }, 1_500); + } catch { + setOcrState('error'); + window.setTimeout(() => setOcrState('idle'), 2_000); + } + } + + return ( +
+ + + {token ? ( + + ) : null} + + + + {homeHref ? ( + + New paste + + ) : null} +
+ ); +} diff --git a/apps/web/components/site-footer.tsx b/apps/web/components/site-footer.tsx new file mode 100644 index 0000000..473b039 --- /dev/null +++ b/apps/web/components/site-footer.tsx @@ -0,0 +1,60 @@ +import Link from 'next/link'; + +export function SiteFooter() { + return ( +
+
+ + +

+ Brought to you by{' '} + + Modem + + , your auto-triage PM +

+
+ + +
+ ); +} diff --git a/apps/web/docs/testing.md b/apps/web/docs/testing.md new file mode 100644 index 0000000..0ae27de --- /dev/null +++ b/apps/web/docs/testing.md @@ -0,0 +1,52 @@ +# Testing + +The web app uses Vitest for unit and route-contract tests. + +## Commands + +```bash +npm test # run all tests once +npm run test:watch # watch mode +npm run test:coverage +npm run typecheck +npm run build +npm run test:e2e:live +``` + +`test:e2e:live` is an env-gated live E2E suite that exercises security-critical +flows against a running app: + +- upload-proof issuance/upload validation +- session SSE contract (`/api/session`, `/events`, `/push`) + +Configuration: + +- `RUN_LIVE_E2E=1` enables execution (otherwise the suite is skipped) +- `LIVE_E2E_BASE_URL` sets the target app URL (defaults to `http://127.0.0.1:3000`) + +## Coverage policy + +Minimum thresholds: + +- lines >= 85% +- branches >= 80% +- functions >= 85% +- statements >= 85% + +Security-critical app routes are explicitly included in coverage targeting, +including: + +- `app/[token]/route.ts` +- `app/api/issue/route.ts` +- `app/api/upload/route.ts` +- `app/api/ocr/route.ts` +- `app/api/cron/*` +- `app/api/session/*` + +The full agent plugin matrix is tested from the repository root. + +## CI behavior + +Public CI should run web tests/builds and plugin tests without production +secrets. Live E2E suites should remain opt-in and must not expose hosted-service +secrets to untrusted PRs. diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts new file mode 100644 index 0000000..9e2c811 --- /dev/null +++ b/apps/web/instrumentation-client.ts @@ -0,0 +1,37 @@ +// This file configures Sentry on the client when a DSN is provided. +// Self-hosted installs are telemetry-free by default. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; + +if (dsn) { + Sentry.init({ + dsn, + tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? '0.1'), + enableLogs: process.env.NEXT_PUBLIC_SENTRY_ENABLE_LOGS === '1', + + // Minimize client-side PII collection by default. + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: false, + + beforeSend(event) { + // Defense-in-depth scrub: keep diagnostics, drop direct user identifiers. + if (event.user) { + delete event.user.email; + delete event.user.id; + delete event.user.ip_address; + delete event.user.username; + + if (Object.keys(event.user).length === 0) { + delete event.user; + } + } + + return event; + }, + }); +} + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts new file mode 100644 index 0000000..7cbe93c --- /dev/null +++ b/apps/web/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/apps/web/lib/config.env.test.ts b/apps/web/lib/config.env.test.ts new file mode 100644 index 0000000..7f6f038 --- /dev/null +++ b/apps/web/lib/config.env.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ORIGINAL_ENV = { ...process.env }; + +function clearConfigEnv() { + delete process.env.GLANCE_TTL_MINUTES; + delete process.env.GLANCE_MAX_UPLOAD_MB; + delete process.env.GLANCE_UPLOAD_TOKEN_TTL_SECONDS; + delete process.env.GLANCE_CLEANUP_BATCH_SIZE; + delete process.env.AGENTPASTE_TTL_MINUTES; + delete process.env.AGENTPASTE_MAX_UPLOAD_MB; + delete process.env.AGENTPASTE_UPLOAD_TOKEN_TTL_SECONDS; + delete process.env.AGENTPASTE_CLEANUP_BATCH_SIZE; +} + +async function loadConfig() { + vi.resetModules(); + return import('./config'); +} + +describe('config env parsing', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + clearConfigEnv(); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('falls back for non-numeric env values', async () => { + process.env.GLANCE_TTL_MINUTES = 'abc'; + process.env.GLANCE_MAX_UPLOAD_MB = 'NaN'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_MINUTES).toBe(30); + expect(config.MAX_UPLOAD_MB).toBe(15); + }); + + it('clamps values to configured minimums', async () => { + process.env.GLANCE_TTL_MINUTES = '1'; + process.env.GLANCE_MAX_UPLOAD_MB = '0'; + process.env.GLANCE_UPLOAD_TOKEN_TTL_SECONDS = '1'; + process.env.GLANCE_CLEANUP_BATCH_SIZE = '0'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_MINUTES).toBe(5); + expect(config.MAX_UPLOAD_MB).toBe(1); + expect(config.UPLOAD_TOKEN_TTL_MS).toBe(60_000); + expect(config.CLEANUP_BATCH_SIZE).toBe(1); + }); + + it('clamps values to configured maximums', async () => { + process.env.GLANCE_TTL_MINUTES = '99999'; + process.env.GLANCE_MAX_UPLOAD_MB = '999'; + process.env.GLANCE_UPLOAD_TOKEN_TTL_SECONDS = '99999'; + process.env.GLANCE_CLEANUP_BATCH_SIZE = '99999'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_MINUTES).toBe(1440); + expect(config.MAX_UPLOAD_MB).toBe(64); + expect(config.UPLOAD_TOKEN_TTL_MS).toBe(3_600_000); + expect(config.CLEANUP_BATCH_SIZE).toBe(1000); + }); + + it('renders hour TTL labels when divisible by 60', async () => { + process.env.GLANCE_TTL_MINUTES = '120'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_LABEL).toBe('2h'); + }); + + it('keeps AGENTPASTE env names as compatibility fallbacks', async () => { + process.env.AGENTPASTE_TTL_MINUTES = '60'; + process.env.AGENTPASTE_MAX_UPLOAD_MB = '20'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_MINUTES).toBe(60); + expect(config.MAX_UPLOAD_MB).toBe(20); + }); + + it('prefers GLANCE env names over legacy AGENTPASTE names', async () => { + process.env.GLANCE_TTL_MINUTES = '45'; + process.env.AGENTPASTE_TTL_MINUTES = '60'; + + const config = await loadConfig(); + + expect(config.SHARE_TTL_MINUTES).toBe(45); + }); +}); diff --git a/apps/web/lib/config.test.ts b/apps/web/lib/config.test.ts new file mode 100644 index 0000000..8f616df --- /dev/null +++ b/apps/web/lib/config.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + ALLOWED_IMAGE_TYPES, + CLEANUP_BATCH_SIZE, + MAX_UPLOAD_BYTES, + MAX_UPLOAD_MB, + MINUTE_MS, + SHARE_TTL_LABEL, + SHARE_TTL_MINUTES, + SHARE_TTL_MS, + TOKEN_PREFIX_LENGTH, + TOKEN_RANDOM_LENGTH, + UPLOAD_TOKEN_TTL_MS, +} from './config'; + +describe('config defaults', () => { + it('default TTL is 30 minutes', () => { + expect(SHARE_TTL_MINUTES).toBe(30); + expect(SHARE_TTL_MS).toBe(30 * MINUTE_MS); + }); + + it('TTL label is 30m at default', () => { + expect(SHARE_TTL_LABEL).toBe('30m'); + }); + + it('token dimensions add up to 20', () => { + expect(TOKEN_PREFIX_LENGTH + TOKEN_RANDOM_LENGTH).toBe(20); + }); + + it('max upload is 15 MB by default', () => { + expect(MAX_UPLOAD_MB).toBe(15); + expect(MAX_UPLOAD_BYTES).toBe(15 * 1024 * 1024); + }); + + it('upload token TTL default is 5 minutes', () => { + expect(UPLOAD_TOKEN_TTL_MS).toBe(300_000); + }); + + it('cleanup batch size default is 100', () => { + expect(CLEANUP_BATCH_SIZE).toBe(100); + }); + + it('allowed image types include the 5 supported formats', () => { + expect(ALLOWED_IMAGE_TYPES).toEqual([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif', + ]); + }); +}); diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts new file mode 100644 index 0000000..ba7c5dc --- /dev/null +++ b/apps/web/lib/config.ts @@ -0,0 +1,96 @@ +export const APP_NAME = 'glance-sh'; +export const TOKEN_PREFIX_LENGTH = 5; +export const TOKEN_RANDOM_LENGTH = 15; +export const TOKEN_EPOCH_MS = Date.UTC(2025, 0, 1); +export const MINUTE_MS = 60_000; +export const HOUR_MS = 60 * MINUTE_MS; +export const UPLOADS_PREFIX = 'uploads/'; + +const DEFAULT_TTL_MINUTES = 30; +const DEFAULT_MAX_UPLOAD_MB = 15; +const DEFAULT_UPLOAD_TOKEN_TTL_SECONDS = 300; +const DEFAULT_CLEANUP_BATCH_SIZE = 100; +const CLOCK_SKEW_MINUTES = 5; + +export const ALLOWED_IMAGE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif', +] as const; + +type NumberOptions = { + max?: number; + min?: number; +}; + +function readEnv(name: string, legacyName?: string): string | undefined { + return process.env[name] ?? (legacyName ? process.env[legacyName] : undefined); +} + +function readNumber( + value: string | undefined, + fallback: number, + options: NumberOptions = {}, +): number { + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return fallback; + } + + if (options.min !== undefined && parsed < options.min) { + return options.min; + } + + if (options.max !== undefined && parsed > options.max) { + return options.max; + } + + return parsed; +} + +export const SHARE_TTL_MINUTES = readNumber( + readEnv('GLANCE_TTL_MINUTES', 'AGENTPASTE_TTL_MINUTES'), + DEFAULT_TTL_MINUTES, + { min: 5, max: 1440 }, +); + +export const SHARE_TTL_MS = SHARE_TTL_MINUTES * MINUTE_MS; + +/** Human-readable TTL label, e.g. "30m" or "2h". */ +export const SHARE_TTL_LABEL = + SHARE_TTL_MINUTES >= 60 && SHARE_TTL_MINUTES % 60 === 0 + ? `${SHARE_TTL_MINUTES / 60}h` + : `${SHARE_TTL_MINUTES}m`; + +export const MAX_UPLOAD_MB = readNumber( + readEnv('GLANCE_MAX_UPLOAD_MB', 'AGENTPASTE_MAX_UPLOAD_MB'), + DEFAULT_MAX_UPLOAD_MB, + { min: 1, max: 64 }, +); + +export const MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024; + +export const UPLOAD_TOKEN_TTL_MS = + readNumber( + readEnv( + 'GLANCE_UPLOAD_TOKEN_TTL_SECONDS', + 'AGENTPASTE_UPLOAD_TOKEN_TTL_SECONDS', + ), + DEFAULT_UPLOAD_TOKEN_TTL_SECONDS, + { min: 60, max: 3600 }, + ) * 1000; + +export const CLEANUP_BATCH_SIZE = readNumber( + readEnv('GLANCE_CLEANUP_BATCH_SIZE', 'AGENTPASTE_CLEANUP_BATCH_SIZE'), + DEFAULT_CLEANUP_BATCH_SIZE, + { min: 1, max: 1000 }, +); + +export const MAX_ALLOWED_EXPIRY_AHEAD_MS = + SHARE_TTL_MS + CLOCK_SKEW_MINUTES * MINUTE_MS; diff --git a/apps/web/lib/cron-auth.ts b/apps/web/lib/cron-auth.ts new file mode 100644 index 0000000..a42514d --- /dev/null +++ b/apps/web/lib/cron-auth.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; + +export const CRON_DEV_BYPASS_ENV = 'GLANCE_ALLOW_UNAUTHENTICATED_CRON'; +const LEGACY_CRON_DEV_BYPASS_ENV = 'AGENTPASTE_ALLOW_UNAUTHENTICATED_CRON'; + +function isLocalDevEnvironment(): boolean { + return process.env.NODE_ENV !== 'production' && !process.env.VERCEL; +} + +function allowUnauthenticatedCronInDev(): boolean { + return ( + isLocalDevEnvironment() && + (process.env[CRON_DEV_BYPASS_ENV] === '1' || + process.env[LEGACY_CRON_DEV_BYPASS_ENV] === '1') + ); +} + +export function authorizeCronRequest(request: Request): Response | null { + const secret = process.env.CRON_SECRET; + + if (!secret) { + if (allowUnauthenticatedCronInDev()) { + return null; + } + + return NextResponse.json( + { + error: + 'CRON_SECRET is required. For local dev only, set GLANCE_ALLOW_UNAUTHENTICATED_CRON=1.', + }, + { status: 500 }, + ); + } + + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${secret}`) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + return null; +} diff --git a/apps/web/lib/csp.test.ts b/apps/web/lib/csp.test.ts new file mode 100644 index 0000000..7dac0b7 --- /dev/null +++ b/apps/web/lib/csp.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { buildContentSecurityPolicy } from './csp'; + +describe('buildContentSecurityPolicy', () => { + it('omits unsafe-eval in production policy', () => { + const policy = buildContentSecurityPolicy({ isDev: false }); + + expect(policy).not.toContain("'unsafe-eval'"); + expect(policy).toContain("script-src 'self' 'unsafe-inline' https://va.vercel-scripts.com"); + }); + + it('keeps unsafe-eval in development policy', () => { + const policy = buildContentSecurityPolicy({ isDev: true }); + + expect(policy).toContain("'unsafe-eval'"); + expect(policy).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval'"); + }); +}); diff --git a/apps/web/lib/csp.ts b/apps/web/lib/csp.ts new file mode 100644 index 0000000..c1e12b6 --- /dev/null +++ b/apps/web/lib/csp.ts @@ -0,0 +1,26 @@ +type BuildCspOptions = { + isDev: boolean; +}; + +export function buildContentSecurityPolicy(options: BuildCspOptions): string { + const scriptSrc = [ + "'self'", + "'unsafe-inline'", + 'https://va.vercel-scripts.com', + ]; + + // Keep unsafe-eval in development only for tooling/HMR compatibility. + if (options.isDev) { + scriptSrc.splice(2, 0, "'unsafe-eval'"); + } + + return [ + "default-src 'self'", + `script-src ${scriptSrc.join(' ')}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' https://fonts.gstatic.com", + "connect-src 'self' https://vercel.com https://*.public.blob.vercel-storage.com https://*.sentry.io https://*.ingest.us.sentry.io https://*.vercel-insights.com https://va.vercel-scripts.com", + "frame-ancestors 'none'", + ].join('; '); +} diff --git a/apps/web/lib/encryption.compat.test.ts b/apps/web/lib/encryption.compat.test.ts new file mode 100644 index 0000000..291b02a --- /dev/null +++ b/apps/web/lib/encryption.compat.test.ts @@ -0,0 +1,104 @@ +import { createCipheriv, hkdfSync, randomBytes } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { deriveKeyBytesWebCrypto, encrypt } from './encryption'; +import { decrypt, deriveKeyBytesNode } from './encryption.server'; + +/** + * T2 — Cross-environment compatibility tests. + * + * In production the browser encrypts via Web Crypto and the server decrypts + * via node:crypto. These tests verify the two implementations agree on key + * derivation and can interoperate. + * + * Vitest runs in Node where `globalThis.crypto.subtle` is available (Node 20+), + * so we can exercise both code paths directly. + */ + +const TOKEN = '0dagxtiLC3aBEk0lbXVE'; +const HKDF_SALT = new TextEncoder().encode('glance.sh-image-encryption-v1'); +const HKDF_INFO = new TextEncoder().encode('aes-256-gcm'); + +describe('encryption cross-environment compat', () => { + it('key derivation parity: Web Crypto and node:crypto produce identical keys', async () => { + const webKey = await deriveKeyBytesWebCrypto(TOKEN); + const nodeKey = await deriveKeyBytesNode(TOKEN); + + expect(webKey).toEqual(nodeKey); + expect(webKey.byteLength).toBe(32); + }); + + it('encrypt with Web Crypto → decrypt with node:crypto', async () => { + // encrypt() uses Web Crypto (SubtleCrypto) internally + const plaintext = new TextEncoder().encode('browser to server'); + const contentType = 'image/jpeg'; + + const encrypted = await encrypt( + plaintext.buffer as ArrayBuffer, + contentType, + TOKEN, + ); + + // decrypt() uses node:crypto internally + const result = await decrypt(encrypted, TOKEN); + + expect(result.contentType).toBe(contentType); + expect(new Uint8Array(result.data)).toEqual(plaintext); + }); + + it('encrypt with node:crypto → decrypt with Web Crypto (via round-trip verification)', async () => { + // Manually encrypt using node:crypto to simulate the reverse direction + const plaintext = new TextEncoder().encode('server to browser'); + const contentType = 'image/webp'; + + // Build header + const ctBytes = new TextEncoder().encode(contentType); + const header = new Uint8Array(1 + ctBytes.byteLength); + header[0] = ctBytes.byteLength; + header.set(ctBytes, 1); + + // Derive key via node:crypto + const keyBytes = hkdfSync( + 'sha256', + new TextEncoder().encode(TOKEN), + HKDF_SALT, + HKDF_INFO, + 32, + ); + + // Encrypt via node:crypto + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', new Uint8Array(keyBytes), iv); + cipher.setAAD(header); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + // Assemble envelope: header | iv | ciphertext | tag + const envelope = new Uint8Array( + header.byteLength + 12 + encrypted.byteLength + tag.byteLength, + ); + envelope.set(header, 0); + envelope.set(new Uint8Array(iv), header.byteLength); + envelope.set(new Uint8Array(encrypted), header.byteLength + 12); + envelope.set(new Uint8Array(tag), header.byteLength + 12 + encrypted.byteLength); + + // Decrypt using our decrypt() which uses node:crypto — + // but the key derivation was independently done above, verifying + // the envelope format is compatible. + const result = await decrypt(envelope.buffer as ArrayBuffer, TOKEN); + + expect(result.contentType).toBe(contentType); + expect(new Uint8Array(result.data)).toEqual(plaintext); + + // Also verify via Web Crypto decrypt path by re-encrypting with + // encrypt() (Web Crypto) and decrypting with decrypt() (node:crypto) + const reEncrypted = await encrypt( + result.data, + result.contentType, + TOKEN, + ); + const reResult = await decrypt(reEncrypted, TOKEN); + expect(new Uint8Array(reResult.data)).toEqual(plaintext); + }); +}); diff --git a/apps/web/lib/encryption.server.ts b/apps/web/lib/encryption.server.ts new file mode 100644 index 0000000..56c2ab2 --- /dev/null +++ b/apps/web/lib/encryption.server.ts @@ -0,0 +1,78 @@ +/** + * Server-only decryption for images at rest. + * + * Uses node:crypto to decrypt envelopes produced by the browser-side + * `encrypt()` in `lib/encryption.ts`. + * + * This module must NOT be imported from client components. + */ + +import { createDecipheriv, hkdfSync } from 'node:crypto'; + +import { + HKDF_INFO, + HKDF_SALT, + IV_BYTES, + KEY_BITS, + parseHeader, +} from './encryption'; + +const KEY_BYTES = KEY_BITS / 8; + +/** + * Derive raw key bytes via node:crypto HKDF — exported for cross-env tests. + */ +export function deriveKeyBytesNode(token: string): Uint8Array { + const derived = hkdfSync( + 'sha256', + new TextEncoder().encode(token), + HKDF_SALT, + HKDF_INFO, + KEY_BYTES, + ); + return new Uint8Array(derived); +} + +/** + * Decrypt an encrypted envelope on the server. + * + * @param encrypted The full envelope (header + IV + ciphertext). + * @param token The share token used as key material. + * @returns Decrypted image data and the original content-type. + */ +export function decrypt( + encrypted: ArrayBuffer, + token: string, +): { contentType: string; data: ArrayBuffer } { + const buf = new Uint8Array(encrypted); + + const { contentType, headerLength } = parseHeader(buf); + const header = buf.subarray(0, headerLength); + const iv = buf.subarray(headerLength, headerLength + IV_BYTES); + const ciphertextWithTag = buf.subarray(headerLength + IV_BYTES); + + if (ciphertextWithTag.byteLength < 16) { + throw new Error('Encrypted payload is too short to contain an auth tag.'); + } + + const keyBytes = deriveKeyBytesNode(token); + + // GCM auth tag is the last 16 bytes + const tagStart = ciphertextWithTag.byteLength - 16; + const ciphertext = ciphertextWithTag.subarray(0, tagStart); + const authTag = ciphertextWithTag.subarray(tagStart); + + const decipher = createDecipheriv('aes-256-gcm', keyBytes, iv); + decipher.setAAD(header); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + return { + contentType, + data: decrypted.buffer.slice( + decrypted.byteOffset, + decrypted.byteOffset + decrypted.byteLength, + ) as ArrayBuffer, + }; +} diff --git a/apps/web/lib/encryption.test.ts b/apps/web/lib/encryption.test.ts new file mode 100644 index 0000000..709c0f4 --- /dev/null +++ b/apps/web/lib/encryption.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveKeyBytesWebCrypto, encrypt } from './encryption'; +import { decrypt, deriveKeyBytesNode } from './encryption.server'; + +const TOKEN_A = '0dagxtiLC3aBEk0lbXVE'; +const TOKEN_B = '0dagxSOMETHINGELSEab'; + +function randomPayload(size: number): Uint8Array { + const buf = new Uint8Array(size); + for (let i = 0; i < size; i++) buf[i] = i % 256; + return buf; +} + +// ----------------------------------------------------------------------- +// T1 — Core round-trip and correctness +// ----------------------------------------------------------------------- + +describe('encryption', () => { + it('round-trips: encrypt then decrypt recovers original bytes and content-type', async () => { + const plaintext = new TextEncoder().encode('hello world'); + const contentType = 'image/png'; + + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, contentType, TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(result.contentType).toBe(contentType); + expect(new Uint8Array(result.data)).toEqual(plaintext); + }); + + it('different tokens produce different ciphertext', async () => { + const plaintext = new TextEncoder().encode('same data'); + const iv = new Uint8Array(12); // fixed IV to isolate key difference + + const a = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A, iv); + const b = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_B, iv); + + expect(new Uint8Array(a)).not.toEqual(new Uint8Array(b)); + }); + + it('wrong token fails to decrypt', async () => { + const plaintext = new TextEncoder().encode('secret'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + + expect(() => decrypt(encrypted, TOKEN_B)).toThrow(); + }); + + it('tampered ciphertext fails', async () => { + const plaintext = new TextEncoder().encode('secret'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + + const buf = new Uint8Array(encrypted); + // Flip a byte in the ciphertext region (after header + IV) + buf[buf.byteLength - 20] ^= 0xff; + + expect(() => decrypt(buf.buffer as ArrayBuffer, TOKEN_A)).toThrow(); + }); + + it('tampered IV fails', async () => { + const plaintext = new TextEncoder().encode('secret'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + + const buf = new Uint8Array(encrypted); + // Content-type "image/png" = 9 bytes, header = 1 + 9 = 10, IV starts at offset 10 + buf[10] ^= 0xff; + + expect(() => decrypt(buf.buffer as ArrayBuffer, TOKEN_A)).toThrow(); + }); + + it('tampered content-type header fails (AAD mismatch)', async () => { + const plaintext = new TextEncoder().encode('secret'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + + const buf = new Uint8Array(encrypted); + // Corrupt the content-type byte (offset 1) + buf[1] ^= 0xff; + + expect(() => decrypt(buf.buffer as ArrayBuffer, TOKEN_A)).toThrow(); + }); + + it('empty payload round-trips', async () => { + const plaintext = new ArrayBuffer(0); + const contentType = 'image/gif'; + + const encrypted = await encrypt(plaintext, contentType, TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(result.contentType).toBe(contentType); + expect(new Uint8Array(result.data)).toEqual(new Uint8Array(0)); + }); + + it('max-length content-type (255 bytes) works', async () => { + const contentType = 'x/' + 'a'.repeat(253); + expect(new TextEncoder().encode(contentType).byteLength).toBe(255); + + const plaintext = new TextEncoder().encode('data'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, contentType, TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(result.contentType).toBe(contentType); + expect(new Uint8Array(result.data)).toEqual(plaintext); + }); + + it('content-type too long (256+ bytes) throws on encrypt', async () => { + const contentType = 'x/' + 'a'.repeat(254); // 256 bytes + const plaintext = new ArrayBuffer(1); + + await expect( + encrypt(plaintext, contentType, TOKEN_A), + ).rejects.toThrow(/exceeds 255 bytes/); + }); + + it.each([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif', + ])('preserves content-type %s', async (ct) => { + const plaintext = new TextEncoder().encode('test'); + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, ct, TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(result.contentType).toBe(ct); + }); + + it('handles ~5 MB payload', { timeout: 30_000 }, async () => { + const size = 5 * 1024 * 1024; + const plaintext = randomPayload(size); + + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(new Uint8Array(result.data)).toEqual(plaintext); + }); + + it('deriveKeyBytesNode is deterministic', async () => { + const a = await deriveKeyBytesNode(TOKEN_A); + const b = await deriveKeyBytesNode(TOKEN_A); + + expect(a).toEqual(b); + expect(a.byteLength).toBe(32); + }); + + it('IV is unique across encryptions (ciphertexts differ)', async () => { + const plaintext = new TextEncoder().encode('same'); + + const a = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + const b = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', TOKEN_A); + + // With random IVs, the ciphertexts should differ + expect(new Uint8Array(a)).not.toEqual(new Uint8Array(b)); + }); + + // ------------------------------------------------------------------- + // T6 — Edge cases + // ------------------------------------------------------------------- + + it('low-entropy token (all same chars) still works', async () => { + const weakToken = 'aaaaaaaaaaaaaaaaaaaaaa'; + const plaintext = new TextEncoder().encode('works'); + + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, 'image/png', weakToken); + const result = await decrypt(encrypted, weakToken); + + expect(new Uint8Array(result.data)).toEqual(plaintext); + }); + + it('content-type with special chars preserved', async () => { + const ct = 'image/svg+xml'; + const plaintext = new TextEncoder().encode(''); + + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, ct, TOKEN_A); + const result = await decrypt(encrypted, TOKEN_A); + + expect(result.contentType).toBe(ct); + }); + + it('truncated payload throws', () => { + expect(() => decrypt(new ArrayBuffer(0), TOKEN_A)).toThrow(); + expect(() => decrypt(new ArrayBuffer(5), TOKEN_A)).toThrow(); + }); + + it('payload with valid header but too short for auth tag throws', async () => { + // Build a valid header + IV but no ciphertext/tag + const header = new Uint8Array([9, ...new TextEncoder().encode('image/png')]); + const iv = new Uint8Array(12); + const buf = new Uint8Array(header.byteLength + iv.byteLength + 10); // 10 < 16 tag + buf.set(header, 0); + buf.set(iv, header.byteLength); + expect(() => decrypt(buf.buffer as ArrayBuffer, TOKEN_A)).toThrow(/auth tag/); + }); + + it('payload format is stable (snapshot)', async () => { + // Fixed IV so ciphertext is deterministic for a given key + const iv = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + const plaintext = new TextEncoder().encode('snapshot'); + const ct = 'image/png'; + + const encrypted = await encrypt( + plaintext.buffer as ArrayBuffer, + ct, + TOKEN_A, + iv, + ); + + // If the envelope format changes, this snapshot will break — that's the point. + const snapshot = Buffer.from(new Uint8Array(encrypted)).toString('base64'); + + // Decrypt to verify it's valid + const result = await decrypt(encrypted, TOKEN_A); + expect(new Uint8Array(result.data)).toEqual(plaintext); + + // Re-encrypt with same params → same output + const encrypted2 = await encrypt( + plaintext.buffer as ArrayBuffer, + ct, + TOKEN_A, + iv, + ); + const snapshot2 = Buffer.from(new Uint8Array(encrypted2)).toString('base64'); + + expect(snapshot2).toBe(snapshot); + }); +}); diff --git a/apps/web/lib/encryption.ts b/apps/web/lib/encryption.ts new file mode 100644 index 0000000..bf2d36e --- /dev/null +++ b/apps/web/lib/encryption.ts @@ -0,0 +1,129 @@ +/** + * Client-safe encryption for images at rest. + * + * The share token doubles as the encryption key: HKDF derives an AES-256-GCM + * key from the token string. The browser encrypts before uploading. + * + * Envelope format (all fields are concatenated into a single ArrayBuffer): + * + * [1 byte – content-type length N] + * [N bytes – content-type UTF-8] + * [12 bytes – IV] + * [remaining – AES-256-GCM ciphertext + 16-byte auth tag] + * + * The content-type header lives *outside* the ciphertext so the envelope can + * be parsed without decryption, but integrity is still guaranteed because we + * feed the header bytes as GCM additional authenticated data (AAD). + */ + +// --------------------------------------------------------------------------- +// Shared constants (also imported by encryption.server.ts) +// --------------------------------------------------------------------------- + +export const HKDF_SALT = new TextEncoder().encode('glance.sh-image-encryption-v1'); +export const HKDF_INFO = new TextEncoder().encode('aes-256-gcm'); +export const IV_BYTES = 12; +export const KEY_BITS = 256; +export const MAX_CONTENT_TYPE_LENGTH = 255; + +// --------------------------------------------------------------------------- +// Envelope helpers +// --------------------------------------------------------------------------- + +export function buildHeader(contentType: string): Uint8Array { + const ctBytes = new TextEncoder().encode(contentType); + + if (ctBytes.byteLength > MAX_CONTENT_TYPE_LENGTH) { + throw new Error( + `Content-type exceeds ${MAX_CONTENT_TYPE_LENGTH} bytes: ${contentType}`, + ); + } + + const header = new Uint8Array(1 + ctBytes.byteLength); + header[0] = ctBytes.byteLength; + header.set(ctBytes, 1); + return header; +} + +export function parseHeader(data: Uint8Array): { contentType: string; headerLength: number } { + if (data.byteLength < 1) { + throw new Error('Encrypted payload is too short to contain a header.'); + } + + const ctLength = data[0]; + + if (data.byteLength < 1 + ctLength + IV_BYTES + 1) { + throw new Error('Encrypted payload is truncated.'); + } + + const contentType = new TextDecoder().decode(data.subarray(1, 1 + ctLength)); + return { contentType, headerLength: 1 + ctLength }; +} + +// --------------------------------------------------------------------------- +// Web Crypto (browser) implementation +// --------------------------------------------------------------------------- + +async function deriveKeyWebCrypto(token: string): Promise { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(token), + 'HKDF', + false, + ['deriveKey'], + ); + + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: HKDF_INFO }, + keyMaterial, + { name: 'AES-GCM', length: KEY_BITS }, + true, // extractable for testing parity + ['encrypt', 'decrypt'], + ); +} + +/** Derive raw key bytes via Web Crypto — exported for cross-env tests. */ +export async function deriveKeyBytesWebCrypto(token: string): Promise { + const key = await deriveKeyWebCrypto(token); + return new Uint8Array(await crypto.subtle.exportKey('raw', key)); +} + +/** + * Encrypt an image in the browser before uploading. + * + * @param plaintext Raw image bytes. + * @param contentType Original MIME type (e.g. `image/png`). + * @param token The share token used as key material. + * @param iv Optional IV override — **only** for deterministic tests. + * @returns Encrypted envelope as an ArrayBuffer. + */ +export async function encrypt( + plaintext: ArrayBuffer, + contentType: string, + token: string, + iv?: Uint8Array, +): Promise { + const header = buildHeader(contentType); + const actualIv = iv ?? crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const key = await deriveKeyWebCrypto(token); + + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: actualIv as Uint8Array, + additionalData: header as Uint8Array, + }, + key, + plaintext, + ); + + // Assemble envelope: header | iv | ciphertext+tag + const envelope = new Uint8Array( + header.byteLength + IV_BYTES + ciphertext.byteLength, + ); + envelope.set(header, 0); + envelope.set(actualIv, header.byteLength); + envelope.set(new Uint8Array(ciphertext), header.byteLength + IV_BYTES); + + return envelope.buffer as ArrayBuffer; +} diff --git a/apps/web/lib/rate-limit.redis.test.ts b/apps/web/lib/rate-limit.redis.test.ts new file mode 100644 index 0000000..726ab52 --- /dev/null +++ b/apps/web/lib/rate-limit.redis.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const redisMock = vi.hoisted(() => ({ + eval: vi.fn(), +})); + +const fromEnvMock = vi.hoisted(() => vi.fn(() => redisMock)); + +vi.mock('@upstash/redis', () => ({ + Redis: { + fromEnv: fromEnvMock, + }, +})); + +const ORIGINAL_ENV = { ...process.env }; + +describe('rate limit redis store path', () => { + beforeEach(() => { + process.env = { + ...ORIGINAL_ENV, + UPSTASH_REDIS_REST_URL: 'https://redis.example', + UPSTASH_REDIS_REST_TOKEN: 'token', + }; + redisMock.eval.mockReset(); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + it('uses Redis.fromEnv store when redis env vars are present', async () => { + vi.resetModules(); + const mod = await import('./rate-limit'); + + redisMock.eval.mockResolvedValue(1); + + const result = await mod.checkRateLimit('198.51.100.9', { + bucket: 'redis-bucket', + maxRequests: 2, + windowMs: 60_000, + }); + + // Second call should reuse cached redisStore (line 101 path) + await mod.checkRateLimit('198.51.100.9', { + bucket: 'redis-bucket', + maxRequests: 2, + windowMs: 60_000, + }); + + expect(result.allowed).toBe(true); + expect(redisMock.eval).toHaveBeenCalledTimes(2); + expect(fromEnvMock).toHaveBeenCalledTimes(1); + + mod.__resetRateLimitMemoryForTests(); + }); + + it('returns blocked response from redis counter when over limit', async () => { + vi.resetModules(); + const mod = await import('./rate-limit'); + + redisMock.eval.mockResolvedValue(3); + + const result = await mod.checkRateLimit('198.51.100.10', { + bucket: 'redis-bucket-2', + maxRequests: 2, + windowMs: 60_000, + }); + + expect(result.allowed).toBe(false); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + + mod.__resetRateLimitMemoryForTests(); + }); +}); diff --git a/apps/web/lib/rate-limit.test.ts b/apps/web/lib/rate-limit.test.ts new file mode 100644 index 0000000..0bb1690 --- /dev/null +++ b/apps/web/lib/rate-limit.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetRateLimitMemoryForTests, + __setCounterStoreForTests, + checkRateLimit, +} from './rate-limit'; + +describe('checkRateLimit', () => { + afterEach(() => { + __resetRateLimitMemoryForTests(); + }); + + it('allows requests under the limit', async () => { + const bucket = `test-under-${Date.now()}`; + const result = await checkRateLimit('10.0.0.1', { + bucket, + maxRequests: 5, + windowMs: 60_000, + }); + + expect(result.allowed).toBe(true); + expect(result.retryAfterSeconds).toBeUndefined(); + }); + + it('blocks requests over the limit', async () => { + const bucket = `test-over-${Date.now()}`; + const config = { bucket, maxRequests: 3, windowMs: 60_000 }; + + await checkRateLimit('10.0.0.2', config); + await checkRateLimit('10.0.0.2', config); + await checkRateLimit('10.0.0.2', config); + const fourth = await checkRateLimit('10.0.0.2', config); + + expect(fourth.allowed).toBe(false); + expect(fourth.retryAfterSeconds).toBeGreaterThan(0); + expect(fourth.retryAfterSeconds).toBeLessThanOrEqual(60); + }); + + it('returns correct retryAfterSeconds', async () => { + const bucket = `test-retry-${Date.now()}`; + const config = { bucket, maxRequests: 1, windowMs: 30_000 }; + + await checkRateLimit('10.0.0.3', config); + const blocked = await checkRateLimit('10.0.0.3', config); + + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(30); + }); + + it('separate buckets do not interfere', async () => { + const ts = Date.now(); + const configA = { bucket: `bucket-a-${ts}`, maxRequests: 1, windowMs: 60_000 }; + const configB = { bucket: `bucket-b-${ts}`, maxRequests: 1, windowMs: 60_000 }; + + await checkRateLimit('10.0.0.4', configA); + const blockedA = await checkRateLimit('10.0.0.4', configA); + const allowedB = await checkRateLimit('10.0.0.4', configB); + + expect(blockedA.allowed).toBe(false); + expect(allowedB.allowed).toBe(true); + }); + + it('separate IPs do not interfere', async () => { + const bucket = `test-ips-${Date.now()}`; + const config = { bucket, maxRequests: 1, windowMs: 60_000 }; + + await checkRateLimit('10.0.0.5', config); + const blockedFirst = await checkRateLimit('10.0.0.5', config); + const allowedSecond = await checkRateLimit('10.0.0.6', config); + + expect(blockedFirst.allowed).toBe(false); + expect(allowedSecond.allowed).toBe(true); + }); + + it('exactly at the limit is allowed, one over is blocked', async () => { + const bucket = `test-boundary-${Date.now()}`; + const config = { bucket, maxRequests: 2, windowMs: 60_000 }; + + const first = await checkRateLimit('10.0.0.7', config); + const second = await checkRateLimit('10.0.0.7', config); + const third = await checkRateLimit('10.0.0.7', config); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(true); + expect(third.allowed).toBe(false); + }); + + it('uses injected distributed store and keeps fixed-window retry semantics', async () => { + const sharedCounts = new Map(); + + __setCounterStoreForTests({ + async increment(key) { + const next = (sharedCounts.get(key) ?? 0) + 1; + sharedCounts.set(key, next); + return next; + }, + }); + + const config = { + bucket: `distributed-${Date.now()}`, + maxRequests: 2, + windowMs: 60_000, + }; + + expect((await checkRateLimit('198.51.100.1', config)).allowed).toBe(true); + expect((await checkRateLimit('198.51.100.1', config)).allowed).toBe(true); + + const blocked = await checkRateLimit('198.51.100.1', config); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(0); + expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(60); + }); + + it('clears distributed store when null is passed to setCounterStore', async () => { + __setCounterStoreForTests({ + async increment() { return 1; }, + }); + + // Clear it + __setCounterStoreForTests(null); + + // Should fall back to memory + const result = await checkRateLimit('10.0.0.99', { + bucket: `clear-test-${Date.now()}`, + maxRequests: 5, + windowMs: 60_000, + }); + expect(result.allowed).toBe(true); + }); + + it('prunes expired in-memory entries after a full window', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now); + + const bucket = `prune-${Date.now()}`; + const config = { bucket, maxRequests: 1, windowMs: 1_000 }; + + // Create two entries + await checkRateLimit('10.0.0.200', config); + await checkRateLimit('10.0.0.201', config); + + // Move beyond window to trigger prune path + vi.setSystemTime(now + 2_000); + const result = await checkRateLimit('10.0.0.202', config); + + expect(result.allowed).toBe(true); + vi.useRealTimers(); + }); + + it('falls back to memory limiter when distributed backend fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + __setCounterStoreForTests({ + async increment() { + throw new Error('redis unavailable'); + }, + }); + + const config = { + bucket: `fallback-${Date.now()}`, + maxRequests: 1, + windowMs: 60_000, + }; + + const first = await checkRateLimit('203.0.113.2', config); + const second = await checkRateLimit('203.0.113.2', config); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(false); + expect(second.retryAfterSeconds).toBeGreaterThan(0); + + errorSpy.mockRestore(); + }); +}); diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 0000000..ae32777 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,208 @@ +import { Redis } from '@upstash/redis'; + +type Entry = { + count: number; + resetAt: number; +}; + +type BucketConfig = { + maxRequests: number; + windowMs: number; + bucket?: string; +}; + +export type RateLimitResult = { + allowed: boolean; + retryAfterSeconds?: number; +}; + +interface CounterStore { + increment(key: string, ttlMs: number): Promise; +} + +const DEFAULT_BUCKET = 'default'; +const REDIS_KEY_PREFIX = 'ratelimit'; + +const memoryStores = new Map>(); +const memoryPruneTimers = new Map(); + +let redisStore: CounterStore | null = null; +let useRedisStore = false; +let hasResolvedStore = false; +let testStore: CounterStore | null = null; + +function getMemoryStore(bucket: string): Map { + let store = memoryStores.get(bucket); + if (!store) { + store = new Map(); + memoryStores.set(bucket, store); + } + return store; +} + +function pruneMemory(bucket: string, windowMs: number, now: number) { + const last = memoryPruneTimers.get(bucket) ?? 0; + if (now - last < windowMs) return; + + const store = getMemoryStore(bucket); + for (const [key, entry] of store) { + if (now >= entry.resetAt) { + store.delete(key); + } + } + + memoryPruneTimers.set(bucket, now); +} + +async function checkMemoryRateLimit( + ip: string, + bucket: string, + maxRequests: number, + windowMs: number, + now: number, +): Promise { + pruneMemory(bucket, windowMs, now); + + const store = getMemoryStore(bucket); + const entry = store.get(ip); + + if (!entry || now >= entry.resetAt) { + store.set(ip, { count: 1, resetAt: now + windowMs }); + return { allowed: true }; + } + + if (entry.count >= maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.ceil((entry.resetAt - now) / 1000), + }; + } + + entry.count += 1; + return { allowed: true }; +} + +function hasRedisEnv(): boolean { + return Boolean( + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN, + ); +} + +const INCR_WITH_PEXPIRE_SCRIPT = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return count +`; + +function resolveRedisStore(): CounterStore { + if (redisStore) { + return redisStore; + } + + const redis = Redis.fromEnv(); + redisStore = { + async increment(key: string, ttlMs: number): Promise { + return redis.eval<[string], number>( + INCR_WITH_PEXPIRE_SCRIPT, + [key], + [String(ttlMs)], + ); + }, + }; + + return redisStore; +} + +function resolveStore(): CounterStore | null { + if (testStore) { + return testStore; + } + + if (!hasResolvedStore) { + useRedisStore = hasRedisEnv(); + hasResolvedStore = true; + } + + if (!useRedisStore) { + return null; + } + + return resolveRedisStore(); +} + +function redisKey(bucket: string, ip: string, windowStart: number): string { + return `${REDIS_KEY_PREFIX}:${bucket}:${ip}:${windowStart}`; +} + +function fixedWindow(now: number, windowMs: number): { + start: number; + end: number; +} { + const start = Math.floor(now / windowMs) * windowMs; + return { start, end: start + windowMs }; +} + +/** + * Test-only hook for injecting a custom store implementation. + */ +export function __setCounterStoreForTests(store: CounterStore | null): void { + testStore = store; + useRedisStore = Boolean(store); + hasResolvedStore = true; + if (!store) { + redisStore = null; + } +} + +/** + * Test-only hook for clearing in-memory local state. + */ +export function __resetRateLimitMemoryForTests(): void { + memoryStores.clear(); + memoryPruneTimers.clear(); + testStore = null; + redisStore = null; + useRedisStore = false; + hasResolvedStore = false; +} + +export async function checkRateLimit( + ip: string, + config: BucketConfig = { maxRequests: 20, windowMs: 3_600_000 }, +): Promise { + const bucket = config.bucket ?? DEFAULT_BUCKET; + const now = Date.now(); + const window = fixedWindow(now, config.windowMs); + const retryAfterSeconds = Math.ceil(Math.max(window.end - now, 1) / 1000); + + const store = resolveStore(); + if (store) { + const key = redisKey(bucket, ip, window.start); + const ttlMs = Math.max(window.end - now, 1); + + try { + const count = await store.increment(key, ttlMs); + if (count > config.maxRequests) { + return { + allowed: false, + retryAfterSeconds, + }; + } + + return { allowed: true }; + } catch (error) { + console.error('Distributed rate limit failed, falling back to memory store', error); + return checkMemoryRateLimit( + ip, + bucket, + config.maxRequests, + config.windowMs, + now, + ); + } + } + + return checkMemoryRateLimit(ip, bucket, config.maxRequests, config.windowMs, now); +} diff --git a/apps/web/lib/request-ip.test.ts b/apps/web/lib/request-ip.test.ts new file mode 100644 index 0000000..70d8274 --- /dev/null +++ b/apps/web/lib/request-ip.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { getRequestIp } from './request-ip'; + +describe('getRequestIp', () => { + it('uses first value from x-forwarded-for', () => { + const request = new Request('http://localhost', { + headers: { 'x-forwarded-for': '203.0.113.10, 10.0.0.1' }, + }); + + expect(getRequestIp(request)).toBe('203.0.113.10'); + }); + + it('falls back to x-real-ip when forwarded-for first value is blank', () => { + const request = new Request('http://localhost', { + headers: { + 'x-forwarded-for': ' , 10.0.0.1', + 'x-real-ip': '198.51.100.20', + }, + }); + + expect(getRequestIp(request)).toBe('198.51.100.20'); + }); + + it('returns unknown when no forwarding headers are present', () => { + const request = new Request('http://localhost'); + + expect(getRequestIp(request)).toBe('unknown'); + }); +}); diff --git a/apps/web/lib/request-ip.ts b/apps/web/lib/request-ip.ts new file mode 100644 index 0000000..3b68a3b --- /dev/null +++ b/apps/web/lib/request-ip.ts @@ -0,0 +1,16 @@ +function firstHeaderValue(value: string | null): string | null { + if (!value) { + return null; + } + + const first = value.split(',')[0]?.trim(); + return first || null; +} + +export function getRequestIp(request: Request): string { + return ( + firstHeaderValue(request.headers.get('x-forwarded-for')) ?? + firstHeaderValue(request.headers.get('x-real-ip')) ?? + 'unknown' + ); +} diff --git a/apps/web/lib/sessions.test.ts b/apps/web/lib/sessions.test.ts new file mode 100644 index 0000000..6ef0a9b --- /dev/null +++ b/apps/web/lib/sessions.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const redisMock = vi.hoisted(() => { + const data = new Map(); + const ttl = new Map(); + + const client = { + set: vi.fn(async (key: string, value: string, options?: { ex?: number }) => { + data.set(key, value); + if (options?.ex !== undefined) { + ttl.set(key, options.ex); + } + return 'OK'; + }), + get: vi.fn(async (key: string) => data.get(key) ?? null), + exists: vi.fn(async (key: string) => (data.has(key) ? 1 : 0)), + ttl: vi.fn(async (key: string) => ttl.get(key) ?? -2), + }; + + return { + client, + data, + ttl, + reset() { + data.clear(); + ttl.clear(); + client.set.mockClear(); + client.get.mockClear(); + client.exists.mockClear(); + client.ttl.mockClear(); + }, + }; +}); + +vi.mock('@upstash/redis', () => ({ + Redis: { + fromEnv() { + return redisMock.client; + }, + }, +})); + +import { + MAX_SESSION_EVENTS, + createSession, + getEvents, + pushEvent, + sessionExists, +} from './sessions'; + +describe('sessions store', () => { + beforeEach(() => { + redisMock.reset(); + }); + + it('creates URL-safe 12-char session IDs', async () => { + const id = await createSession(); + + expect(id).toMatch(/^[A-Za-z0-9_-]{12}$/); + expect(await sessionExists(id)).toBe(true); + }); + + it('returns null events for unknown session', async () => { + await expect(getEvents('missing')).resolves.toBeNull(); + }); + + it('rejects invalid session ids before hitting redis', async () => { + await expect(sessionExists('bad')).resolves.toBe(false); + await expect(getEvents('bad')).resolves.toBeNull(); + await expect( + pushEvent('bad', { + url: 'https://glance.sh/nope.png', + expiresAt: Date.now() + 60_000, + }), + ).resolves.toBe(false); + + expect(redisMock.client.exists).not.toHaveBeenCalled(); + }); + + it('pushes and reads events for an existing session', async () => { + const id = await createSession(); + + const pushed = await pushEvent(id, { + url: 'https://glance.sh/abc.png', + expiresAt: Date.now() + 60_000, + }); + + expect(pushed).toBe(true); + await expect(getEvents(id)).resolves.toEqual([ + { + url: 'https://glance.sh/abc.png', + expiresAt: expect.any(Number), + }, + ]); + }); + + it('keeps only the most recent bounded set of events', async () => { + const id = await createSession(); + + for (let index = 0; index < MAX_SESSION_EVENTS + 5; index += 1) { + await pushEvent(id, { + url: `https://glance.sh/${index}.png`, + expiresAt: Date.now() + 60_000, + }); + } + + const events = await getEvents(id); + + expect(events).toHaveLength(MAX_SESSION_EVENTS); + expect(events?.[0]?.url).toBe('https://glance.sh/5.png'); + expect(events?.at(-1)?.url).toBe( + `https://glance.sh/${MAX_SESSION_EVENTS + 4}.png`, + ); + }); + + it('returns false when pushing to missing session', async () => { + const pushed = await pushEvent('missing', { + url: 'https://glance.sh/missing.png', + expiresAt: Date.now() + 60_000, + }); + + expect(pushed).toBe(false); + }); + + it('handles object payloads from redis get when reading events', async () => { + const id = await createSession(); + + redisMock.client.get.mockResolvedValueOnce({ + id, + createdAt: Date.now(), + events: [{ url: 'https://glance.sh/object.png', expiresAt: Date.now() + 1000 }], + }); + + await expect(getEvents(id)).resolves.toEqual([ + { url: 'https://glance.sh/object.png', expiresAt: expect.any(Number) }, + ]); + }); + + it('handles object payloads from redis get when pushing events', async () => { + const id = await createSession(); + const redisKey = `session:${id}`; + + redisMock.ttl.set(redisKey, 42); + redisMock.client.get.mockResolvedValueOnce({ + id, + createdAt: Date.now(), + events: [], + }); + + const pushed = await pushEvent(id, { + url: 'https://glance.sh/object-push.png', + expiresAt: Date.now() + 60_000, + }); + + expect(pushed).toBe(true); + }); + + it('falls back to default TTL when redis ttl is non-positive', async () => { + const id = await createSession(); + const redisKey = `session:${id}`; + + redisMock.ttl.set(redisKey, -1); + + const pushed = await pushEvent(id, { + url: 'https://glance.sh/default-ttl.png', + expiresAt: Date.now() + 60_000, + }); + + expect(pushed).toBe(true); + expect(redisMock.client.set).toHaveBeenLastCalledWith( + redisKey, + expect.any(String), + { ex: 600 }, + ); + }); + + it('preserves remaining session ttl when pushing events', async () => { + const id = await createSession(); + const redisKey = `session:${id}`; + + redisMock.ttl.set(redisKey, 42); + + const pushed = await pushEvent(id, { + url: 'https://glance.sh/ttl.png', + expiresAt: Date.now() + 60_000, + }); + + expect(pushed).toBe(true); + expect(redisMock.client.set).toHaveBeenLastCalledWith( + redisKey, + expect.any(String), + { ex: 42 }, + ); + }); + + it('returns null for malformed JSON session payloads', async () => { + const id = await createSession(); + redisMock.data.set(`session:${id}`, '{bad-json'); + + await expect(getEvents(id)).resolves.toBeNull(); + await expect( + pushEvent(id, { + url: 'https://glance.sh/bad-json.png', + expiresAt: Date.now() + 60_000, + }), + ).resolves.toBe(false); + }); + + it('returns null when session payload has invalid shape', async () => { + const id = await createSession(); + + redisMock.data.set(`session:${id}`, JSON.stringify(null)); + await expect(getEvents(id)).resolves.toBeNull(); + + redisMock.data.set( + `session:${id}`, + JSON.stringify({ createdAt: 'not-a-number', events: [] }), + ); + await expect(getEvents(id)).resolves.toBeNull(); + }); +}); diff --git a/apps/web/lib/sessions.ts b/apps/web/lib/sessions.ts new file mode 100644 index 0000000..733d9eb --- /dev/null +++ b/apps/web/lib/sessions.ts @@ -0,0 +1,140 @@ +import { Redis } from '@upstash/redis'; + +const SESSION_TTL_SEC = 600; // 10 minutes +const KEY_PREFIX = 'session:'; +export const SESSION_ID_LENGTH = 12; +export const MAX_SESSION_EVENTS = 50; + +const SESSION_ID_PATTERN = new RegExp( + `^[A-Za-z0-9_-]{${SESSION_ID_LENGTH}}$`, +); + +export interface SessionEvent { + url: string; + expiresAt: number; +} + +interface SessionData { + createdAt: number; + events: SessionEvent[]; +} + +function redis(): Redis { + return Redis.fromEnv(); +} + +export function isValidSessionId(id: string): boolean { + return SESSION_ID_PATTERN.test(id); +} + +function key(id: string): string { + return `${KEY_PREFIX}${id}`; +} + +function isSessionEvent(value: unknown): value is SessionEvent { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + + return ( + typeof candidate.url === 'string' && + typeof candidate.expiresAt === 'number' && + Number.isFinite(candidate.expiresAt) + ); +} + +function parseSessionData(raw: unknown): SessionData | null { + const parsed = + typeof raw === 'string' + ? (() => { + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } + })() + : raw; + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const candidate = parsed as Partial; + if (typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt)) { + return null; + } + + const events = Array.isArray(candidate.events) + ? candidate.events.filter(isSessionEvent).slice(-MAX_SESSION_EVENTS) + : []; + + return { + createdAt: candidate.createdAt, + events, + }; +} + +/** Create a new session. Returns the 12-char ID. */ +export async function createSession(): Promise { + const { randomBytes } = await import('node:crypto'); + const id = randomBytes(9).toString('base64url'); // 12 chars, URL-safe + const data: SessionData = { createdAt: Date.now(), events: [] }; + await redis().set(key(id), JSON.stringify(data), { ex: SESSION_TTL_SEC }); + return id; +} + +/** Check whether a session exists. */ +export async function sessionExists(id: string): Promise { + if (!isValidSessionId(id)) { + return false; + } + + const exists = await redis().exists(key(id)); + return exists === 1; +} + +/** Push an image event into a session. Returns false if not found. */ +export async function pushEvent(id: string, event: SessionEvent): Promise { + if (!isValidSessionId(id)) { + return false; + } + + const redisKey = key(id); + const raw = await redis().get(redisKey); + if (!raw) return false; + + const data = parseSessionData(raw); + if (!data) return false; + + data.events.push(event); + + if (data.events.length > MAX_SESSION_EVENTS) { + data.events = data.events.slice(-MAX_SESSION_EVENTS); + } + + // Re-set with remaining TTL + const ttl = await redis().ttl(redisKey); + await redis().set(redisKey, JSON.stringify(data), { + ex: ttl > 0 ? ttl : SESSION_TTL_SEC, + }); + return true; +} + +/** Get all events for a session. Returns null if not found. */ +export async function getEvents(id: string): Promise { + if (!isValidSessionId(id)) { + return null; + } + + const raw = await redis().get(key(id)); + if (!raw) return null; + + const data = parseSessionData(raw); + if (!data) { + return null; + } + + return data.events; +} diff --git a/apps/web/lib/share.test.ts b/apps/web/lib/share.test.ts new file mode 100644 index 0000000..d560014 --- /dev/null +++ b/apps/web/lib/share.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + assetFilenameForToken, + describeExpiry, + extensionForContentType, + sharePathForToken, +} from './share'; + +describe('extensionForContentType', () => { + it('maps all supported image types', () => { + expect(extensionForContentType('image/png')).toBe('png'); + expect(extensionForContentType('image/jpeg')).toBe('jpg'); + expect(extensionForContentType('image/webp')).toBe('webp'); + expect(extensionForContentType('image/gif')).toBe('gif'); + expect(extensionForContentType('image/avif')).toBe('avif'); + }); + + it('returns null for unsupported types', () => { + expect(extensionForContentType('application/json')).toBeNull(); + expect(extensionForContentType('text/html')).toBeNull(); + expect(extensionForContentType('video/mp4')).toBeNull(); + }); + + it('returns null for null/undefined', () => { + expect(extensionForContentType(null)).toBeNull(); + expect(extensionForContentType(undefined)).toBeNull(); + expect(extensionForContentType('')).toBeNull(); + }); +}); + +describe('sharePathForToken', () => { + it('returns / with no /a/ prefix', () => { + expect(sharePathForToken('abc123', 'png')).toBe('/abc123.png'); + expect(sharePathForToken('abc123', 'jpg')).toBe('/abc123.jpg'); + }); + + it('supports missing extension', () => { + expect(sharePathForToken('abc123')).toBe('/abc123'); + expect(sharePathForToken('abc123', null)).toBe('/abc123'); + }); +}); + +describe('assetFilenameForToken', () => { + it('uses extension or falls back to img', () => { + expect(assetFilenameForToken('abc', 'png')).toBe('abc.png'); + expect(assetFilenameForToken('abc', null)).toBe('abc.img'); + expect(assetFilenameForToken('abc', undefined)).toBe('abc.img'); + }); +}); + +describe('describeExpiry', () => { + it('returns expired for past timestamps', () => { + const now = Date.now(); + + expect(describeExpiry(now - 1000, now)).toBe('expired'); + expect(describeExpiry(now, now)).toBe('expired'); + }); + + it('returns minutes only when under 1h', () => { + const now = Date.now(); + + expect(describeExpiry(now + 5 * 60_000, now)).toBe('expires in 5m'); + expect(describeExpiry(now + 29 * 60_000, now)).toBe('expires in 29m'); + }); + + it('rounds up partial minutes', () => { + const now = Date.now(); + + expect(describeExpiry(now + 60_001, now)).toBe('expires in 2m'); + }); + + it('returns hours when exact', () => { + const now = Date.now(); + + expect(describeExpiry(now + 60 * 60_000, now)).toBe('expires in 1h'); + expect(describeExpiry(now + 120 * 60_000, now)).toBe('expires in 2h'); + }); + + it('returns hours + minutes combo', () => { + const now = Date.now(); + + expect(describeExpiry(now + 90 * 60_000, now)).toBe('expires in 1h 30m'); + expect(describeExpiry(now + 150 * 60_000, now)).toBe('expires in 2h 30m'); + }); +}); diff --git a/apps/web/lib/share.ts b/apps/web/lib/share.ts new file mode 100644 index 0000000..e8bfc65 --- /dev/null +++ b/apps/web/lib/share.ts @@ -0,0 +1,74 @@ +const MINUTE_MS = 60_000; +const CONTENT_TYPE_EXTENSION_MAP = { + 'image/avif': 'avif', + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +} as const; + +export function extensionForContentType( + contentType: string | null | undefined, +): string | null { + if (!contentType) { + return null; + } + + return ( + CONTENT_TYPE_EXTENSION_MAP[ + contentType as keyof typeof CONTENT_TYPE_EXTENSION_MAP + ] ?? null + ); +} + +export function assetFilenameForToken( + token: string, + extension: string | null | undefined, +): string { + return `${token}.${extension ?? 'img'}`; +} + +export function sharePathForToken( + token: string, + extension?: string | null, +): string { + return `/${token}${extension ? `.${extension}` : ''}`; +} + +export function formatExpiryUtc(expiresAt: number): string { + return new Intl.DateTimeFormat('en-US', { + day: 'numeric', + hour12: false, + hour: '2-digit', + minute: '2-digit', + month: 'short', + timeZone: 'UTC', + timeZoneName: 'short', + year: 'numeric', + }).format(expiresAt); +} + +export function describeExpiry( + expiresAt: number, + now = Date.now(), +): string { + const delta = expiresAt - now; + + if (delta <= 0) { + return 'expired'; + } + + const minutes = Math.ceil(delta / MINUTE_MS); + const hours = Math.floor(minutes / 60); + const remainderMinutes = minutes % 60; + + if (hours === 0) { + return `expires in ${minutes}m`; + } + + if (remainderMinutes === 0) { + return `expires in ${hours}h`; + } + + return `expires in ${hours}h ${remainderMinutes}m`; +} diff --git a/apps/web/lib/social-image.tsx b/apps/web/lib/social-image.tsx new file mode 100644 index 0000000..7d83f22 --- /dev/null +++ b/apps/web/lib/social-image.tsx @@ -0,0 +1,89 @@ +import { ImageResponse } from 'next/og'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const socialImageSize = { width: 1200, height: 630 }; + +type SocialImageOptions = { + section?: string; + title: string; + description: string; +}; + +let boldFontPromise: Promise | null = null; + +async function getBoldFont() { + if (!boldFontPromise) { + boldFontPromise = readFile(join(process.cwd(), 'app/JetBrainsMono-Bold.ttf')); + } + + return boldFontPromise; +} + +export async function renderSocialImage({ section, title, description }: SocialImageOptions) { + const font = await getBoldFont(); + const eyebrow = section ? `${section} · glance.sh` : 'glance.sh'; + + return new ImageResponse( + ( +
+
+ {eyebrow} +
+
+ {title} +
+
+ {description} +
+
+ ), + { + ...socialImageSize, + fonts: [ + { + name: 'JetBrains Mono', + data: font, + style: 'normal', + weight: 700, + }, + ], + }, + ); +} diff --git a/apps/web/lib/tokens.test.ts b/apps/web/lib/tokens.test.ts new file mode 100644 index 0000000..0942d91 --- /dev/null +++ b/apps/web/lib/tokens.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { + assetFilenameForToken, + extensionForContentType, + formatExpiryUtc, + issueToken, + isValidToken, + parseAssetSlug, + parseToken, + pathForToken, + resultPathForToken, + sharePathForToken, + tokenFromPathname, +} from './tokens'; + +describe('tokens', () => { + it('issueToken creates a valid round-trippable token', () => { + const now = Date.UTC(2026, 2, 6, 12, 0, 0); + const { token, expiresAt } = issueToken(now, 4 * 60 * 60 * 1000); + const parsed = parseToken(token, now); + + expect(parsed).toEqual({ + token, + expiresAt, + expired: false, + }); + expect(token).toHaveLength(20); + expect(isValidToken(token)).toBe(true); + }); + + it('parseToken marks expired tokens correctly', () => { + const now = Date.UTC(2026, 2, 6, 12, 0, 0); + const { token, expiresAt } = issueToken(now, 60 * 60 * 1000); + const parsed = parseToken(token, expiresAt + 1); + + expect(parsed?.expired).toBe(true); + }); + + it('token path helpers round-trip uploads pathnames', () => { + const { token } = issueToken(Date.UTC(2026, 2, 6, 12, 0, 0)); + const pathname = pathForToken(token); + + expect(tokenFromPathname(pathname)).toBe(token); + expect(tokenFromPathname('avatars/not-a-token')).toBeNull(); + expect(sharePathForToken(token, 'png')).toBe(`/${token}.png`); + }); + + it('asset slug helpers keep token and extension aligned', () => { + const { token } = issueToken(Date.UTC(2026, 2, 6, 12, 0, 0)); + + expect(parseAssetSlug(`${token}.PNG`)).toEqual({ + token, + extension: 'png', + }); + expect(parseAssetSlug(`${token}.bad-ext!`)).toBeNull(); + expect(assetFilenameForToken(token, 'webp')).toBe(`${token}.webp`); + }); + + it('content types map to the expected file extensions', () => { + expect(extensionForContentType('image/jpeg')).toBe('jpg'); + expect(extensionForContentType('image/png')).toBe('png'); + expect(extensionForContentType('application/json')).toBeNull(); + }); + + it('invalid tokens are rejected', () => { + expect(parseToken('bad-token')).toBeNull(); + expect(isValidToken('bad-token')).toBe(false); + }); + + it('formatExpiryUtc renders a UTC timestamp', () => { + const formatted = formatExpiryUtc(Date.UTC(2026, 2, 6, 12, 34, 0)); + + expect(formatted).toMatch(/2026/); + expect(formatted).toMatch(/UTC$/); + }); + + it('resultPathForToken returns /p/', () => { + expect(resultPathForToken('abc123')).toBe('/p/abc123'); + }); + + it('parseToken returns null for tokens with corrupt expiry prefix', () => { + // Valid format but prefix that triggers decode error + const fakeToken = '!!!!!abcdefghijklmno'; + expect(parseToken(fakeToken)).toBeNull(); + }); + + it('issueToken throws when expiry is before token epoch', () => { + // Token epoch is Jan 1 2025 — issuing a token with a date far before that + expect(() => issueToken(0, 1000)).toThrow(/before token epoch/); + }); + + it('issueToken throws when expiry exceeds prefix capacity', () => { + // 36^5 minutes of capacity (~115 years). 200 years should overflow. + const twoHundredYearsMs = 200 * 365 * 24 * 60 * 60 * 1000; + expect(() => issueToken(Date.UTC(2026, 0, 1), twoHundredYearsMs)).toThrow( + /token capacity/, + ); + }); + + it('sharePathForToken omits extension when null', () => { + const { token } = issueToken(Date.UTC(2026, 2, 6, 12, 0, 0)); + expect(sharePathForToken(token)).toBe(`/${token}`); + expect(sharePathForToken(token, null)).toBe(`/${token}`); + }); + + it('parseAssetSlug returns token with null extension for bare tokens', () => { + const { token } = issueToken(Date.UTC(2026, 2, 6, 12, 0, 0)); + const result = parseAssetSlug(token); + expect(result).toEqual({ token, extension: null }); + }); + + it('parseAssetSlug rejects slugs with multiple dots', () => { + const { token } = issueToken(Date.UTC(2026, 2, 6, 12, 0, 0)); + expect(parseAssetSlug(`${token}.a.b`)).toBeNull(); + }); + + it('tokenFromPathname rejects valid prefix with invalid token', () => { + expect(tokenFromPathname('uploads/short')).toBeNull(); + }); +}); diff --git a/apps/web/lib/tokens.ts b/apps/web/lib/tokens.ts new file mode 100644 index 0000000..9ebfdff --- /dev/null +++ b/apps/web/lib/tokens.ts @@ -0,0 +1,132 @@ +import { randomInt } from 'node:crypto'; + +import { + MINUTE_MS, + SHARE_TTL_MS, + TOKEN_EPOCH_MS, + TOKEN_PREFIX_LENGTH, + TOKEN_RANDOM_LENGTH, + UPLOADS_PREFIX, +} from '@/lib/config'; +export { + assetFilenameForToken, + describeExpiry, + extensionForContentType, + formatExpiryUtc, + sharePathForToken, +} from './share'; + +const BASE36_EPOCH_MINUTE = Math.floor(TOKEN_EPOCH_MS / MINUTE_MS); +const RANDOM_ALPHABET = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +const TOKEN_PATTERN = new RegExp( + `^[0-9a-z]{${TOKEN_PREFIX_LENGTH}}[A-Za-z0-9]{${TOKEN_RANDOM_LENGTH}}$`, +); +const EXTENSION_PATTERN = /^[a-z0-9]+$/; + +export type ParsedToken = { + expired: boolean; + expiresAt: number; + token: string; +}; + +function alignToMinute(timestamp: number): number { + return Math.ceil(timestamp / MINUTE_MS) * MINUTE_MS; +} + +function encodeExpiry(expiresAt: number): string { + const expiryMinute = Math.floor(expiresAt / MINUTE_MS); + const offset = expiryMinute - BASE36_EPOCH_MINUTE; + + if (offset < 0) { + throw new Error('Expiry cannot be before token epoch'); + } + + const encoded = offset.toString(36).padStart(TOKEN_PREFIX_LENGTH, '0'); + if (encoded.length > TOKEN_PREFIX_LENGTH) { + throw new Error('Expiry exceeded token capacity'); + } + + return encoded; +} + +function decodeExpiry(prefix: string): number { + const offset = Number.parseInt(prefix, 36); + return (BASE36_EPOCH_MINUTE + offset) * MINUTE_MS; +} + +function randomSegment(length: number): string { + let result = ''; + + for (let index = 0; index < length; index += 1) { + result += RANDOM_ALPHABET[randomInt(RANDOM_ALPHABET.length)]; + } + + return result; +} + +export function issueToken(now = Date.now(), ttlMs = SHARE_TTL_MS): { + expiresAt: number; + token: string; +} { + const expiresAt = alignToMinute(now + ttlMs); + const token = `${encodeExpiry(expiresAt)}${randomSegment(TOKEN_RANDOM_LENGTH)}`; + + return { token, expiresAt }; +} + +export function isValidToken(value: string): boolean { + return TOKEN_PATTERN.test(value); +} + +export function parseToken(token: string, now = Date.now()): ParsedToken | null { + if (!isValidToken(token)) { + return null; + } + + const expiresAt = decodeExpiry(token.slice(0, TOKEN_PREFIX_LENGTH)); + + return { + token, + expiresAt, + expired: now >= expiresAt, + }; +} + +export function pathForToken(token: string): string { + return `${UPLOADS_PREFIX}${token}`; +} + +export function parseAssetSlug( + slug: string, +): { extension: string | null; token: string } | null { + const [token, extensionPart, ...rest] = slug.split('.'); + + if (rest.length > 0 || !isValidToken(token)) { + return null; + } + + if (!extensionPart) { + return { token, extension: null }; + } + + const extension = extensionPart.toLowerCase(); + if (!EXTENSION_PATTERN.test(extension)) { + return null; + } + + return { token, extension }; +} + +export function tokenFromPathname(pathname: string): string | null { + if (!pathname.startsWith(UPLOADS_PREFIX)) { + return null; + } + + const token = pathname.slice(UPLOADS_PREFIX.length); + return isValidToken(token) ? token : null; +} + +export function resultPathForToken(token: string): string { + return `/p/${token}`; +} diff --git a/apps/web/lib/upload-proof.test.ts b/apps/web/lib/upload-proof.test.ts new file mode 100644 index 0000000..d1a5900 --- /dev/null +++ b/apps/web/lib/upload-proof.test.ts @@ -0,0 +1,138 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import { UPLOAD_TOKEN_TTL_MS } from '@/lib/config'; +import { + createUploadProof, + type UploadProofContext, + verifyUploadProof, +} from '@/lib/upload-proof'; + +const ORIGINAL_ENV = { ...process.env }; + +function context(overrides: Partial = {}): UploadProofContext { + return { + token: '0dagxtiLC3aBEk0lbXVE', + pathname: 'uploads/0dagxtiLC3aBEk0lbXVE', + tokenExpiresAt: Date.UTC(2026, 2, 8, 12, 0, 0), + ...overrides, + }; +} + +describe('upload proof', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.GLANCE_UPLOAD_PROOF_SECRET = 'test-proof-secret'; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('creates and verifies a valid proof', () => { + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const proof = createUploadProof(context(), now); + + expect( + verifyUploadProof(proof, context(), now + 1_000), + ).toEqual({ ok: true }); + }); + + it('rejects missing or malformed proof strings', () => { + expect(verifyUploadProof(null, context())).toEqual({ + ok: false, + error: 'Missing upload proof.', + }); + + expect(verifyUploadProof('not-a-proof', context())).toEqual({ + ok: false, + error: 'Invalid upload proof.', + }); + }); + + it('rejects tampered signatures', () => { + const proof = createUploadProof(context()); + const tampered = `${proof}x`; + + expect(verifyUploadProof(tampered, context())).toEqual({ + ok: false, + error: 'Invalid upload proof signature.', + }); + }); + + it('rejects proofs that do not match pathname/token context', () => { + const proof = createUploadProof(context({ pathname: 'uploads/abc' })); + + expect(verifyUploadProof(proof, context())).toEqual({ + ok: false, + error: 'Upload proof does not match upload token.', + }); + }); + + it('expires proofs after upload token TTL window', () => { + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const proof = createUploadProof(context(), now); + + expect( + verifyUploadProof(proof, context(), now + UPLOAD_TOKEN_TTL_MS + 1), + ).toEqual({ + ok: false, + error: 'Upload proof has expired.', + }); + }); + + it('throws when no signing secret is configured', () => { + delete process.env.GLANCE_UPLOAD_PROOF_SECRET; + delete process.env.BLOB_READ_WRITE_TOKEN; + + expect(() => createUploadProof(context())).toThrow( + /Missing upload proof secret/, + ); + }); + + it('returns unavailable error when secret is missing during verify', () => { + const proof = createUploadProof(context()); + delete process.env.GLANCE_UPLOAD_PROOF_SECRET; + delete process.env.BLOB_READ_WRITE_TOKEN; + + expect(verifyUploadProof(proof, context())).toEqual({ + ok: false, + error: 'Upload proof verification is unavailable.', + }); + }); + + it('rejects proofs with corrupted base64 payload', () => { + // Valid signature format but garbage payload that won't parse as JSON + const garbage = Buffer.from('not-valid-json', 'utf8').toString('base64url'); + const proof = `${garbage}.fakesig`; + + expect(verifyUploadProof(proof, context())).toEqual({ + ok: false, + error: 'Invalid upload proof signature.', + }); + }); + + it('rejects proofs with valid signature but invalid claims shape', () => { + // Create a proof with missing fields + const { createHmac } = require('node:crypto'); + const secret = process.env.GLANCE_UPLOAD_PROOF_SECRET!; + const payload = Buffer.from(JSON.stringify({ v: 2, bad: true }), 'utf8').toString('base64url'); + const sig = createHmac('sha256', secret).update(payload).digest('base64url'); + + expect(verifyUploadProof(`${payload}.${sig}`, context())).toEqual({ + ok: false, + error: 'Invalid upload proof payload.', + }); + }); + + it('rejects proofs with valid signature but non-JSON payload bytes', () => { + const { createHmac } = require('node:crypto'); + const secret = process.env.GLANCE_UPLOAD_PROOF_SECRET!; + const payload = Buffer.from('{"bad":', 'utf8').toString('base64url'); + const sig = createHmac('sha256', secret).update(payload).digest('base64url'); + + expect(verifyUploadProof(`${payload}.${sig}`, context())).toEqual({ + ok: false, + error: 'Invalid upload proof payload.', + }); + }); +}); diff --git a/apps/web/lib/upload-proof.ts b/apps/web/lib/upload-proof.ts new file mode 100644 index 0000000..4a779fa --- /dev/null +++ b/apps/web/lib/upload-proof.ts @@ -0,0 +1,140 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { UPLOAD_TOKEN_TTL_MS } from '@/lib/config'; + +type UploadProofClaims = { + v: 1; + token: string; + pathname: string; + tokenExpiresAt: number; + proofExpiresAt: number; +}; + +export type UploadProofContext = { + pathname: string; + token: string; + tokenExpiresAt: number; +}; + +export type UploadProofVerificationResult = + | { ok: true } + | { error: string; ok: false }; + +function getUploadProofSecret(): string { + const secret = + process.env.GLANCE_UPLOAD_PROOF_SECRET ?? + process.env.AGENTPASTE_UPLOAD_PROOF_SECRET ?? + process.env.BLOB_READ_WRITE_TOKEN; + + if (!secret) { + throw new Error( + 'Missing upload proof secret. Set GLANCE_UPLOAD_PROOF_SECRET or BLOB_READ_WRITE_TOKEN.', + ); + } + + return secret; +} + +function sign(encodedPayload: string, secret: string): string { + return createHmac('sha256', secret) + .update(encodedPayload) + .digest('base64url'); +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, 'utf8'); + const rightBuffer = Buffer.from(right, 'utf8'); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function parseClaims(encodedPayload: string): UploadProofClaims | null { + try { + const raw = Buffer.from(encodedPayload, 'base64url').toString('utf8'); + const parsed = JSON.parse(raw) as Partial; + + if ( + parsed.v !== 1 || + typeof parsed.token !== 'string' || + typeof parsed.pathname !== 'string' || + typeof parsed.tokenExpiresAt !== 'number' || + typeof parsed.proofExpiresAt !== 'number' + ) { + return null; + } + + return parsed as UploadProofClaims; + } catch { + return null; + } +} + +export function createUploadProof( + context: UploadProofContext, + now = Date.now(), +): string { + const claims: UploadProofClaims = { + v: 1, + token: context.token, + pathname: context.pathname, + tokenExpiresAt: context.tokenExpiresAt, + proofExpiresAt: now + UPLOAD_TOKEN_TTL_MS, + }; + + const encodedPayload = Buffer.from(JSON.stringify(claims), 'utf8').toString( + 'base64url', + ); + const signature = sign(encodedPayload, getUploadProofSecret()); + + return `${encodedPayload}.${signature}`; +} + +export function verifyUploadProof( + proof: string | null | undefined, + context: UploadProofContext, + now = Date.now(), +): UploadProofVerificationResult { + if (!proof) { + return { ok: false, error: 'Missing upload proof.' }; + } + + const [encodedPayload, signature, ...rest] = proof.split('.'); + + if (!encodedPayload || !signature || rest.length > 0) { + return { ok: false, error: 'Invalid upload proof.' }; + } + + let expectedSignature: string; + try { + expectedSignature = sign(encodedPayload, getUploadProofSecret()); + } catch { + return { ok: false, error: 'Upload proof verification is unavailable.' }; + } + + if (!safeEqual(signature, expectedSignature)) { + return { ok: false, error: 'Invalid upload proof signature.' }; + } + + const claims = parseClaims(encodedPayload); + if (!claims) { + return { ok: false, error: 'Invalid upload proof payload.' }; + } + + if (claims.proofExpiresAt <= now) { + return { ok: false, error: 'Upload proof has expired.' }; + } + + if ( + claims.pathname !== context.pathname || + claims.token !== context.token || + claims.tokenExpiresAt !== context.tokenExpiresAt + ) { + return { ok: false, error: 'Upload proof does not match upload token.' }; + } + + return { ok: true }; +} diff --git a/apps/web/lib/url.test.ts b/apps/web/lib/url.test.ts new file mode 100644 index 0000000..29f9d33 --- /dev/null +++ b/apps/web/lib/url.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const headerState = vi.hoisted(() => ({ + values: new Map(), +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => ({ + get(key: string) { + return headerState.values.get(key) ?? null; + }, + })), +})); + +import { getBaseUrl } from './url'; + +describe('getBaseUrl', () => { + beforeEach(() => { + headerState.values.clear(); + }); + + it('returns empty string without host headers', async () => { + await expect(getBaseUrl()).resolves.toBe(''); + }); + + it('prefers forwarded host + proto and strips www', async () => { + headerState.values.set('x-forwarded-host', 'www.glance.sh'); + headerState.values.set('x-forwarded-proto', 'https'); + + await expect(getBaseUrl()).resolves.toBe('https://glance.sh'); + }); + + it('uses http for localhost when proto missing', async () => { + headerState.values.set('host', 'localhost:3000'); + + await expect(getBaseUrl()).resolves.toBe('http://localhost:3000'); + }); + + it('uses https for non-local hosts when proto missing', async () => { + headerState.values.set('host', 'glance.sh'); + + await expect(getBaseUrl()).resolves.toBe('https://glance.sh'); + }); +}); diff --git a/apps/web/lib/url.ts b/apps/web/lib/url.ts new file mode 100644 index 0000000..7b94f8d --- /dev/null +++ b/apps/web/lib/url.ts @@ -0,0 +1,21 @@ +import { headers } from 'next/headers'; + +export async function getBaseUrl(): Promise { + const headerList = await headers(); + const host = + headerList.get('x-forwarded-host') ?? headerList.get('host') ?? ''; + + if (!host) { + return ''; + } + + const protocol = + headerList.get('x-forwarded-proto') ?? + (host.startsWith('localhost') || host.startsWith('127.0.0.1') + ? 'http' + : 'https'); + + const bareHost = host.replace(/^www\./i, ''); + + return `${protocol}://${bareHost}`; +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..4ff675e --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,59 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +import { buildContentSecurityPolicy } from './lib/csp'; + +const appRoot = dirname(fileURLToPath(import.meta.url)); + +const nextConfig: NextConfig = { + turbopack: { + root: appRoot, + }, + async headers() { + const isDev = process.env.NODE_ENV !== 'production'; + + return [ + { + source: '/(.*)', + headers: [ + { + key: 'Content-Security-Policy', + value: buildContentSecurityPolicy({ isDev }), + }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()', + }, + ], + }, + ]; + }, +}; + +const sentrySourceMapsEnabled = Boolean( + process.env.SENTRY_AUTH_TOKEN && + process.env.SENTRY_ORG && + process.env.SENTRY_PROJECT, +); + +export default sentrySourceMapsEnabled + ? withSentryConfig(nextConfig, { + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + silent: !process.env.CI, + widenClientFileUpload: true, + tunnelRoute: process.env.SENTRY_TUNNEL_ROUTE ?? '/monitoring', + webpack: { + automaticVercelMonitors: true, + treeshake: { + removeDebugLogging: true, + }, + }, + }) + : nextConfig; diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..06d68a1 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,5595 @@ +{ + "name": "@modemdev/glance-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@modemdev/glance-web", + "version": "0.1.0", + "dependencies": { + "@ai-sdk/google": "^3.0.43", + "@sentry/nextjs": "^10.42.0", + "@upstash/redis": "^1.36.3", + "@vercel/analytics": "^1.6.1", + "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", + "next": "^16.2.6", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitest/coverage-v8": "^4.0.18", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vitest": "^4.0.18" + }, + "license": "MIT" + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.66", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", + "integrity": "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.43", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz", + "integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "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/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/otel": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", + "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.28.0", + "minimatch": "^10.0.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", + "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", + "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", + "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", + "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", + "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", + "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", + "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", + "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", + "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", + "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", + "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/instrumentation": "0.211.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", + "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", + "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", + "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", + "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", + "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", + "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", + "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", + "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", + "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", + "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", + "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", + "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", + "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@prisma/instrumentation": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", + "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", + "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz", + "integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz", + "integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz", + "integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.42.0", + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz", + "integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.42.0", + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.1.1.tgz", + "integrity": "sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz", + "integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.42.0", + "@sentry-internal/feedback": "10.42.0", + "@sentry-internal/replay": "10.42.0", + "@sentry-internal/replay-canvas": "10.42.0", + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.1.1.tgz", + "integrity": "sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.1.1", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", + "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.5", + "@sentry/cli-linux-arm": "2.58.5", + "@sentry/cli-linux-arm64": "2.58.5", + "@sentry/cli-linux-i686": "2.58.5", + "@sentry/cli-linux-x64": "2.58.5", + "@sentry/cli-win32-arm64": "2.58.5", + "@sentry/cli-win32-i686": "2.58.5", + "@sentry/cli-win32-x64": "2.58.5" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", + "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", + "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", + "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", + "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", + "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", + "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", + "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", + "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/core": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", + "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/nextjs": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.42.0.tgz", + "integrity": "sha512-4YcVwicZLQWCNXMRSmtg0q68cqhttwhUqcvTe0aYg4YkQIDQKzVOYVU7/js9kSK1PFe9gFdaUxgboBYBp2evDg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/semantic-conventions": "^1.37.0", + "@rollup/plugin-commonjs": "28.0.1", + "@sentry-internal/browser-utils": "10.42.0", + "@sentry/bundler-plugin-core": "^5.1.0", + "@sentry/core": "10.42.0", + "@sentry/node": "10.42.0", + "@sentry/opentelemetry": "10.42.0", + "@sentry/react": "10.42.0", + "@sentry/vercel-edge": "10.42.0", + "@sentry/webpack-plugin": "^5.1.0", + "rollup": "^4.35.0", + "stacktrace-parser": "^0.1.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" + } + }, + "node_modules/@sentry/node": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.42.0.tgz", + "integrity": "sha512-ZZfU3Fnni7Aj0lTX4e3QpY3UxK4FGuzfM20316UAJycBGnripm+sDHwcekPMGfLnk/FrN9wa1atspVlHvOI0WQ==", + "license": "MIT", + "dependencies": { + "@fastify/otel": "0.16.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.5.1", + "@opentelemetry/core": "^2.5.1", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/instrumentation-amqplib": "0.58.0", + "@opentelemetry/instrumentation-connect": "0.54.0", + "@opentelemetry/instrumentation-dataloader": "0.28.0", + "@opentelemetry/instrumentation-express": "0.59.0", + "@opentelemetry/instrumentation-fs": "0.30.0", + "@opentelemetry/instrumentation-generic-pool": "0.54.0", + "@opentelemetry/instrumentation-graphql": "0.58.0", + "@opentelemetry/instrumentation-hapi": "0.57.0", + "@opentelemetry/instrumentation-http": "0.211.0", + "@opentelemetry/instrumentation-ioredis": "0.59.0", + "@opentelemetry/instrumentation-kafkajs": "0.20.0", + "@opentelemetry/instrumentation-knex": "0.55.0", + "@opentelemetry/instrumentation-koa": "0.59.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", + "@opentelemetry/instrumentation-mongodb": "0.64.0", + "@opentelemetry/instrumentation-mongoose": "0.57.0", + "@opentelemetry/instrumentation-mysql": "0.57.0", + "@opentelemetry/instrumentation-mysql2": "0.57.0", + "@opentelemetry/instrumentation-pg": "0.63.0", + "@opentelemetry/instrumentation-redis": "0.59.0", + "@opentelemetry/instrumentation-tedious": "0.30.0", + "@opentelemetry/instrumentation-undici": "0.21.0", + "@opentelemetry/resources": "^2.5.1", + "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/semantic-conventions": "^1.39.0", + "@prisma/instrumentation": "7.2.0", + "@sentry/core": "10.42.0", + "@sentry/node-core": "10.42.0", + "@sentry/opentelemetry": "10.42.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.42.0.tgz", + "integrity": "sha512-9tf3fPV6M071aps72D+PEtdQPTuj+SuqO2+PpTfdPP5ZL4TTKYo3VK0li76SL+5wGdTFGV5qmsokHq9IRBA0iA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.42.0", + "@sentry/opentelemetry": "10.42.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.42.0.tgz", + "integrity": "sha512-5vsYz683iihzlIj3sT1+tEixf0awwXK86a+aYsnMHrTXJDrkBDq4U0ZT+yxdPfJlkaxRtYycFR08SXr2pSm7Eg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.42.0.tgz", + "integrity": "sha512-uigyz6E3yPjjqIZpkGzRChww6gzMmqdCpK30M5aBYoaen29DDmSECHYA16sfgXeSwzQhnXyX7GxgOB+eKIr9dw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.42.0", + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/vercel-edge": { + "version": "10.42.0", + "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.42.0.tgz", + "integrity": "sha512-BjK5P5qBBC1biAErKlDICiXaer7FnqAL7NcBCD0pHK7aLO5IAzyegfA0zcu4fIo8TIqipLJiCOGmkYaiSALq8g==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/resources": "^2.5.1", + "@sentry/core": "10.42.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/webpack-plugin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.1.1.tgz", + "integrity": "sha512-XgQg+t2aVrlQDfIiAEizqR/bsy6GtBygwgR+Kw11P/cYczj4W9PZ2IYqQEStBzHqnRTh5DbpyMcUNW2CujdA9A==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.1.1", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "webpack": ">=5.0.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==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/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", + "peer": true + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@upstash/redis": { + "version": "1.36.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.3.tgz", + "integrity": "sha512-wxo1ei4OHDHm4UGMgrNVz9QUEela9N/Iwi4p1JlHNSowQiPi+eljlGnfbZVkV0V4PIrjGtGFJt5GjWM5k28enA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vercel/analytics": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", + "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", + "license": "MPL-2.0", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/@vercel/blob": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.1.tgz", + "integrity": "sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==", + "license": "Apache-2.0", + "dependencies": { + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ai": { + "version": "6.0.116", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz", + "integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.66", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.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/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "peer": true + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/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==", + "license": "MIT", + "peer": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "peer": true + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/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-lib-report/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/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/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@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/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "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==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "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/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "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": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.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", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.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.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "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 + } + } + }, + "node_modules/vitest/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..65c7b8f --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,42 @@ +{ + "name": "@modemdev/glance-web", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "npm@11.6.2", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e:live": "vitest run tests/e2e", + "test:legacy": "node --import tsx --test lib/*.test.ts" + }, + "dependencies": { + "@ai-sdk/google": "^3.0.43", + "@sentry/nextjs": "^10.42.0", + "@upstash/redis": "^1.36.3", + "@vercel/analytics": "^1.6.1", + "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", + "next": "^16.2.6", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitest/coverage-v8": "^4.0.18", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vitest": "^4.0.18" + }, + "overrides": { + "postcss": "8.5.14" + }, + "license": "MIT" +} diff --git a/apps/web/public/logos/claude-code.svg b/apps/web/public/logos/claude-code.svg new file mode 100644 index 0000000..63c171a --- /dev/null +++ b/apps/web/public/logos/claude-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/logos/codex.svg b/apps/web/public/logos/codex.svg new file mode 100644 index 0000000..6c0007f --- /dev/null +++ b/apps/web/public/logos/codex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/logos/opencode.svg b/apps/web/public/logos/opencode.svg new file mode 100644 index 0000000..6f358d5 --- /dev/null +++ b/apps/web/public/logos/opencode.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/logos/pi.svg b/apps/web/public/logos/pi.svg new file mode 100644 index 0000000..c8d4991 --- /dev/null +++ b/apps/web/public/logos/pi.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts new file mode 100644 index 0000000..f68c31d --- /dev/null +++ b/apps/web/sentry.edge.config.ts @@ -0,0 +1,18 @@ +// This file configures Sentry for edge features when a DSN is provided. +// Self-hosted installs are telemetry-free by default. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; + +if (dsn) { + Sentry.init({ + dsn, + tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1'), + enableLogs: process.env.SENTRY_ENABLE_LOGS === '1', + + // Do not collect user PII by default. + sendDefaultPii: false, + }); +} diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts new file mode 100644 index 0000000..3b79bcc --- /dev/null +++ b/apps/web/sentry.server.config.ts @@ -0,0 +1,19 @@ +// This file configures Sentry on the server when a DSN is provided. +// Self-hosted installs are telemetry-free by default. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; + +if (dsn) { + Sentry.init({ + dsn, + tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1'), + enableLogs: process.env.SENTRY_ENABLE_LOGS === '1', + + // Do not collect user PII by default. Hosted deployments may opt in with + // Sentry-side settings if they need more context and have disclosed it. + sendDefaultPii: false, + }); +} diff --git a/apps/web/tests/api/asset-route-contract.test.ts b/apps/web/tests/api/asset-route-contract.test.ts new file mode 100644 index 0000000..091f7f8 --- /dev/null +++ b/apps/web/tests/api/asset-route-contract.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest'; + +const blobMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('@vercel/blob', () => ({ + get: blobMock.get, +})); + +import { GET as assetGet } from '@/app/[token]/route'; +import { encrypt } from '@/lib/encryption'; +import { issueToken } from '@/lib/tokens'; + +function streamFromBuffer(buf: ArrayBuffer): ReadableStream { + const bytes = new Uint8Array(buf); + let sent = false; + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + controller.enqueue(bytes); + sent = true; + }, + }); +} + +async function encryptTestImage( + token: string, + plaintext: Uint8Array = new Uint8Array([137, 80, 78, 71]), + contentType = 'image/png', +) { + const encrypted = await encrypt( + plaintext.buffer as ArrayBuffer, + contentType, + token, + ); + return { encrypted, plaintext, contentType }; +} + +describe('GET /[token]', () => { + it('returns 404 for malformed slugs', async () => { + const response = await assetGet(new Request('http://localhost/bad'), { + params: Promise.resolve({ token: 'bad/slug' }), + }); + + expect(response.status).toBe(404); + }); + + it('returns 410 for expired tokens', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now - 2 * 60 * 60 * 1000); + const { token } = issueToken(); + vi.setSystemTime(now); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.png` }), + }); + + expect(response.status).toBe(410); + vi.useRealTimers(); + }); + + it('returns 404 for valid slug format but invalid token content', async () => { + // 20 chars that match slug regex but won't parse as a valid token + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: '00000AAAAABBBBBCCCCC.png' }), + }); + + // parseToken may return non-null here (it's a valid format), + // so this tests the expiry path or the parseToken null path + expect([404, 410]).toContain(response.status); + }); + + it('returns 404 if blob does not exist', async () => { + const { token } = issueToken(); + blobMock.get.mockResolvedValue(null); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.png` }), + }); + + expect(response.status).toBe(404); + }); + + it('decrypts encrypted blob and serves with correct content-type', async () => { + const { token } = issueToken(); + const { encrypted, plaintext, contentType } = await encryptTestImage(token); + + blobMock.get.mockResolvedValue({ + statusCode: 200, + blob: { + contentType: 'application/octet-stream', + etag: 'etag-123', + size: encrypted.byteLength, + }, + stream: streamFromBuffer(encrypted), + headers: new Headers(), + }); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.png` }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe(contentType); + + const body = new Uint8Array(await response.arrayBuffer()); + expect(body).toEqual(plaintext); + }); + + it('returns 404 for blob that fails decryption (garbage data)', async () => { + const { token } = issueToken(); + const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]); + + blobMock.get.mockResolvedValue({ + statusCode: 200, + blob: { + contentType: 'application/octet-stream', + etag: 'etag-456', + size: garbage.byteLength, + }, + stream: streamFromBuffer(garbage.buffer as ArrayBuffer), + headers: new Headers(), + }); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.png` }), + }); + + expect(response.status).toBe(404); + }); + + it('returns 404 for blob encrypted with wrong token', async () => { + const { token: tokenA } = issueToken(); + const { token: tokenB } = issueToken(); + const { encrypted } = await encryptTestImage(tokenA); + + blobMock.get.mockResolvedValue({ + statusCode: 200, + blob: { + contentType: 'application/octet-stream', + etag: 'etag-789', + size: encrypted.byteLength, + }, + stream: streamFromBuffer(encrypted), + headers: new Headers(), + }); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${tokenB}.png` }), + }); + + expect(response.status).toBe(404); + }); + + it('includes security headers on successful decrypted response', async () => { + const { token } = issueToken(); + const { encrypted } = await encryptTestImage(token); + + blobMock.get.mockResolvedValue({ + statusCode: 200, + blob: { + contentType: 'application/octet-stream', + etag: 'etag-sec', + size: encrypted.byteLength, + }, + stream: streamFromBuffer(encrypted), + headers: new Headers(), + }); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.png` }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toContain('private'); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(response.headers.get('X-Robots-Tag')).toContain('noindex'); + expect(response.headers.get('Content-Disposition')).toContain('inline'); + }); + + it('serves different image types correctly', async () => { + const { token } = issueToken(); + const jpegBytes = new Uint8Array([255, 216, 255, 224]); + const { encrypted } = await encryptTestImage(token, jpegBytes, 'image/jpeg'); + + blobMock.get.mockResolvedValue({ + statusCode: 200, + blob: { + contentType: 'application/octet-stream', + etag: 'etag-jpg', + size: encrypted.byteLength, + }, + stream: streamFromBuffer(encrypted), + headers: new Headers(), + }); + + const response = await assetGet(new Request('http://localhost'), { + params: Promise.resolve({ token: `${token}.jpg` }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('image/jpeg'); + + const body = new Uint8Array(await response.arrayBuffer()); + expect(body).toEqual(jpegBytes); + }); +}); diff --git a/apps/web/tests/api/cron-auth-contract.test.ts b/apps/web/tests/api/cron-auth-contract.test.ts new file mode 100644 index 0000000..b667183 --- /dev/null +++ b/apps/web/tests/api/cron-auth-contract.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobMock = vi.hoisted(() => ({ + del: vi.fn(), + list: vi.fn(), +})); + +const sentryMock = vi.hoisted(() => ({ + metrics: { + count: vi.fn(), + }, +})); + +vi.mock('@vercel/blob', () => ({ + del: blobMock.del, + list: blobMock.list, +})); + +vi.mock('@sentry/nextjs', () => sentryMock); + +import { GET as cleanupGet } from '@/app/api/cron/cleanup/route'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('cron endpoint auth contract', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.CRON_SECRET; + delete process.env.GLANCE_ALLOW_UNAUTHENTICATED_CRON; + delete process.env.AGENTPASTE_ALLOW_UNAUTHENTICATED_CRON; + delete process.env.VERCEL; + + blobMock.del.mockReset(); + blobMock.list.mockReset(); + blobMock.list.mockResolvedValue({ blobs: [], hasMore: false }); + + sentryMock.metrics.count.mockReset(); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('denies by default when CRON_SECRET is unset', async () => { + const cleanup = await cleanupGet(new Request('http://localhost/api/cron/cleanup')); + + expect(cleanup.status).toBe(500); + await expect(cleanup.json()).resolves.toMatchObject({ + error: expect.stringContaining('CRON_SECRET is required'), + }); + }); + + it('rejects bad bearer token and accepts a valid secret', async () => { + process.env.CRON_SECRET = 'super-secret'; + + const badCleanup = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer nope' }, + }), + ); + + expect(badCleanup.status).toBe(401); + + const goodCleanup = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer super-secret' }, + }), + ); + + expect(goodCleanup.status).toBe(200); + }); + + it('allows local-dev bypass only when explicitly enabled', async () => { + process.env.NODE_ENV = 'development'; + process.env.GLANCE_ALLOW_UNAUTHENTICATED_CRON = '1'; + + const cleanup = await cleanupGet(new Request('http://localhost/api/cron/cleanup')); + + expect(cleanup.status).toBe(200); + }); +}); diff --git a/apps/web/tests/api/cron-routes-coverage.test.ts b/apps/web/tests/api/cron-routes-coverage.test.ts new file mode 100644 index 0000000..b5c5828 --- /dev/null +++ b/apps/web/tests/api/cron-routes-coverage.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobMock = vi.hoisted(() => ({ + del: vi.fn(), + list: vi.fn(), +})); + +const sentryMock = vi.hoisted(() => ({ + metrics: { + count: vi.fn(), + }, +})); + +vi.mock('@vercel/blob', () => ({ + del: blobMock.del, + list: blobMock.list, +})); + +vi.mock('@sentry/nextjs', () => sentryMock); + +import { GET as cleanupGet } from '@/app/api/cron/cleanup/route'; +import { issueToken } from '@/lib/tokens'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('cron routes coverage', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + blobMock.del.mockReset(); + blobMock.list.mockReset(); + sentryMock.metrics.count.mockReset(); + }); + + it('rejects cleanup cron with bad auth when CRON_SECRET is set', async () => { + process.env.CRON_SECRET = 'secret'; + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer wrong' }, + }), + ); + + expect(response.status).toBe(401); + }); + + it('deletes expired blobs during cleanup', async () => { + process.env.CRON_SECRET = 'secret'; + + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now - 2 * 60 * 60 * 1000); + const expired = issueToken().token; + vi.setSystemTime(now); + const fresh = issueToken().token; + + blobMock.list.mockResolvedValue({ + blobs: [ + { pathname: `uploads/${expired}` }, + { pathname: `uploads/${fresh}` }, + ], + hasMore: false, + }); + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer secret' }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ deleted: 1, scanned: 2 }); + expect(blobMock.del).toHaveBeenCalledWith([`uploads/${expired}`]); + + vi.useRealTimers(); + }); + + it('skips non-token pathnames in blob listing', async () => { + process.env.CRON_SECRET = 'secret'; + + blobMock.list.mockResolvedValue({ + blobs: [ + { pathname: 'uploads/' }, + { pathname: 'random/file.txt' }, + ], + hasMore: false, + }); + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer secret' }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ deleted: 0, scanned: 2 }); + expect(blobMock.del).not.toHaveBeenCalled(); + }); + + it('skips non-expired tokens', async () => { + process.env.CRON_SECRET = 'secret'; + + const fresh = issueToken().token; + + blobMock.list.mockResolvedValue({ + blobs: [{ pathname: `uploads/${fresh}` }], + hasMore: false, + }); + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer secret' }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ deleted: 0 }); + expect(blobMock.del).not.toHaveBeenCalled(); + }); + + it('deletes in batches when many expired blobs are found', async () => { + process.env.CRON_SECRET = 'secret'; + + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now - 3 * 60 * 60 * 1000); + + const blobs = Array.from({ length: 101 }, () => ({ + pathname: `uploads/${issueToken().token}`, + })); + + vi.setSystemTime(now); + + blobMock.list.mockResolvedValue({ + blobs, + hasMore: false, + }); + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer secret' }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ deleted: 101, scanned: 101 }); + expect(blobMock.del).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('iterates through paginated blob listings', async () => { + process.env.CRON_SECRET = 'secret'; + + blobMock.list + .mockResolvedValueOnce({ + blobs: [{ pathname: 'uploads/not-a-token' }], + hasMore: true, + cursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + blobs: [], + hasMore: false, + }); + + const response = await cleanupGet( + new Request('http://localhost/api/cron/cleanup', { + headers: { authorization: 'Bearer secret' }, + }), + ); + + expect(response.status).toBe(200); + expect(blobMock.list).toHaveBeenCalledTimes(2); + }); + + it('returns 500 when CRON_SECRET is missing in Vercel env', async () => { + process.env.VERCEL = '1'; + delete process.env.CRON_SECRET; + + const cleanup = await cleanupGet(new Request('http://localhost/api/cron/cleanup')); + + expect(cleanup.status).toBe(500); + }); +}); diff --git a/apps/web/tests/api/issue-rate-limit-fallback.test.ts b/apps/web/tests/api/issue-rate-limit-fallback.test.ts new file mode 100644 index 0000000..112a157 --- /dev/null +++ b/apps/web/tests/api/issue-rate-limit-fallback.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; + +const rateLimitMock = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: rateLimitMock.checkRateLimit, +})); + +// Keep proof generation from failing in this test. +vi.mock('@/lib/upload-proof', async () => { + const actual = await vi.importActual('@/lib/upload-proof'); + return { + ...actual, + createUploadProof: vi.fn(() => 'proof'), + }; +}); + +import { POST as issuePost } from '@/app/api/issue/route'; + +describe('issue route rate-limit fallback', () => { + it('uses Retry-After=3600 when rate limiter omits retryAfterSeconds', async () => { + rateLimitMock.checkRateLimit.mockResolvedValue({ allowed: false }); + + const response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + }) as any, + ); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('3600'); + }); +}); diff --git a/apps/web/tests/api/issue-route-coverage.test.ts b/apps/web/tests/api/issue-route-coverage.test.ts new file mode 100644 index 0000000..6056e54 --- /dev/null +++ b/apps/web/tests/api/issue-route-coverage.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import { POST as issuePost } from '@/app/api/issue/route'; + +const ORIGINAL_ENV = { ...process.env }; + +describe('issue route coverage', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('returns 429 with Retry-After when upload issuance is rate limited', async () => { + const ip = '198.51.100.55'; + let response: Response | null = null; + + for (let i = 0; i < 31; i += 1) { + response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + headers: { 'x-real-ip': ip }, + }) as any, + ); + } + + if (!response) throw new Error('Expected response'); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).not.toBeNull(); + }); + + it('extracts IP from x-forwarded-for header', async () => { + process.env.GLANCE_UPLOAD_PROOF_SECRET = 'test-secret'; + + const response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + headers: { 'x-forwarded-for': '203.0.113.1, 10.0.0.1' }, + }) as any, + ); + + expect(response.status).toBe(200); + }); + + it('falls back to unknown IP when no headers present', async () => { + process.env.GLANCE_UPLOAD_PROOF_SECRET = 'test-secret'; + + const response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + }) as any, + ); + + expect(response.status).toBe(200); + }); + + it('returns 500 when upload proof secret configuration is missing', async () => { + delete process.env.BLOB_READ_WRITE_TOKEN; + delete process.env.GLANCE_UPLOAD_PROOF_SECRET; + + const response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + headers: { 'x-real-ip': '198.51.100.56' }, + }) as any, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: 'Upload service is misconfigured.', + }); + }); +}); diff --git a/apps/web/tests/api/issue-upload-contract.test.ts b/apps/web/tests/api/issue-upload-contract.test.ts new file mode 100644 index 0000000..e54cea1 --- /dev/null +++ b/apps/web/tests/api/issue-upload-contract.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobClientMock = vi.hoisted(() => ({ + handleUpload: vi.fn(), +})); + +vi.mock('@vercel/blob/client', () => ({ + handleUpload: blobClientMock.handleUpload, +})); + +import { UPLOAD_TOKEN_TTL_MS } from '@/lib/config'; +import { POST as issuePost } from '@/app/api/issue/route'; +import { POST as uploadPost } from '@/app/api/upload/route'; + +type IssueResponse = { + expiresAt: number; + pathname: string; + token: string; + uploadProof: string; +}; + +const ORIGINAL_ENV = { ...process.env }; + +function uploadEvent(pathname: string, clientPayload: string | null) { + return { + type: 'blob.generate-client-token', + payload: { + pathname, + clientPayload, + multipart: false, + }, + }; +} + +async function issue(ip: string) { + const response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + headers: { 'x-real-ip': ip }, + }) as any, + ); + + return { + response, + payload: (await response.json()) as IssueResponse, + }; +} + +async function requestUpload(pathname: string, proof: string | null) { + return uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent(pathname, proof)), + }), + ); +} + +describe('issue + upload contract', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_test_store_secret'; + process.env.GLANCE_UPLOAD_PROOF_SECRET = 'upload-proof-contract-secret'; + + blobClientMock.handleUpload.mockReset(); + blobClientMock.handleUpload.mockImplementation(async (options: any) => { + const event = options.body; + if (event?.type !== 'blob.generate-client-token') { + throw new Error('Unexpected upload event type in test.'); + } + + await options.onBeforeGenerateToken( + event.payload.pathname, + event.payload.clientPayload, + event.payload.multipart, + ); + + return { + type: 'blob.generate-client-token', + clientToken: 'client-token-from-mock', + }; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + process.env = ORIGINAL_ENV; + }); + + it('issues an upload proof with each token', async () => { + const { response, payload } = await issue('198.51.100.11'); + + expect(response.status).toBe(200); + expect(payload.token).toHaveLength(20); + expect(payload.pathname).toBe(`uploads/${payload.token}`); + expect(payload.expiresAt).toBeTypeOf('number'); + expect(payload.uploadProof).toBeTypeOf('string'); + expect(payload.uploadProof.length).toBeGreaterThan(20); + }); + + it('accepts /api/upload only when proof matches issued token + pathname', async () => { + const { payload } = await issue('198.51.100.12'); + + const response = await requestUpload(payload.pathname, payload.uploadProof); + const body = (await response.json()) as { + clientToken?: string; + type?: string; + }; + + expect(response.status).toBe(200); + expect(body).toEqual({ + type: 'blob.generate-client-token', + clientToken: 'client-token-from-mock', + }); + }); + + it('rejects uploads without proof (legacy bypass attempt)', async () => { + const { payload } = await issue('198.51.100.13'); + + const response = await requestUpload(payload.pathname, null); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Missing upload proof.', + }); + }); + + it('rejects tampered proofs', async () => { + const { payload } = await issue('198.51.100.14'); + + const response = await requestUpload( + payload.pathname, + `${payload.uploadProof}x`, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Invalid upload proof signature.', + }); + }); + + it('rejects proofs reused on a different pathname/token', async () => { + const first = await issue('198.51.100.15'); + const second = await issue('198.51.100.16'); + + const response = await requestUpload( + first.payload.pathname, + second.payload.uploadProof, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Upload proof does not match upload token.', + }); + }); + + it('rejects expired proofs', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now); + + const { payload } = await issue('198.51.100.17'); + + vi.setSystemTime(now + UPLOAD_TOKEN_TTL_MS + 1); + + const response = await requestUpload(payload.pathname, payload.uploadProof); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Upload proof has expired.', + }); + }); +}); diff --git a/apps/web/tests/api/ocr-contract.test.ts b/apps/web/tests/api/ocr-contract.test.ts new file mode 100644 index 0000000..9ba8670 --- /dev/null +++ b/apps/web/tests/api/ocr-contract.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const aiMock = vi.hoisted(() => ({ + generateText: vi.fn(), +})); + +const rateLimitMock = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), +})); + +const sentryMock = vi.hoisted(() => ({ + captureException: vi.fn(), +})); + +vi.mock('@vercel/blob', () => ({ + get: blobMock.get, +})); + +vi.mock('ai', () => ({ + generateText: aiMock.generateText, +})); + +vi.mock('@ai-sdk/google', () => ({ + google: vi.fn(() => 'gemini-test-model'), +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: rateLimitMock.checkRateLimit, +})); + +vi.mock('@sentry/nextjs', () => sentryMock); + +import { POST as ocrPost } from '@/app/api/ocr/route'; +import { encrypt } from '@/lib/encryption'; +import { issueToken } from '@/lib/tokens'; + +function streamFromBuffer(buf: ArrayBuffer): ReadableStream { + const bytes = new Uint8Array(buf); + let sent = false; + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + controller.enqueue(bytes); + sent = true; + }, + }); +} + +describe('POST /api/ocr', () => { + beforeEach(() => { + blobMock.get.mockReset(); + aiMock.generateText.mockReset(); + rateLimitMock.checkRateLimit.mockReset(); + sentryMock.captureException.mockReset(); + + rateLimitMock.checkRateLimit.mockReturnValue({ allowed: true }); + }); + + it('returns sanitized OCR errors without leaking upstream details', async () => { + const { token } = issueToken(); + const encrypted = await encrypt( + new Uint8Array([1, 2, 3]).buffer as ArrayBuffer, + 'image/png', + token, + ); + + blobMock.get.mockResolvedValue({ + blob: { + contentType: 'application/octet-stream', + url: 'https://blob.example/ocr', + }, + stream: streamFromBuffer(encrypted), + }); + + aiMock.generateText.mockRejectedValue( + new Error('upstream model timeout: request id abc123'), + ); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-real-ip': '198.51.100.44', + }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: 'OCR processing failed.', + }); + + expect(sentryMock.captureException).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); +}); diff --git a/apps/web/tests/api/ocr-route-coverage.test.ts b/apps/web/tests/api/ocr-route-coverage.test.ts new file mode 100644 index 0000000..2f23694 --- /dev/null +++ b/apps/web/tests/api/ocr-route-coverage.test.ts @@ -0,0 +1,337 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const aiMock = vi.hoisted(() => ({ + generateText: vi.fn(), +})); + +const rateLimitMock = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@vercel/blob', () => ({ + get: blobMock.get, +})); + +vi.mock('ai', () => ({ + generateText: aiMock.generateText, +})); + +vi.mock('@ai-sdk/google', () => ({ + google: vi.fn(() => 'gemini-test-model'), +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: rateLimitMock.checkRateLimit, +})); + +import { POST as ocrPost } from '@/app/api/ocr/route'; +import { encrypt } from '@/lib/encryption'; +import { issueToken } from '@/lib/tokens'; + +function streamFromBuffer(buf: ArrayBuffer): ReadableStream { + const bytes = new Uint8Array(buf); + let sent = false; + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + controller.enqueue(bytes); + sent = true; + }, + }); +} + +function streamFromChunks(chunks: Uint8Array[]): ReadableStream { + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index >= chunks.length) { + controller.close(); + return; + } + controller.enqueue(chunks[index]); + index += 1; + }, + }); +} + +async function encryptedBlob(token: string, plaintext = new Uint8Array([1, 2, 3]), contentType = 'image/png') { + const encrypted = await encrypt(plaintext.buffer as ArrayBuffer, contentType, token); + return { + blob: { contentType: 'application/octet-stream', url: 'https://blob.example/ocr' }, + stream: streamFromBuffer(encrypted), + }; +} + +describe('OCR route coverage', () => { + beforeEach(() => { + blobMock.get.mockReset(); + aiMock.generateText.mockReset(); + rateLimitMock.checkRateLimit.mockReset(); + rateLimitMock.checkRateLimit.mockReturnValue({ allowed: true }); + }); + + it('returns 429 with Retry-After when rate limited', async () => { + rateLimitMock.checkRateLimit.mockReturnValue({ + allowed: false, + retryAfterSeconds: 123, + }); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { + 'x-real-ip': '198.51.100.3', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: 'ignored' }), + }) as any, + ); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('123'); + }); + + it('decrypts blob and returns extracted text for a valid token', async () => { + const { token } = issueToken(); + + blobMock.get.mockResolvedValue(await encryptedBlob(token)); + aiMock.generateText.mockResolvedValue({ text: 'hello world' }); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { + 'x-real-ip': '198.51.100.4', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ text: 'hello world' }); + + // Verify Gemini received the decrypted content-type, not application/octet-stream + const call = aiMock.generateText.mock.calls[0][0]; + const fileContent = call.messages[0].content[0]; + expect(fileContent.mediaType).toBe('image/png'); + }); + + it('passes the correct decrypted content-type to Gemini', async () => { + const { token } = issueToken(); + + blobMock.get.mockResolvedValue( + await encryptedBlob(token, new Uint8Array([255, 216, 255]), 'image/jpeg'), + ); + aiMock.generateText.mockResolvedValue({ text: 'jpeg text' }); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { + 'x-real-ip': '198.51.100.5', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(200); + const call = aiMock.generateText.mock.calls[0][0]; + expect(call.messages[0].content[0].mediaType).toBe('image/jpeg'); + }); + + it('returns 404 when blob cannot be decrypted (wrong key / corrupt)', async () => { + const { token: tokenA } = issueToken(); + const { token: tokenB } = issueToken(); + + // Encrypt with tokenA but try to decrypt with tokenB + blobMock.get.mockResolvedValue(await encryptedBlob(tokenA)); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: tokenB }), + }) as any, + ); + + expect(response.status).toBe(404); + }); + + it('returns 400 for invalid JSON body', async () => { + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json', + }) as any, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Invalid request body.' }); + }); + + it('returns 410 for expired tokens', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now - 2 * 60 * 60 * 1000); + const { token } = issueToken(); + vi.setSystemTime(now); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(410); + vi.useRealTimers(); + }); + + it('returns cached result on second call for same token', async () => { + const { token } = issueToken(); + + blobMock.get.mockResolvedValue(await encryptedBlob(token)); + aiMock.generateText.mockResolvedValue({ text: 'cached text' }); + + // First call - hits Gemini + await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'x-real-ip': '198.51.100.20', 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + // Second call - should use cache + aiMock.generateText.mockReset(); + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'x-real-ip': '198.51.100.20', 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ text: 'cached text' }); + expect(aiMock.generateText).not.toHaveBeenCalled(); + }); + + it('prunes expired cache entries and recomputes OCR', async () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const { token } = issueToken(); + + blobMock.get.mockImplementation(async () => encryptedBlob(token)); + aiMock.generateText.mockResolvedValue({ text: 'first text' }); + + await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'x-real-ip': '198.51.100.21', 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + // Advance beyond cache TTL (30m) and prune interval (60s), + // while keeping token valid (issueToken rounds expiry to next minute). + vi.setSystemTime(now + 30 * 60 * 1000 + 1); + + aiMock.generateText.mockResolvedValue({ text: 'second text' }); + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'x-real-ip': '198.51.100.21', 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ text: 'second text' }); + expect(aiMock.generateText).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('returns 404 when blob fetch throws', async () => { + const { token } = issueToken(); + blobMock.get.mockRejectedValue(new Error('blob store error')); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(404); + }); + + it('rejects missing/invalid tokens before OCR work', async () => { + let response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) as any, + ); + expect(response.status).toBe(400); + + response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 'not-a-token' }), + }) as any, + ); + expect(response.status).toBe(400); + }); + + it('returns 404 when the referenced image is missing', async () => { + const { token } = issueToken(); + blobMock.get.mockResolvedValue(null); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(404); + }); + + it('returns 413 when OCR stream exceeds size cap', async () => { + const { token } = issueToken(); + + blobMock.get.mockResolvedValue({ + blob: { contentType: 'application/octet-stream', url: 'https://blob.example/ocr-big' }, + stream: streamFromChunks([new Uint8Array(10 * 1024 * 1024 + 1)]), + }); + + const response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) as any, + ); + + expect(response.status).toBe(413); + }); +}); diff --git a/apps/web/tests/api/rate-limit-contract.test.ts b/apps/web/tests/api/rate-limit-contract.test.ts new file mode 100644 index 0000000..07b54f5 --- /dev/null +++ b/apps/web/tests/api/rate-limit-contract.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { POST as issuePost } from '@/app/api/issue/route'; +import { POST as ocrPost } from '@/app/api/ocr/route'; +import { __resetRateLimitMemoryForTests } from '@/lib/rate-limit'; + +describe('rate-limit headers and limits', () => { + beforeEach(() => { + __resetRateLimitMemoryForTests(); + }); + + it('returns Retry-After for upload issue limit (30/hr)', async () => { + const ip = '198.51.100.200'; + let response: Response | null = null; + + for (let i = 0; i < 31; i += 1) { + response = await issuePost( + new Request('http://localhost/api/issue', { + method: 'POST', + headers: { 'x-real-ip': ip }, + }) as any, + ); + } + + if (!response) { + throw new Error('Expected response from /api/issue'); + } + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).not.toBeNull(); + expect(Number(response.headers.get('Retry-After'))).toBeGreaterThan(0); + }); + + it('returns Retry-After for OCR limit (20/hr)', async () => { + const ip = '198.51.100.201'; + let response: Response | null = null; + + for (let i = 0; i < 21; i += 1) { + response = await ocrPost( + new Request('http://localhost/api/ocr', { + method: 'POST', + headers: { + 'x-real-ip': ip, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: 'not-a-valid-token' }), + }) as any, + ); + } + + if (!response) { + throw new Error('Expected response from /api/ocr'); + } + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).not.toBeNull(); + expect(Number(response.headers.get('Retry-After'))).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/tests/api/session-contract.test.ts b/apps/web/tests/api/session-contract.test.ts new file mode 100644 index 0000000..2b86501 --- /dev/null +++ b/apps/web/tests/api/session-contract.test.ts @@ -0,0 +1,723 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const sessionsMock = vi.hoisted(() => ({ + createSession: vi.fn(), + getEvents: vi.fn(), + pushEvent: vi.fn(), + sessionExists: vi.fn(), +})); + +const rateLimitMock = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@/lib/sessions', () => ({ + createSession: sessionsMock.createSession, + getEvents: sessionsMock.getEvents, + pushEvent: sessionsMock.pushEvent, + sessionExists: sessionsMock.sessionExists, +})); + +vi.mock('@/lib/rate-limit', () => ({ + checkRateLimit: rateLimitMock.checkRateLimit, +})); + +import { GET as eventsGet } from '@/app/api/session/[id]/events/route'; +import { POST as pushPost } from '@/app/api/session/[id]/push/route'; +import { POST as sessionPost } from '@/app/api/session/route'; +import { issueToken } from '@/lib/tokens'; + +describe('session API contract', () => { + beforeEach(() => { + sessionsMock.createSession.mockReset(); + sessionsMock.getEvents.mockReset(); + sessionsMock.pushEvent.mockReset(); + sessionsMock.sessionExists.mockReset(); + rateLimitMock.checkRateLimit.mockReset(); + rateLimitMock.checkRateLimit.mockResolvedValue({ allowed: true }); + + delete process.env.NEXT_PUBLIC_BASE_URL; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('POST /api/session', () => { + it('returns { id, url } with configured base URL', async () => { + process.env.NEXT_PUBLIC_BASE_URL = 'https://custom.glance.sh'; + sessionsMock.createSession.mockResolvedValue('abc123def456'); + + const response = await sessionPost( + new Request('http://localhost/api/session', { method: 'POST' }), + ); + const payload = (await response.json()) as { id: string; url: string }; + + expect(response.status).toBe(200); + expect(payload).toEqual({ + id: 'abc123def456', + url: 'https://custom.glance.sh/s/abc123def456', + }); + }); + + it('uses https://glance.sh when NEXT_PUBLIC_BASE_URL is missing', async () => { + sessionsMock.createSession.mockResolvedValue('abc123def456'); + + const response = await sessionPost( + new Request('http://localhost/api/session', { method: 'POST' }), + ); + const payload = (await response.json()) as { id: string; url: string }; + + expect(payload.url).toBe('https://glance.sh/s/abc123def456'); + }); + + it('returns 429 when session creation is rate limited', async () => { + rateLimitMock.checkRateLimit.mockResolvedValue({ + allowed: false, + retryAfterSeconds: 90, + }); + + const response = await sessionPost( + new Request('http://localhost/api/session', { method: 'POST' }), + ); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('90'); + await expect(response.json()).resolves.toEqual({ + error: 'Too many sessions created. Try again later.', + }); + expect(sessionsMock.createSession).not.toHaveBeenCalled(); + }); + }); + + describe('POST /api/session/[id]/push', () => { + it('returns 404 when session does not exist', async () => { + sessionsMock.sessionExists.mockResolvedValue(false); + + const response = await pushPost(new Request('http://localhost'), { + params: Promise.resolve({ id: 'missing' }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: 'Session not found' }); + }); + + it('returns 429 when push is rate limited', async () => { + rateLimitMock.checkRateLimit.mockResolvedValue({ + allowed: false, + retryAfterSeconds: 45, + }); + + const response = await pushPost( + new Request('http://localhost', { method: 'POST' }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('45'); + await expect(response.json()).resolves.toEqual({ + error: 'Too many session updates. Try again later.', + }); + expect(sessionsMock.sessionExists).not.toHaveBeenCalled(); + }); + + it('returns 400 when url is missing', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expiresAt: Date.now() + 60_000 }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Missing url' }); + }); + + it('returns 400 when expiresAt is missing', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: `http://localhost/${token}.png` }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Missing expiresAt' }); + }); + + it('rejects non-first-party URLs', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: `https://evil.example/${token}.png`, expiresAt }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'URL must be a first-party glance link', + }); + }); + + it('rejects expiresAt mismatch against token expiry', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png`, + expiresAt: expiresAt + 1, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'expiresAt does not match token expiry', + }); + }); + + it('rejects expiresAt values far beyond allowed lifetime', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png`, + expiresAt: expiresAt + 86_400_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'expiresAt exceeds allowed lifetime', + }); + }); + + it('returns 410 when session expired before push', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.pushEvent.mockResolvedValue(false); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(410); + await expect(response.json()).resolves.toEqual({ error: 'Session expired' }); + }); + + it('returns ok on successful push', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.pushEvent.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const shareUrl = `http://localhost/${token}.png`; + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: shareUrl, expiresAt }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(sessionsMock.pushEvent).toHaveBeenCalledWith('abc123def456', { + url: shareUrl, + expiresAt, + }); + }); + + it('rejects URLs with query strings', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png?foo=bar`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'URL must not include query/hash', + }); + }); + + it('rejects URLs with hash fragments', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png#section`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'URL must not include query/hash', + }); + }); + + it('rejects URLs targeting nested paths', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/nested/${token}.png`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'URL must target a share token route', + }); + }); + + it('rejects URLs with valid slug format but invalid token parse', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + // 20 chars matching token pattern, but the expiry prefix is corrupt + const badToken = '00000ABCDEFGHIJKLmno'; + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${badToken}.png`, + expiresAt: Date.now() + 60_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + // Token parses successfully but the expiry is way in the past → 410 + expect([400, 410]).toContain(response.status); + }); + + it('rejects URLs with invalid asset slugs', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'http://localhost/not-a-valid-token', + expiresAt: Date.now() + 60_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + }); + + it('rejects expired share tokens in URL', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + vi.setSystemTime(now - 2 * 60 * 60 * 1000); + const { token, expiresAt } = issueToken(); + vi.setSystemTime(now); + + sessionsMock.sessionExists.mockResolvedValue(true); + + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(410); + vi.useRealTimers(); + }); + + it('rejects expiresAt in the past', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const { token } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `http://localhost/${token}.png`, + expiresAt: Date.now() - 1000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'expiresAt must be in the future', + }); + }); + + it('rejects completely invalid URLs that fail to parse', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + // This URL parses relative to currentOrigin, so we need something truly broken + // Use a data: URL which passes new URL() but fails the protocol check + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'data:text/plain;base64,SGVsbG8=', + expiresAt: Date.now() + 60_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Invalid url protocol' }); + }); + + it('rejects malformed URLs that cannot be parsed', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'http://[::1', + expiresAt: Date.now() + 60_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'Invalid url' }); + }); + + it('rejects invalid URL protocols', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: 'ftp://localhost/something', + expiresAt: Date.now() + 60_000, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Invalid url protocol', + }); + }); + + it('accepts NEXT_PUBLIC_BASE_URL as allowed origin', async () => { + process.env.NEXT_PUBLIC_BASE_URL = 'https://glance.sh'; + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.pushEvent.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const response = await pushPost( + new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: `https://glance.sh/${token}.png`, + expiresAt, + }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(200); + }); + + it('handles blank forwarded host header by falling back to host', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.pushEvent.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const shareUrl = `http://127.0.0.1/${token}.png`; + + const response = await pushPost( + new Request('http://internal-proxy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-forwarded-host': ' , proxy.internal', + host: '127.0.0.1', + }, + body: JSON.stringify({ url: shareUrl, expiresAt }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(200); + }); + + it('handles comma-separated forwarded host/proto headers', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.pushEvent.mockResolvedValue(true); + + const { token, expiresAt } = issueToken(); + const shareUrl = `http://localhost/${token}.png`; + + const response = await pushPost( + new Request('http://internal-proxy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-forwarded-host': 'localhost, proxy.internal', + 'x-forwarded-proto': 'http, https', + }, + body: JSON.stringify({ url: shareUrl, expiresAt }), + }), + { params: Promise.resolve({ id: 'abc123def456' }) }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + }); + + describe('GET /api/session/[id]/events', () => { + it('returns 429 when stream requests are rate limited', async () => { + rateLimitMock.checkRateLimit.mockResolvedValue({ + allowed: false, + retryAfterSeconds: 30, + }); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'abc123def456' }), + }); + + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('30'); + await expect(response.json()).resolves.toEqual({ + error: 'Too many session stream connections. Try again later.', + }); + expect(sessionsMock.sessionExists).not.toHaveBeenCalled(); + }); + + it('returns 404 when session does not exist', async () => { + sessionsMock.sessionExists.mockResolvedValue(false); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'missing' }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: 'Session not found' }); + }); + + it('emits connected + expired (without timeout) when the session disappears', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.getEvents.mockResolvedValue(null); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'abc123def456' }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toContain('text/event-stream'); + + const text = await response.text(); + expect(text).toContain('event: connected'); + expect(text).toContain('event: expired'); + expect(text).not.toContain('event: timeout'); + }); + + it('handles stream cancellation gracefully', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + // Never return null (session never expires) so the loop keeps going + sessionsMock.getEvents.mockResolvedValue([]); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'cancel-test' }), + }); + + expect(response.status).toBe(200); + + // Read just the connected event, then cancel + const reader = response.body!.getReader(); + const { value } = await reader.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('event: connected'); + await reader.cancel(); + }); + + it('handles immediate body cancellation before reading', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.getEvents.mockResolvedValue([]); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'cancel-immediate' }), + }); + + expect(response.status).toBe(200); + await response.body?.cancel(); + }); + + it('emits keep-alive pings and timeout for long-lived sessions', async () => { + vi.useFakeTimers(); + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.getEvents.mockResolvedValue([]); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'long-lived' }), + }); + + const textPromise = response.text(); + + // Advance past ping and timeout thresholds. + await vi.advanceTimersByTimeAsync(296_000); + + const text = await textPromise; + expect(text).toContain(': ping'); + expect(text).toContain('event: timeout'); + + vi.useRealTimers(); + }); + + it('emits image events from the session queue', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + sessionsMock.getEvents + .mockResolvedValueOnce([ + { + url: 'https://glance.sh/one.png', + expiresAt: 1_750_000_000_000, + }, + ]) + .mockResolvedValueOnce(null); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'abc123def456' }), + }); + + const text = await response.text(); + expect(text).toContain('event: image'); + expect(text).toContain('https://glance.sh/one.png'); + expect(text).toContain('event: expired'); + expect(text).not.toContain('event: timeout'); + }); + + it('continues emitting new events when history is capped and shifted', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const expiresAtBase = 1_750_000_000_000; + const firstWindow = Array.from({ length: 50 }, (_, index) => ({ + url: `https://glance.sh/${index}.png`, + expiresAt: expiresAtBase + index, + })); + const shiftedWindow = Array.from({ length: 50 }, (_, index) => ({ + url: `https://glance.sh/${index + 1}.png`, + expiresAt: expiresAtBase + index + 1, + })); + + sessionsMock.getEvents + .mockResolvedValueOnce(firstWindow) + .mockResolvedValueOnce(shiftedWindow) + .mockResolvedValueOnce(null); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'abc123def456' }), + }); + + const text = await response.text(); + const newestEventMatches = text.match(/https:\/\/glance\.sh\/50\.png/g) ?? []; + + expect(text).toContain('event: image'); + expect(newestEventMatches).toHaveLength(1); + expect(text).toContain('event: expired'); + expect(text).not.toContain('event: timeout'); + }); + + it('does not skip a newly-added duplicate event key', async () => { + sessionsMock.sessionExists.mockResolvedValue(true); + + const duplicate = { + url: 'https://glance.sh/dup.png', + expiresAt: 1_750_000_000_000, + }; + + sessionsMock.getEvents + .mockResolvedValueOnce([duplicate]) + .mockResolvedValueOnce([duplicate, duplicate]) + .mockResolvedValueOnce(null); + + const response = await eventsGet(new Request('http://localhost'), { + params: Promise.resolve({ id: 'abc123def456' }), + }); + + const text = await response.text(); + const dupMatches = + text.match(/https:\/\/glance\.sh\/dup\.png/g) ?? []; + + expect(dupMatches).toHaveLength(2); + expect(text).toContain('event: expired'); + }); + }); +}); diff --git a/apps/web/tests/api/upload-route-coverage.test.ts b/apps/web/tests/api/upload-route-coverage.test.ts new file mode 100644 index 0000000..58058fb --- /dev/null +++ b/apps/web/tests/api/upload-route-coverage.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const blobClientMock = vi.hoisted(() => ({ + handleUpload: vi.fn(), +})); + +vi.mock('@vercel/blob/client', () => ({ + handleUpload: blobClientMock.handleUpload, +})); + +import { POST as uploadPost } from '@/app/api/upload/route'; +import { issueToken, parseToken } from '@/lib/tokens'; +import { createUploadProof } from '@/lib/upload-proof'; + +const ORIGINAL_ENV = { ...process.env }; + +function uploadEvent(pathname: string, clientPayload: string | null = null) { + return { + type: 'blob.generate-client-token', + payload: { + pathname, + clientPayload, + multipart: false, + }, + }; +} + +describe('upload route branch coverage', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.BLOB_READ_WRITE_TOKEN = 'vercel_blob_rw_test_store_secret'; + process.env.GLANCE_UPLOAD_PROOF_SECRET = 'test-proof-secret'; + + blobClientMock.handleUpload.mockReset(); + blobClientMock.handleUpload.mockImplementation(async (options: any) => { + const event = options.body; + await options.onBeforeGenerateToken( + event.payload.pathname, + event.payload.clientPayload, + event.payload.multipart, + ); + return { type: 'blob.generate-client-token', clientToken: 'ok' }; + }); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + vi.useRealTimers(); + }); + + it('rejects invalid upload pathname payloads', async () => { + const response = await uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent('not-uploads-prefix')), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Invalid upload pathname.', + }); + }); + + it('rejects malformed upload path tokens', async () => { + const response = await uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent('uploads/not-a-token')), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Invalid upload pathname.', + }); + }); + + it('rejects already-expired upload tokens', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + + vi.setSystemTime(now - 2 * 60 * 60 * 1000); + const { token } = issueToken(); + vi.setSystemTime(now); + + const response = await uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent(`uploads/${token}`)), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Upload token has already expired.', + }); + }); + + it('executes onUploadCompleted callback in happy path', async () => { + let completedCalled = false; + + blobClientMock.handleUpload.mockImplementation(async (options: any) => { + const event = options.body; + await options.onBeforeGenerateToken( + event.payload.pathname, + event.payload.clientPayload, + event.payload.multipart, + ); + await options.onUploadCompleted({}); + completedCalled = true; + return { type: 'blob.generate-client-token', clientToken: 'ok' }; + }); + + const { token } = issueToken(); + const parsed = parseToken(token)!; + const pathname = `uploads/${token}`; + const proof = createUploadProof({ + token, + pathname, + tokenExpiresAt: parsed.expiresAt, + }); + + const response = await uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent(pathname, proof)), + }), + ); + + expect(response.status).toBe(200); + expect(completedCalled).toBe(true); + }); + + it('rejects tokens that exceed allowed lifetime horizon', async () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + + vi.setSystemTime(now + 24 * 60 * 60 * 1000); + const { token } = issueToken(); + vi.setSystemTime(now); + + const response = await uploadPost( + new Request('http://localhost/api/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadEvent(`uploads/${token}`)), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Upload token exceeds the allowed lifetime.', + }); + }); +}); diff --git a/apps/web/tests/e2e/live-session-sse.test.ts b/apps/web/tests/e2e/live-session-sse.test.ts new file mode 100644 index 0000000..80fb32e --- /dev/null +++ b/apps/web/tests/e2e/live-session-sse.test.ts @@ -0,0 +1,221 @@ +import { del } from '@vercel/blob'; +import { upload } from '@vercel/blob/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +type IssueResponse = { + token: string; + pathname: string; + expiresAt: number; + uploadProof: string; +}; + +type SessionResponse = { + id: string; + url: string; +}; + +type ParsedSseEvent = { + event: string; + data: string; +}; + +type SessionImageEvent = { + url: string; + expiresAt: number; +}; + +const BASE_URL = process.env.LIVE_E2E_BASE_URL ?? 'http://127.0.0.1:3000'; +const ENABLED = process.env.RUN_LIVE_E2E === '1'; +const describeLive = ENABLED ? describe : describe.skip; + +const uploadedPathnames = new Set(); + +const ONE_BY_ONE_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9xq4QAAAAASUVORK5CYII='; + +async function issueToken(): Promise { + const response = await fetch(`${BASE_URL}/api/issue`, { method: 'POST' }); + expect(response.status).toBe(200); + return response.json() as Promise; +} + +async function createSession(): Promise { + const response = await fetch(`${BASE_URL}/api/session`, { method: 'POST' }); + expect(response.status).toBe(200); + return response.json() as Promise; +} + +function parseSseChunk(buffer: string): { + events: ParsedSseEvent[]; + remainder: string; +} { + const events: ParsedSseEvent[] = []; + const blocks = buffer.split('\n\n'); + const remainder = blocks.pop() ?? ''; + + for (const block of blocks) { + const lines = block.split('\n'); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event: ')) { + event = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + dataLines.push(line.slice(6)); + } + } + + events.push({ event, data: dataLines.join('\n') }); + } + + return { events, remainder }; +} + +async function waitForSseEvent( + reader: ReadableStreamDefaultReader, + decoder: TextDecoder, + state: { buffer: string }, + eventName: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const parsedBeforeRead = parseSseChunk(state.buffer); + state.buffer = parsedBeforeRead.remainder; + + const foundBeforeRead = parsedBeforeRead.events.find( + (event) => event.event === eventName, + ); + + if (foundBeforeRead) { + return foundBeforeRead; + } + + const remaining = Math.max(deadline - Date.now(), 1); + + const readResult = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out waiting for ${eventName}`)), remaining); + }), + ]); + + if (readResult.done) { + break; + } + + state.buffer += decoder.decode(readResult.value, { stream: true }); + } + + throw new Error(`Did not receive SSE event: ${eventName}`); +} + +describeLive('live session SSE integration', () => { + afterEach(async () => { + if (uploadedPathnames.size === 0) { + return; + } + + const pathnames = [...uploadedPathnames]; + uploadedPathnames.clear(); + + try { + await del(pathnames); + } catch { + // best effort cleanup; TTL + cleanup cron are a fallback + } + }); + + it( + 'streams connected + image events after push', + async () => { + const session = await createSession(); + expect(session.id).toHaveLength(12); + expect(session.url).toContain(`/s/${session.id}`); + + const eventsResponse = await fetch( + `${BASE_URL}/api/session/${session.id}/events`, + { + headers: { Accept: 'text/event-stream' }, + }, + ); + + expect(eventsResponse.status).toBe(200); + expect(eventsResponse.headers.get('content-type')).toContain( + 'text/event-stream', + ); + + if (!eventsResponse.body) { + throw new Error('Expected SSE response body'); + } + + const reader = eventsResponse.body.getReader(); + const decoder = new TextDecoder(); + const state = { buffer: '' }; + + try { + const connectedEvent = await waitForSseEvent( + reader, + decoder, + state, + 'connected', + 5_000, + ); + expect(connectedEvent.data).toBe('{}'); + + const issue = await issueToken(); + const imageBytes = Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64'); + const imageBlob = new Blob([imageBytes], { type: 'image/png' }); + + const uploaded = await upload(issue.pathname, imageBlob, { + access: 'private', + contentType: 'image/png', + clientPayload: issue.uploadProof, + handleUploadUrl: `${BASE_URL}/api/upload`, + }); + + uploadedPathnames.add(uploaded.pathname); + + const shareUrl = `${BASE_URL}/${issue.token}.png`; + + const pushResponse = await fetch( + `${BASE_URL}/api/session/${session.id}/push`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: shareUrl, + expiresAt: issue.expiresAt, + }), + }, + ); + + expect(pushResponse.status).toBe(200); + await expect(pushResponse.json()).resolves.toEqual({ ok: true }); + + const imageEvent = await waitForSseEvent( + reader, + decoder, + state, + 'image', + 7_000, + ); + + const imagePayload = JSON.parse(imageEvent.data) as SessionImageEvent; + expect(imagePayload).toEqual({ + url: shareUrl, + expiresAt: issue.expiresAt, + }); + + const shareResponse = await fetch(shareUrl); + expect(shareResponse.status).toBe(200); + expect(shareResponse.headers.get('content-type')).toContain('image/png'); + } finally { + await reader.cancel().catch(() => {}); + } + }, + 35_000, + ); +}); diff --git a/apps/web/tests/e2e/live-upload-proof.test.ts b/apps/web/tests/e2e/live-upload-proof.test.ts new file mode 100644 index 0000000..ab88e76 --- /dev/null +++ b/apps/web/tests/e2e/live-upload-proof.test.ts @@ -0,0 +1,110 @@ +import { del } from '@vercel/blob'; +import { upload } from '@vercel/blob/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +type IssueResponse = { + token: string; + pathname: string; + expiresAt: number; + uploadProof: string; +}; + +const BASE_URL = process.env.LIVE_E2E_BASE_URL ?? 'http://127.0.0.1:3000'; +const ENABLED = process.env.RUN_LIVE_E2E === '1'; +const describeLive = ENABLED ? describe : describe.skip; + +const uploadedPathnames = new Set(); + +const ONE_BY_ONE_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9xq4QAAAAASUVORK5CYII='; + +async function issueToken(): Promise { + const response = await fetch(`${BASE_URL}/api/issue`, { method: 'POST' }); + expect(response.status).toBe(200); + return response.json() as Promise; +} + +describeLive('live upload-proof integration', () => { + afterEach(async () => { + if (uploadedPathnames.size === 0) { + return; + } + + const pathnames = [...uploadedPathnames]; + uploadedPathnames.clear(); + + try { + await del(pathnames); + } catch { + // best effort cleanup; TTL + cleanup cron are a fallback + } + }); + + it( + 'accepts signed upload proofs and rejects missing/tampered proofs', + async () => { + const issue = await issueToken(); + + expect(issue.token).toHaveLength(20); + expect(issue.pathname).toBe(`uploads/${issue.token}`); + expect(issue.uploadProof.length).toBeGreaterThan(20); + + const missingProofResponse = await fetch(`${BASE_URL}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'blob.generate-client-token', + payload: { + pathname: issue.pathname, + clientPayload: null, + multipart: false, + }, + }), + }); + + expect(missingProofResponse.status).toBe(400); + await expect(missingProofResponse.json()).resolves.toEqual({ + error: 'Missing upload proof.', + }); + + const tamperedProofResponse = await fetch(`${BASE_URL}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'blob.generate-client-token', + payload: { + pathname: issue.pathname, + clientPayload: `${issue.uploadProof}x`, + multipart: false, + }, + }), + }); + + expect(tamperedProofResponse.status).toBe(400); + await expect(tamperedProofResponse.json()).resolves.toEqual({ + error: 'Invalid upload proof signature.', + }); + + const imageBytes = Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64'); + const imageBlob = new Blob([imageBytes], { type: 'image/png' }); + + const uploaded = await upload(issue.pathname, imageBlob, { + access: 'private', + contentType: 'image/png', + clientPayload: issue.uploadProof, + handleUploadUrl: `${BASE_URL}/api/upload`, + }); + + uploadedPathnames.add(uploaded.pathname); + + expect(uploaded.pathname).toBe(issue.pathname); + expect(uploaded.contentType).toBe('image/png'); + + const shareResponse = await fetch(`${BASE_URL}/${issue.token}.png`); + expect(shareResponse.status).toBe(200); + expect(shareResponse.headers.get('content-type')).toContain('image/png'); + expect(shareResponse.headers.get('cache-control')).toContain('private'); + }, + 30_000, + ); +}); diff --git a/apps/web/tests/instrumentation-client-config.test.ts b/apps/web/tests/instrumentation-client-config.test.ts new file mode 100644 index 0000000..d004f90 --- /dev/null +++ b/apps/web/tests/instrumentation-client-config.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const sentryMock = vi.hoisted(() => ({ + captureRouterTransitionStart: vi.fn(), + init: vi.fn(), +})); + +vi.mock('@sentry/nextjs', () => sentryMock); + +describe('instrumentation-client Sentry config', () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_SENTRY_DSN; + sentryMock.init.mockReset(); + }); + + it('does not initialize Sentry without an explicit DSN', async () => { + vi.resetModules(); + await import('@/instrumentation-client'); + + expect(sentryMock.init).not.toHaveBeenCalled(); + }); + + it('disables default PII and scrubs direct user identifiers', async () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = 'https://example.com/1'; + vi.resetModules(); + await import('@/instrumentation-client'); + + expect(sentryMock.init).toHaveBeenCalledTimes(1); + + const options = sentryMock.init.mock.calls[0]?.[0] as { + sendDefaultPii?: boolean; + beforeSend?: (event: any) => any; + }; + + expect(options.sendDefaultPii).toBe(false); + expect(typeof options.beforeSend).toBe('function'); + + const scrubbed = options.beforeSend?.({ + user: { + id: 'user-123', + email: 'person@example.com', + ip_address: '203.0.113.10', + username: 'dev', + segment: 'beta', + }, + }); + + expect(scrubbed).toEqual({ + user: { + segment: 'beta', + }, + }); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..5fc2632 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "es2022" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "agent-plugins", + "tests", + "scripts", + "**/*.test.ts", + "vitest.config.ts" + ] +} diff --git a/apps/web/types/pi-extension-stubs.d.ts b/apps/web/types/pi-extension-stubs.d.ts new file mode 100644 index 0000000..83c3579 --- /dev/null +++ b/apps/web/types/pi-extension-stubs.d.ts @@ -0,0 +1,26 @@ +declare module '@mariozechner/pi-ai' { + export const Type: { + Object>(shape: T): { + type: 'object'; + shape: T; + }; + }; +} + +declare module '@mariozechner/pi-tui' { + export class Text { + constructor(text: string, x: number, y: number); + text: string; + x: number; + y: number; + } +} + +declare module '@mariozechner/pi-coding-agent' { + export interface ExtensionAPI { + on(event: string, handler: (...args: any[]) => any): void; + registerCommand(name: string, definition: any): void; + registerTool(definition: any): void; + sendUserMessage(message: string, options?: { deliverAs?: string }): void; + } +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..4a1e546 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "crons": [ + { + "path": "/api/cron/cleanup", + "schedule": "*/15 * * * *" + } + ] +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..722ccce --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,40 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +const rootDir = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@': rootDir, + }, + }, + test: { + environment: 'node', + include: ['lib/**/*.test.ts', 'app/**/*.test.ts', 'tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + reportsDirectory: 'coverage', + include: [ + 'lib/**/*.ts', + 'app/[token]/route.ts', + 'app/api/issue/route.ts', + 'app/api/upload/route.ts', + 'app/api/ocr/route.ts', + + 'app/api/cron/cleanup/route.ts', + + 'app/api/session/**/*.ts', + ], + exclude: ['**/*.test.ts'], + thresholds: { + lines: 85, + branches: 80, + functions: 85, + statements: 85, + }, + }, + }, +}); diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index bf7408b..6373d01 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -5,8 +5,8 @@ "author": { "name": "Modem" }, - "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/claude", - "repository": "https://github.com/modem-dev/glance-agent-plugins", + "homepage": "https://github.com/modem-dev/glance/tree/main/claude", + "repository": "https://github.com/modem-dev/glance", "license": "MIT", "keywords": ["claude-code", "mcp", "glance", "screenshots", "agent"], "mcpServers": "./.mcp.json" diff --git a/claude/README.md b/claude/README.md index dbeb0b2..0c24aa8 100644 --- a/claude/README.md +++ b/claude/README.md @@ -16,8 +16,8 @@ The server keeps a background SSE listener alive, reconnects automatically, and Recommended (npm-backed marketplace plugin): ```text -/plugin marketplace add modem-dev/glance-agent-plugins -/plugin install glance-claude@glance-agent-plugins +/plugin marketplace add modem-dev/glance +/plugin install glance-claude@glance ``` This plugin is distributed as `@modemdev/glance-claude` and installed through Claude Code's plugin marketplace flow. @@ -49,13 +49,13 @@ claude --plugin-dir ./claude - Update: `/plugin update glance-claude` - Remove: `/plugin uninstall glance-claude` -If you have multiple plugins with the same name from different marketplaces, use the fully qualified form (`glance-claude@glance-agent-plugins`). +If you have multiple plugins with the same name from different marketplaces, use the fully qualified form (`glance-claude@glance`). ## Publishing (maintainers) Releases are automated via GitHub Actions. -Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository with publish access to `@modemdev/glance-claude`. +Prerequisite: configure `NPM_TOKEN` in the `glance` repository with publish access to `@modemdev/glance-claude`. 1. Bump `version` in both: - `claude/package.json` diff --git a/claude/package.json b/claude/package.json index d0514e2..ef59640 100644 --- a/claude/package.json +++ b/claude/package.json @@ -6,12 +6,12 @@ "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/modem-dev/glance-agent-plugins.git", + "url": "git+https://github.com/modem-dev/glance.git", "directory": "claude" }, - "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/claude", + "homepage": "https://github.com/modem-dev/glance/tree/main/claude", "bugs": { - "url": "https://github.com/modem-dev/glance-agent-plugins/issues" + "url": "https://github.com/modem-dev/glance/issues" }, "keywords": [ "claude-code", diff --git a/codex/README.md b/codex/README.md index 7becadc..7bef721 100644 --- a/codex/README.md +++ b/codex/README.md @@ -28,7 +28,7 @@ codex mcp add glance -- npx -y @modemdev/glance-codex@0.1.1 Local development / manual install: ```bash -codex mcp add glance -- node /absolute/path/to/glance-agent-plugins/codex/servers/glance-mcp.js +codex mcp add glance -- node /absolute/path/to/glance/codex/servers/glance-mcp.js ``` ## Verify @@ -64,7 +64,7 @@ codex mcp remove glance Releases are automated via GitHub Actions. -Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository with publish access to `@modemdev/glance-codex`. +Prerequisite: configure `NPM_TOKEN` in the `glance` repository with publish access to `@modemdev/glance-codex`. 1. Bump `version` in `codex/package.json`. 2. Commit and push to `main`. diff --git a/codex/package.json b/codex/package.json index e475d9b..028e51d 100644 --- a/codex/package.json +++ b/codex/package.json @@ -6,12 +6,12 @@ "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/modem-dev/glance-agent-plugins.git", + "url": "git+https://github.com/modem-dev/glance.git", "directory": "codex" }, - "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/codex", + "homepage": "https://github.com/modem-dev/glance/tree/main/codex", "bugs": { - "url": "https://github.com/modem-dev/glance-agent-plugins/issues" + "url": "https://github.com/modem-dev/glance/issues" }, "keywords": [ "codex", diff --git a/opencode/README.md b/opencode/README.md index e800d1f..3764996 100644 --- a/opencode/README.md +++ b/opencode/README.md @@ -51,7 +51,7 @@ Then call `glance_wait` and paste an image in the browser tab — it should retu Releases are automated via GitHub Actions. -Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository with publish access to `@modemdev/glance-opencode`. +Prerequisite: configure `NPM_TOKEN` in the `glance` repository with publish access to `@modemdev/glance-opencode`. 1. Bump `version` in `opencode/package.json`. 2. Commit and push to `main`. diff --git a/opencode/package.json b/opencode/package.json index c416418..c812da6 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -6,12 +6,12 @@ "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/modem-dev/glance-agent-plugins.git", + "url": "git+https://github.com/modem-dev/glance.git", "directory": "opencode" }, - "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/opencode", + "homepage": "https://github.com/modem-dev/glance/tree/main/opencode", "bugs": { - "url": "https://github.com/modem-dev/glance-agent-plugins/issues" + "url": "https://github.com/modem-dev/glance/issues" }, "keywords": [ "opencode", diff --git a/package-lock.json b/package-lock.json index df2531f..6d96baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,11 @@ { - "name": "glance-agent-plugins", + "name": "glance", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "glance-agent-plugins", + "name": "glance", + "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.0.0", @@ -987,7 +988,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1200,9 +1200,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1475,9 +1475,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1774,12 +1774,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1788,9 +1787,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2176,9 +2175,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", "dependencies": { @@ -2279,7 +2278,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index 4641ca6..e5d2069 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,26 @@ { - "name": "glance-agent-plugins", + "name": "glance", "private": true, "type": "module", "packageManager": "npm@11.6.2", "scripts": { "sync:mcp-common": "node scripts/sync-mcp-common.mjs", "check:mcp-common": "node scripts/sync-mcp-common.mjs --check", - "test": "npm run check:mcp-common && vitest run", - "test:coverage": "npm run check:mcp-common && vitest run --coverage" + "test:plugins": "npm run check:mcp-common && vitest run", + "test:plugins:coverage": "npm run check:mcp-common && vitest run --coverage", + "typecheck:plugins": "tsc --noEmit", + "test:web": "npm --prefix apps/web test", + "build:web": "npm --prefix apps/web run build", + "typecheck:web": "npm --prefix apps/web run typecheck", + "test": "npm run test:plugins && npm run test:web", + "test:coverage": "npm run test:plugins:coverage && npm --prefix apps/web run test:coverage", + "typecheck": "npm run typecheck:plugins && npm run typecheck:web" }, "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.0.0", "typescript": "^5.0.0", "vitest": "^3.0.0" - } + }, + "license": "MIT" } diff --git a/pi/README.md b/pi/README.md index 881a7a3..4f3e296 100644 --- a/pi/README.md +++ b/pi/README.md @@ -54,7 +54,7 @@ For a local path install, remove that path from your pi settings (or run `pi rem Releases are automated via GitHub Actions. -Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository with publish access to `@modemdev/glance-pi`. +Prerequisite: configure `NPM_TOKEN` in the `glance` repository with publish access to `@modemdev/glance-pi`. 1. Bump `version` in `pi/package.json`. 2. Commit and push to `main`. diff --git a/pi/glance.ts b/pi/glance.ts index e1df83f..4901e51 100644 --- a/pi/glance.ts +++ b/pi/glance.ts @@ -12,7 +12,7 @@ * * Install: * - Recommended: `pi install npm:@modemdev/glance-pi` - * - Local checkout: `pi install /path/to/glance-agent-plugins/pi` + * - Local checkout: `pi install /path/to/glance/pi` * - Manual fallback: symlink/copy into ~/.pi/agent/extensions/glance.ts */ diff --git a/pi/package.json b/pi/package.json index 369e867..ea3459c 100644 --- a/pi/package.json +++ b/pi/package.json @@ -5,12 +5,12 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/modem-dev/glance-agent-plugins.git", + "url": "git+https://github.com/modem-dev/glance.git", "directory": "pi" }, - "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/pi", + "homepage": "https://github.com/modem-dev/glance/tree/main/pi", "bugs": { - "url": "https://github.com/modem-dev/glance-agent-plugins/issues" + "url": "https://github.com/modem-dev/glance/issues" }, "keywords": [ "pi-package", diff --git a/vitest.config.ts b/vitest.config.ts index 00e2353..d5875a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,12 @@ export default defineConfig({ }, test: { clearMocks: true, + include: [ + 'opencode/**/*.test.ts', + 'pi/**/*.test.ts', + 'claude/**/*.test.ts', + 'codex/**/*.test.ts', + ], coverage: { include: ["opencode/**/*.ts", "pi/**/*.ts", "claude/**/*.js", "codex/**/*.js"], provider: "v8",