From 7d579e559a2b902e0735fb3c6699d4aee9579402 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 16:44:12 +0200 Subject: [PATCH 1/2] feat: add private support intake foundation --- .dockerignore | 8 + .env.example | 7 + .github/workflows/ci.yml | 48 + .gitignore | 6 + Dockerfile | 23 + README.md | 92 +- cmd/support/main.go | 100 + compose.yaml | 47 + docs/operations.md | 38 + docs/threat-model.md | 63 + frontend/index.html | 13 + frontend/package-lock.json | 3238 +++++++++++++++++++++ frontend/package.json | 27 + frontend/src/App.vue | 23 + frontend/src/env.d.ts | 7 + frontend/src/main.ts | 17 + frontend/src/styles.css | 151 + frontend/src/support.test.ts | 64 + frontend/src/support.ts | 183 ++ frontend/src/views/PrivateStatusView.vue | 70 + frontend/src/views/SupportIntakeView.vue | 200 ++ frontend/tsconfig.json | 16 + frontend/vite.config.ts | 15 + go.mod | 14 + go.sum | 28 + internal/config/config.go | 63 + internal/cryptobox/cryptobox.go | 52 + internal/cryptobox/cryptobox_test.go | 25 + internal/domain/report.go | 99 + internal/intake/service.go | 395 +++ internal/intake/service_test.go | 166 ++ internal/products/registry.go | 83 + internal/products/registry_test.go | 20 + internal/store/memory.go | 94 + internal/store/migrate.go | 32 + internal/store/migrations/001_initial.sql | 25 + internal/store/objects.go | 92 + internal/store/postgres.go | 130 + internal/store/store.go | 24 + internal/web/server.go | 233 ++ internal/web/server_test.go | 31 + openapi.yaml | 173 ++ 42 files changed, 6233 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/support/main.go create mode 100644 compose.yaml create mode 100644 docs/operations.md create mode 100644 docs/threat-model.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/support.test.ts create mode 100644 frontend/src/support.ts create mode 100644 frontend/src/views/PrivateStatusView.vue create mode 100644 frontend/src/views/SupportIntakeView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/cryptobox/cryptobox.go create mode 100644 internal/cryptobox/cryptobox_test.go create mode 100644 internal/domain/report.go create mode 100644 internal/intake/service.go create mode 100644 internal/intake/service_test.go create mode 100644 internal/products/registry.go create mode 100644 internal/products/registry_test.go create mode 100644 internal/store/memory.go create mode 100644 internal/store/migrate.go create mode 100644 internal/store/migrations/001_initial.sql create mode 100644 internal/store/objects.go create mode 100644 internal/store/postgres.go create mode 100644 internal/store/store.go create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 openapi.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd34891 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +.env +data +frontend/node_modules +frontend/dist +coverage.out +support diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84c5c25 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +SUPPORT_ADDRESS=:8080 +SUPPORT_PUBLIC_URL=http://localhost:8080 +SUPPORT_ENVIRONMENT=development +SUPPORT_OBJECT_ROOT=./data/private +SUPPORT_WEB_ROOT=./frontend/dist +SUPPORT_DATA_KEY=replace-with-base64-encoded-32-byte-key +DATABASE_URL=postgres://support:replace-me@localhost:5432/support?sslmode=disable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f033ba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: Validate + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + service: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test ./... + - run: go vet ./... + + portal: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + push: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a6a565 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +/data/ +/support +coverage.out +frontend/node_modules/ +frontend/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e90830 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-alpine AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/support ./cmd/support +RUN mkdir -p /out/private + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/support /support +COPY --from=frontend /src/frontend/dist /srv/support-web +COPY --chown=65532:65532 --from=build /out/private /var/lib/obiente-support/private +VOLUME ["/var/lib/obiente-support/private"] +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/support"] diff --git a/README.md b/README.md index 892b781..2c6729f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ -# support -Support portal, issue intake, and private diagnostics for Obiente projects +# Obiente Support + +Obiente Support is the product-neutral support portal and private intake service for Obiente projects. It lets people report bugs, suggest features, ask for help, and send explicitly approved diagnostics without needing a GitHub account. + +The public portal is a Vue 3 and TypeScript application. A small Go service owns the versioned intake contract, private storage, idempotency, status capabilities, deletion, and retention. + +## What this foundation includes + +- Bug, feature, and general-support intake for products in a maintained registry. +- A versioned multipart API shared by the web portal and native applications. +- Explicit 4 MiB diagnostic upload bounds and per-product ZIP entry allowlists. +- Idempotent submission and receipt reconciliation after an uncertain response. +- Human-readable support codes and unguessable private status/deletion links. +- Application-level AES-256-GCM encryption for private report fields, capabilities, and diagnostic objects. +- Automatic private-data expiration and immediate deletion through the private capability. +- A second product registration and synthetic contract fixtures to prevent product-specific service code. +- A responsive, keyboard-operable Vue portal with reduced-motion behavior and upload cancellation. + +This PR advances issue #1. Moderation queues, maintainer authentication, public tracker promotion, GitHub synchronization, safe follow-up messages, and audited maintainer access remain required before the complete issue can close. + +## Local development + +Requirements: + +- Go 1.24 or newer +- Node.js 22 or newer +- PostgreSQL 17 + +Create a data key and local environment file: + +```bash +cp .env.example .env +openssl rand -base64 32 +``` + +Place the generated value in `SUPPORT_DATA_KEY`, set a database password, then start PostgreSQL: + +```bash +docker compose up database +``` + +Build the Vue portal and run the same-origin service on port 8080: + +```bash +cd frontend +npm ci +npm run build +cd .. +set -a +. ./.env +set +a +go run ./cmd/support +``` + +For Vue hot reload, keep a built portal available for the Go process, set `SUPPORT_PUBLIC_URL=http://localhost:5173`, run `npm run dev`, and open port 5173. Vite proxies `/api` to the Go service on port 8080. + +For the production-shaped single-container build: + +```bash +docker compose up --build +``` + +## Validation + +```bash +go test ./... +go vet ./... +cd frontend +npm ci +npm run lint +npm test +npm run build +``` + +## Privacy boundaries + +- New reports are private. Nothing is published automatically. +- Diagnostic attachments are optional and must match the selected product's registered schema. +- The service does not store raw idempotency keys or raw status capabilities in database lookup columns. +- Private report text, contact details, receipt capabilities, and diagnostic objects are encrypted before storage. +- Support codes are identifiers, not authentication secrets. +- Private status URLs are bearer capabilities. Applications must never put them in diagnostics, telemetry, or public issues. +- The application does not submit diagnostics automatically, after a crash, or in the background. +- Server logs contain operational failures but no request body, capability path, contact data, diagnostic content, or remote address. + +See [docs/threat-model.md](docs/threat-model.md), [docs/operations.md](docs/operations.md), and [openapi.yaml](openapi.yaml) for the initial security and integration contract. + +## License + +GNU Affero General Public License v3.0. See [LICENSE](LICENSE). diff --git a/cmd/support/main.go b/cmd/support/main.go new file mode 100644 index 0000000..d5cdc8c --- /dev/null +++ b/cmd/support/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/obiente/support/internal/config" + "github.com/obiente/support/internal/cryptobox" + "github.com/obiente/support/internal/intake" + "github.com/obiente/support/internal/products" + "github.com/obiente/support/internal/store" + "github.com/obiente/support/internal/web" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + if err := run(logger); err != nil { + logger.Error("support service stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + configuration, err := config.FromEnvironment() + if err != nil { + return err + } + box, err := cryptobox.NewFromBase64(configuration.DataKey) + if err != nil { + return err + } + objects, err := store.NewFileObjects(configuration.ObjectRoot, box) + if err != nil { + return err + } + startupContext, startupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startupCancel() + reports, err := store.OpenPostgres(startupContext, configuration.DatabaseURL) + if err != nil { + return err + } + defer reports.Close() + if err := reports.Migrate(startupContext); err != nil { + return err + } + registry := products.Default() + intakeService := intake.New(reports, objects, registry, box, configuration.PublicURL) + if err := intakeService.PurgeExpired(startupContext, 250); err != nil { + logger.Warn("initial private-data retention purge did not complete", "error", err) + } + retentionContext, retentionCancel := context.WithCancel(context.Background()) + defer retentionCancel() + go runRetention(retentionContext, intakeService, logger) + webServer, err := web.New(intakeService, registry, logger, configuration.WebRoot) + if err != nil { + return err + } + server := web.HTTPServer(configuration.Address, webServer.Handler()) + shutdown, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + serverError := make(chan error, 1) + go func() { + logger.Info("support service listening", "address", configuration.Address, "environment", configuration.Environment) + serverError <- server.ListenAndServe() + }() + select { + case err := <-serverError: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-shutdown.Done(): + shutdownContext, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return server.Shutdown(shutdownContext) + } +} + +func runRetention(ctx context.Context, intakeService *intake.Service, logger *slog.Logger) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + purgeContext, cancel := context.WithTimeout(ctx, 5*time.Minute) + if err := intakeService.PurgeExpired(purgeContext, 250); err != nil { + logger.Warn("private-data retention purge did not complete", "error", err) + } + cancel() + } + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6d943cb --- /dev/null +++ b/compose.yaml @@ -0,0 +1,47 @@ +services: + support: + build: . + restart: unless-stopped + environment: + SUPPORT_ADDRESS: ":8080" + SUPPORT_PUBLIC_URL: "${SUPPORT_PUBLIC_URL:-http://localhost:8080}" + SUPPORT_ENVIRONMENT: "${SUPPORT_ENVIRONMENT:-development}" + SUPPORT_OBJECT_ROOT: /var/lib/obiente-support/private + SUPPORT_WEB_ROOT: /srv/support-web + SUPPORT_DATA_KEY: "${SUPPORT_DATA_KEY:?set SUPPORT_DATA_KEY}" + DATABASE_URL: "postgres://support:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@database:5432/support?sslmode=disable" + depends_on: + database: + condition: service_healthy + ports: + - "127.0.0.1:8080:8080" + volumes: + - private_reports:/var/lib/obiente-support/private + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + database: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: support + POSTGRES_USER: support + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U support -d support"] + interval: 5s + timeout: 3s + retries: 20 + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + volumes: + - support_database:/var/lib/postgresql/data + +volumes: + private_reports: + support_database: diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..adb1978 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,38 @@ +# Operations + +## Required production configuration + +- `SUPPORT_PUBLIC_URL`: canonical HTTPS origin, for example `https://support.obiente.org`. +- `SUPPORT_ENVIRONMENT=production`: rejects a non-HTTPS public URL. +- `SUPPORT_DATA_KEY`: base64-encoded 32-byte key from a cryptographically secure source. +- `DATABASE_URL`: PostgreSQL connection with a dedicated least-privilege database user. +- `SUPPORT_OBJECT_ROOT`: private persistent volume writable only by the service user. +- `SUPPORT_WEB_ROOT`: built Vue distribution included in the production image. + +Terminate HTTPS at a trusted reverse proxy. Do not expose PostgreSQL or the private object volume. Do not enable request-body logging at the proxy. + +## Backups + +Back up PostgreSQL and the private object volume as one retention unit. Encrypt backups independently from `SUPPORT_DATA_KEY`, restrict operator access, and apply the same deletion schedule to expired backups. A restore test must verify that database object keys and encrypted files remain consistent. + +## Key rotation + +The initial foundation supports one active data key. Do not replace it while private reports encrypted under the previous key remain. Dual-key reads and an audited re-encryption job are required before production key rotation. If the active key is exposed, stop intake, preserve audit evidence, notify the incident owner, and treat all retained private payloads as affected. + +## Recovery + +1. Restore PostgreSQL and the private object volume from the same backup generation. +2. Restore the exact `SUPPORT_DATA_KEY` version used for that generation. +3. Start the service without public traffic and verify `/healthz`. +4. Use synthetic fixtures to test create, reconcile, status, and deletion. +5. Confirm the retention purge completes before reopening intake. + +Never inspect a real user's plaintext report as a recovery test. + +## Data deletion + +Capability deletion soft-revokes the report immediately and removes its diagnostic object. The hourly retention task hard-deletes revoked and expired rows. If object deletion fails, the row remains so cleanup can retry without losing the object reference. + +## Incident response + +Do not paste private reports, capabilities, contact details, database rows, or decrypted diagnostics into GitHub issues, chat, or CI logs. Use synthetic identifiers in public incident tracking and a separately authorized private evidence path for affected data. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..d3e9f8f --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,63 @@ +# Initial threat model + +This document covers the private-intake foundation. It must be extended when moderation, maintainer access, public promotion, and GitHub synchronization are introduced. + +## Protected data + +- Unreviewed titles and descriptions. +- Optional contact details. +- Diagnostic archives and their contents. +- Private status and deletion capabilities. +- Idempotency keys while held by a submitting client. +- The encryption key and database credentials. + +Support codes, product IDs, request types, timestamps, and moderation states are not secrets by themselves, but they are not public tracker entries until moderation explicitly promotes them. + +## Trust boundaries + +1. The user's browser or application constructs a report after explicit review. +2. The HTTPS reverse proxy terminates public transport security. +3. The Go API validates and bounds metadata and archives before persistence. +4. PostgreSQL stores lookup metadata and encrypted private payloads. +5. A private filesystem volume stores encrypted diagnostic objects. +6. Future maintainer and GitHub integrations are outside this initial boundary. + +## Controls + +### Guessing and enumeration + +Private status capabilities contain 256 bits of randomness. Database lookups use SHA-256 hashes of capabilities. Invalid and deleted capabilities return the same not-found response. Support codes cannot access a report. + +### Replay and duplicate submission + +Clients provide a 256-bit idempotency key. The service stores only its hash and binds it to a hash of the normalized metadata and archive. A retry returns the original encrypted receipt capability. Reusing the same key for different content is rejected. + +### Archive attacks + +The HTTP body, metadata part, compressed archive, expanded archive, entry count, content type, and entry names are bounded. Every ZIP entry must match the selected product's registry. Nested paths, traversal, duplicates, special files, and unregistered entries are rejected. Archives are never extracted on the intake host. + +### Data disclosure + +Private fields, the recoverable receipt capability, and diagnostic objects use AES-256-GCM with random nonces and report-specific associated data. Database lookup columns contain hashes rather than bearer secrets. The service does not log request bodies, remote addresses, capability paths, or attachment names. + +Infrastructure still needs encrypted PostgreSQL and volume storage, protected backups, and encrypted backup transport. Application encryption does not replace host and backup encryption. + +### Retention and deletion + +Each product defines a private-data retention period. Expired and user-deleted records are purged hourly. Diagnostic object deletion must succeed before database hard deletion so an orphaned encrypted object remains discoverable for a later retry. Deleting a live report immediately revokes its capability. + +### Browser attacks + +The service sends a restrictive Content Security Policy, denies framing, disables MIME sniffing, uses same-origin API requests, and sets a no-referrer policy. Private status responses and SPA routes are not cacheable. The Vue application renders user-safe API fields rather than private report text. + +## Known incomplete controls + +- Abuse scoring, accessible challenge escalation, and distributed rate limits. +- Maintainer authentication, authorization, access audit, and session hardening. +- Moderation and exact public-field preview. +- Malware inspection beyond strict product schemas. +- Key rotation with dual-key reads and re-encryption. +- Backup restoration exercises and deletion propagation into backups. +- Public tracker and GitHub bridge threat boundaries. + +These are release gates for the corresponding later capabilities, not claims of current support. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..71d1541 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Obiente Support + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6fca2f9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3238 @@ +{ + "name": "@obiente/support-portal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@obiente/support-portal", + "version": "0.1.0", + "dependencies": { + "vue": "3.5.41", + "vue-router": "5.2.0" + }, + "devDependencies": { + "@types/node": "26.2.0", + "@vitejs/plugin-vue": "6.0.8", + "@vue/test-utils": "2.4.11", + "jsdom": "30.0.1", + "typescript": "5.9.3", + "vite": "8.2.1", + "vitest": "4.1.10", + "vue-tsc": "3.2.5" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/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/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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/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/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.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/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/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/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", + "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^8.0.0", + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2", + "yaml": "^2.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b143fa3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "@obiente/support-portal", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "test": "vitest run", + "test:watch": "vitest", + "lint": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "3.5.41", + "vue-router": "5.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.8", + "@types/node": "26.2.0", + "@vue/test-utils": "2.4.11", + "jsdom": "30.0.1", + "typescript": "5.9.3", + "vite": "8.2.1", + "vitest": "4.1.10", + "vue-tsc": "3.2.5" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..9cd3b22 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..2c3ed98 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..b047910 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,17 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' +import SupportIntakeView from './views/SupportIntakeView.vue' +import PrivateStatusView from './views/PrivateStatusView.vue' +import './styles.css' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'intake', component: SupportIntakeView }, + { path: '/r/:capability', name: 'private-status', component: PrivateStatusView }, + ], + scrollBehavior: () => ({ top: 0 }), +}) + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..b87f17c --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,151 @@ +:root { + color-scheme: dark; + --bg: #090a0f; + --panel: #11131b; + --panel-raised: #171a24; + --line: rgba(255, 255, 255, .1); + --line-strong: rgba(255, 255, 255, .18); + --text: #f6f4fb; + --muted: #a8a5b3; + --accent: #c6a6ff; + --accent-strong: #9d6df2; + --accent-soft: rgba(198, 166, 255, .12); + --success: #8ed9b2; + --danger: #ff9caa; + --radius: 22px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-height: 100vh; background: + radial-gradient(circle at 75% 5%, rgba(119, 78, 190, .19), transparent 32rem), + radial-gradient(circle at 8% 38%, rgba(82, 110, 188, .08), transparent 28rem), var(--bg); } +#app { display: grid; min-height: 100vh; grid-template-rows: auto 1fr auto; } +a { color: inherit; } +button, input, select, textarea { font: inherit; } +button, select { cursor: pointer; } +[hidden] { display: none !important; } + +.skip-link { position: fixed; top: 12px; left: 12px; z-index: 20; padding: 10px 14px; border-radius: 10px; background: var(--text); color: var(--bg); transform: translateY(-160%); } +.skip-link:focus { transform: translateY(0); } +.site-header { width: min(1180px, calc(100% - 40px)); height: 92px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } +.brand { display: inline-flex; align-items: center; gap: 12px; text-decoration: none; } +.brand-mark { display: grid; place-items: center; width: 38px; aspect-ratio: 1; border-radius: 12px; color: #0b0910; background: linear-gradient(145deg, #e6d7ff, #9c71ed); font-size: 20px; font-weight: 900; box-shadow: 0 10px 30px rgba(148, 101, 226, .25); } +.brand span:last-child { display: grid; } +.brand strong { font-size: 15px; letter-spacing: .02em; } +.brand small { color: var(--muted); font-size: 12px; } +nav { display: flex; gap: 28px; } +nav a { color: var(--muted); font-size: 14px; text-decoration: none; } +nav a:hover, nav a:focus-visible { color: var(--text); } + +main { overflow: hidden; } +.hero { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 104px 0 92px; } +.eyebrow { color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } +h1, h2, p { margin-top: 0; } +.hero h1 { max-width: 900px; margin: 22px 0 28px; font-size: clamp(52px, 8vw, 104px); line-height: .94; letter-spacing: -.065em; } +.hero h1 span { color: var(--muted); } +.hero-copy { max-width: 680px; color: #c5c2ce; font-size: clamp(18px, 2.2vw, 23px); line-height: 1.55; } +.trust-row { display: flex; flex-wrap: wrap; gap: 12px 26px; margin-top: 42px; color: var(--muted); font-size: 13px; } +.trust-row span::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 9px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px rgba(142, 217, 178, .08); } + +.intake-layout { position: relative; display: grid; grid-template-columns: minmax(280px, .8fr) minmax(500px, 1.2fr); gap: clamp(50px, 8vw, 120px); width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 100px 0 120px; } +.intake-layout::before { content: ""; position: absolute; z-index: -1; inset: 0 -100vw; border-top: 1px solid var(--line); background: rgba(6, 7, 11, .58); } +.form-intro { position: sticky; top: 40px; align-self: start; } +.form-intro h2 { margin: 18px 0; font-size: clamp(32px, 4vw, 54px); line-height: 1.02; letter-spacing: -.045em; } +.form-intro > p { color: var(--muted); line-height: 1.65; } +.steps { display: grid; gap: 0; margin: 40px 0 0; padding: 0; list-style: none; } +.steps li { position: relative; display: flex; align-items: flex-start; gap: 16px; padding: 0 0 28px; } +.steps li:not(:last-child)::after { content: ""; position: absolute; top: 32px; bottom: 4px; left: 15px; border-left: 1px solid var(--line-strong); } +.steps li > span { display: grid; flex: 0 0 31px; place-items: center; aspect-ratio: 1; border: 1px solid var(--line-strong); border-radius: 50%; color: var(--accent); background: var(--panel); font-size: 12px; font-weight: 800; } +.steps li div { display: grid; gap: 5px; padding-top: 4px; } +.steps small { color: var(--muted); line-height: 1.5; } + +.form-shell { border: 1px solid var(--line); border-radius: calc(var(--radius) + 8px); background: linear-gradient(150deg, rgba(24, 26, 37, .96), rgba(13, 15, 22, .96)); box-shadow: 0 35px 100px rgba(0, 0, 0, .35); overflow: hidden; } +form, .receipt { display: grid; gap: 24px; padding: clamp(24px, 5vw, 48px); } +fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +legend, .field > span { display: block; margin-bottom: 10px; color: #d8d5df; font-size: 13px; font-weight: 700; } +.request-types { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.request-types legend { grid-column: 1 / -1; } +.request-types label { position: relative; } +.request-types input { position: absolute; opacity: 0; } +.request-types label span { display: grid; min-height: 88px; gap: 6px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(255, 255, 255, .025); transition: border-color .15s, background .15s, transform .15s; } +.request-types label:hover span { border-color: var(--line-strong); transform: translateY(-1px); } +.request-types input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px rgba(198, 166, 255, .18); } +.request-types input:focus-visible + span { outline: 3px solid rgba(198, 166, 255, .28); outline-offset: 2px; } +.request-types b { font-size: 13px; } +.request-types small { color: var(--muted); font-size: 11px; line-height: 1.4; } +.field-grid { display: grid; grid-template-columns: 1.2fr .8fr; gap: 16px; } +.field { display: block; } +.field em { margin-left: 5px; color: var(--muted); font-size: 11px; font-style: normal; font-weight: 500; } +input, select, textarea { width: 100%; padding: 14px 15px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--text); background: #0d0f16; transition: border-color .15s, box-shadow .15s; } +input::placeholder, textarea::placeholder { color: #6f6d78; } +textarea { resize: vertical; line-height: 1.55; } +input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(198, 166, 255, .11); } +.field > small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.5; } +.consent { display: flex; align-items: flex-start; gap: 12px; color: #c4c1cb; font-size: 12px; line-height: 1.55; } +.consent input { width: 18px; height: 18px; margin-top: 1px; accent-color: var(--accent-strong); } +.primary, .secondary, .danger { display: inline-flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 50px; padding: 0 19px; border: 1px solid transparent; border-radius: 13px; font-weight: 750; text-decoration: none; } +.primary { color: #0c0910; background: linear-gradient(135deg, #dec9ff, #a77bea); box-shadow: 0 14px 35px rgba(130, 79, 211, .2); } +.primary:hover { filter: brightness(1.06); } +.primary:disabled { cursor: wait; filter: grayscale(.6); opacity: .62; } +.secondary { color: var(--text); border-color: var(--line-strong); background: transparent; } +.danger { color: var(--danger); border-color: rgba(255, 156, 170, .3); background: rgba(255, 156, 170, .06); } +.submit-actions { display: grid; grid-template-columns: 1fr auto; gap: 12px; } +progress { width: 100%; height: 7px; border: 0; border-radius: 999px; overflow: hidden; background: rgba(255, 255, 255, .07); } +progress::-webkit-progress-bar { background: rgba(255, 255, 255, .07); } +progress::-webkit-progress-value { background: var(--accent); } +progress::-moz-progress-bar { background: var(--accent); } +.form-status { min-height: 20px; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } + +.receipt { align-content: center; min-height: 620px; text-align: center; } +.receipt-icon { display: grid; place-items: center; width: 60px; aspect-ratio: 1; margin: 0 auto 4px; border-radius: 50%; color: #07120c; background: var(--success); font-size: 26px; font-weight: 900; } +.receipt h2 { margin: -10px 0 0; font-size: 36px; letter-spacing: -.04em; } +.receipt p { max-width: 440px; margin: 0 auto; color: var(--muted); line-height: 1.6; } +.support-code { display: block; padding: 20px; border: 1px dashed rgba(198, 166, 255, .38); border-radius: 15px; color: var(--accent); background: var(--accent-soft); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(22px, 4vw, 34px); font-weight: 800; letter-spacing: .08em; } +.receipt-actions { display: grid; grid-template-columns: 1fr auto; gap: 12px; } + +footer { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-between; width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 36px 0 48px; color: var(--muted); font-size: 12px; } +footer a { color: var(--text); } + +.status-body { display: grid; min-height: 100vh; grid-template-rows: auto 1fr; } +.status-main { display: grid; place-items: center; padding: 70px 20px; } +.status-card { width: min(620px, 100%); padding: clamp(28px, 6vw, 54px); border: 1px solid var(--line); border-radius: 28px; background: var(--panel); box-shadow: 0 35px 100px rgba(0, 0, 0, .4); } +.status-card h1 { margin: 16px 0 18px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(30px, 6vw, 48px); letter-spacing: .04em; } +.status-pill { display: inline-flex; padding: 7px 12px; border-radius: 999px; color: var(--accent); background: var(--accent-soft); font-size: 12px; font-weight: 800; text-transform: capitalize; } +.status-card dl { display: grid; margin: 36px 0; border-top: 1px solid var(--line); } +.status-card dl div { display: grid; grid-template-columns: 1fr 1.4fr; gap: 20px; padding: 15px 0; border-bottom: 1px solid var(--line); } +.status-card dt { color: var(--muted); } +.status-card dd { margin: 0; text-align: right; text-transform: capitalize; } +.status-card p { color: var(--muted); line-height: 1.6; } + +@media (max-width: 820px) { + nav a:first-child { display: none; } + .hero { padding: 76px 0 70px; } + .intake-layout { grid-template-columns: 1fr; padding-top: 72px; } + .form-intro { position: static; } + .steps { grid-template-columns: repeat(3, 1fr); gap: 12px; } + .steps li { display: grid; } + .steps li::after { display: none; } +} + +@media (max-width: 600px) { + .site-header { height: 76px; } + nav { gap: 16px; } + .hero h1 { font-size: 49px; } + .steps { grid-template-columns: 1fr; } + .steps li { grid-template-columns: auto 1fr; } + .request-types, .field-grid { grid-template-columns: 1fr; } + .request-types label span { min-height: 72px; } + form, .receipt { padding: 24px; } + .form-shell { border-radius: 20px; } + .receipt-actions { grid-template-columns: 1fr; } + .submit-actions { grid-template-columns: 1fr; } + footer { display: grid; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/frontend/src/support.test.ts b/frontend/src/support.test.ts new file mode 100644 index 0000000..cae4ec8 --- /dev/null +++ b/frontend/src/support.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { deleteReceipt, loadProducts, randomIdempotencyKey, reconcileReceipt } from './support' + +afterEach(() => vi.unstubAllGlobals()) + +describe('support contract client', () => { + it('creates a bounded URL-safe idempotency key', () => { + const key = randomIdempotencyKey() + expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + it('loads a product-neutral registry response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + contractVersion: 1, + products: [{ + id: 'synthetic-product', name: 'Synthetic product', description: 'Fixture', + diagnosticContentTypes: [], diagnosticEntries: [], diagnosticMaxBytes: 0, + diagnosticMaxExpandedBytes: 0, retentionDays: 7, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }))) + + const products = await loadProducts() + expect(products.map((product) => product.id)).toEqual(['synthetic-product']) + }) + + it('treats a missing idempotent receipt as safely retryable', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ code: 'not_found' }), { + status: 404, headers: { 'Content-Type': 'application/json' }, + }))) + + await expect(reconcileReceipt('A'.repeat(43))).resolves.toBeUndefined() + }) + + it('deletes only through a same-origin private capability receipt', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) + vi.stubGlobal('fetch', fetch) + const capability = 'abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678' + + await deleteReceipt({ + contractVersion: 1, + supportCode: 'OBI-ABCDE-23456', + status: 'new', + statusUrl: `${window.location.origin}/r/${capability}`, + deletionUrl: `${window.location.origin}/r/${capability}`, + createdAt: '2026-08-13T12:00:00Z', + retentionUntil: '2026-09-12T12:00:00Z', + }) + + expect(fetch).toHaveBeenCalledWith(`/api/v1/reports/${capability}`, expect.objectContaining({ method: 'DELETE' })) + }) + + it('rejects a cross-origin deletion receipt', async () => { + const capability = 'abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678' + await expect(deleteReceipt({ + contractVersion: 1, + supportCode: 'OBI-ABCDE-23456', + status: 'new', + statusUrl: `${window.location.origin}/r/${capability}`, + deletionUrl: `https://untrusted.example/r/${capability}`, + createdAt: '2026-08-13T12:00:00Z', + retentionUntil: '2026-09-12T12:00:00Z', + })).rejects.toThrow('invalid deletion link') + }) +}) diff --git a/frontend/src/support.ts b/frontend/src/support.ts new file mode 100644 index 0000000..2c303c7 --- /dev/null +++ b/frontend/src/support.ts @@ -0,0 +1,183 @@ +export type RequestType = 'bug' | 'feature' | 'support' + +export interface Product { + id: string + name: string + description: string + repository?: string + diagnosticContentTypes: string[] + diagnosticEntries: string[] + diagnosticMaxBytes: number + diagnosticMaxExpandedBytes: number + retentionDays: number +} + +export interface ProductResponse { + contractVersion: 1 + products: Product[] +} + +export interface ReportMetadata { + contractVersion: 1 + productId: string + requestType: RequestType + title: string + description: string + contact: string + source: 'web' | 'app' + release: { + version: string + channel?: string + platform: string + osVersion?: string + architecture?: string + } + privacyAccepted: boolean +} + +export interface Receipt { + contractVersion: 1 + supportCode: string + status: string + statusUrl: string + deletionUrl: string + createdAt: string + retentionUntil: string +} + +export interface PrivateStatus { + contractVersion: 1 + supportCode: string + productId: string + requestType: RequestType + status: string + createdAt: string + updatedAt: string + retentionUntil: string +} + +interface Problem { + code?: string + message?: string +} + +export class SupportApiError extends Error { + constructor(message: string, readonly code: string | undefined, readonly status: number) { + super(message) + } +} + +export function randomIdempotencyKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') +} + +export async function loadProducts(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/products', { headers: { Accept: 'application/json' }, signal }) + if (!response.ok) throw await apiError(response) + const body = (await response.json()) as ProductResponse + if (body.contractVersion !== 1 || !Array.isArray(body.products)) throw new Error('The support product list is invalid.') + return body.products +} + +export function submitReport( + metadata: ReportMetadata, + diagnostics: File | undefined, + idempotencyKey: string, + onProgress: (progress: number | undefined) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const body = new FormData() + body.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })) + if (diagnostics) body.append('diagnostics', diagnostics, 'diagnostics.zip') + const request = new XMLHttpRequest() + request.open('POST', '/api/v1/reports') + request.setRequestHeader('Accept', 'application/json') + request.setRequestHeader('Idempotency-Key', idempotencyKey) + request.upload.onprogress = (event) => onProgress(event.lengthComputable ? event.loaded / event.total : undefined) + request.onerror = () => reject(new TypeError('The support service could not be reached.')) + request.onabort = () => reject(new DOMException('Submission cancelled.', 'AbortError')) + request.onload = () => { + const parsed = parseJSON(request.responseText) + if (request.status < 200 || request.status >= 300) { + const problem = parsed as Problem + reject(new SupportApiError(problem.message || 'The report was rejected.', problem.code, request.status)) + return + } + resolve(parseReceipt(parsed)) + } + signal.addEventListener('abort', () => request.abort(), { once: true }) + request.send(body) + }) +} + +export async function reconcileReceipt(idempotencyKey: string, signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/receipts', { + headers: { Accept: 'application/json', 'Idempotency-Key': idempotencyKey }, + signal, + }) + if (response.status === 404) return undefined + if (!response.ok) throw await apiError(response) + return parseReceipt(await response.json()) +} + +export async function loadPrivateStatus(capability: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/v1/reports/${encodeURIComponent(capability)}`, { + headers: { Accept: 'application/json' }, signal, + }) + if (!response.ok) throw await apiError(response) + return (await response.json()) as PrivateStatus +} + +export async function deletePrivateReport(capability: string): Promise { + const response = await fetch(`/api/v1/reports/${encodeURIComponent(capability)}`, { + method: 'DELETE', headers: { Accept: 'application/json' }, + }) + if (!response.ok) throw await apiError(response) +} + +export async function deleteReceipt(receipt: Receipt): Promise { + const deletionUrl = new URL(receipt.deletionUrl, window.location.origin) + const match = /^\/r\/([A-Za-z0-9_-]{43})$/.exec(deletionUrl.pathname) + if (deletionUrl.origin !== window.location.origin || deletionUrl.search || deletionUrl.hash || !match) { + throw new Error('The support service returned an invalid deletion link.') + } + await deletePrivateReport(match[1]) +} + +async function apiError(response: Response): Promise { + const problem = (await response.json().catch(() => ({}))) as Problem + return new SupportApiError(problem.message || 'Support intake is temporarily unavailable.', problem.code, response.status) +} + +function parseJSON(value: string): unknown { + try { + return JSON.parse(value) + } catch { + throw new Error('The support service returned an invalid response.') + } +} + +function parseReceipt(value: unknown): Receipt { + if (!value || typeof value !== 'object') throw new Error('The support service returned an invalid receipt.') + const receipt = value as Partial + const statusUrl = typeof receipt.statusUrl === 'string' ? new URL(receipt.statusUrl, window.location.origin) : undefined + const deletionUrl = typeof receipt.deletionUrl === 'string' ? new URL(receipt.deletionUrl, window.location.origin) : undefined + if ( + receipt.contractVersion !== 1 || + typeof receipt.supportCode !== 'string' || + !/^OBI-[A-HJ-KM-NP-Z2-9]{5}-[A-HJ-KM-NP-Z2-9]{5}$/.test(receipt.supportCode) || + typeof receipt.status !== 'string' || + !statusUrl || statusUrl.origin !== window.location.origin || !/^\/r\/[A-Za-z0-9_-]{43}$/.test(statusUrl.pathname) || statusUrl.search || statusUrl.hash || + !deletionUrl || deletionUrl.origin !== window.location.origin || deletionUrl.pathname !== statusUrl.pathname || deletionUrl.search || deletionUrl.hash || + typeof receipt.createdAt !== 'string' || + typeof receipt.retentionUntil !== 'string' + ) { + throw new Error('The support service returned an invalid receipt.') + } + return receipt as Receipt +} diff --git a/frontend/src/views/PrivateStatusView.vue b/frontend/src/views/PrivateStatusView.vue new file mode 100644 index 0000000..99df85d --- /dev/null +++ b/frontend/src/views/PrivateStatusView.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/views/SupportIntakeView.vue b/frontend/src/views/SupportIntakeView.vue new file mode 100644 index 0000000..1612633 --- /dev/null +++ b/frontend/src/views/SupportIntakeView.vue @@ -0,0 +1,200 @@ + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..269ec1f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5639546 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:8080', + '/healthz': 'http://localhost:8080', + }, + }, + test: { + environment: 'jsdom', + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d097545 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/obiente/support + +go 1.24.0 + +require github.com/jackc/pgx/v5 v5.7.6 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7600ae8 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6a84f47 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,63 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +type Config struct { + Address string + PublicURL string + DatabaseURL string + DataKey string + ObjectRoot string + WebRoot string + Environment string +} + +func FromEnvironment() (Config, error) { + config := Config{ + Address: valueOrDefault("SUPPORT_ADDRESS", ":8080"), + PublicURL: strings.TrimSuffix(valueOrDefault("SUPPORT_PUBLIC_URL", "http://localhost:8080"), "/"), + DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")), + DataKey: strings.TrimSpace(os.Getenv("SUPPORT_DATA_KEY")), + ObjectRoot: valueOrDefault("SUPPORT_OBJECT_ROOT", "./data/private"), + WebRoot: valueOrDefault("SUPPORT_WEB_ROOT", "./frontend/dist"), + Environment: valueOrDefault("SUPPORT_ENVIRONMENT", "development"), + } + parsed, err := url.Parse(config.PublicURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return Config{}, errors.New("SUPPORT_PUBLIC_URL must be an absolute HTTP or HTTPS URL") + } + if config.Environment == "production" && parsed.Scheme != "https" { + return Config{}, errors.New("production SUPPORT_PUBLIC_URL must use HTTPS") + } + if config.DatabaseURL == "" { + return Config{}, errors.New("DATABASE_URL is required") + } + if config.DataKey == "" { + return Config{}, errors.New("SUPPORT_DATA_KEY is required") + } + absoluteRoot, err := filepath.Abs(config.ObjectRoot) + if err != nil { + return Config{}, fmt.Errorf("resolve SUPPORT_OBJECT_ROOT: %w", err) + } + config.ObjectRoot = absoluteRoot + absoluteWebRoot, err := filepath.Abs(config.WebRoot) + if err != nil { + return Config{}, fmt.Errorf("resolve SUPPORT_WEB_ROOT: %w", err) + } + config.WebRoot = absoluteWebRoot + return config, nil +} + +func valueOrDefault(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} diff --git a/internal/cryptobox/cryptobox.go b/internal/cryptobox/cryptobox.go new file mode 100644 index 0000000..84850e3 --- /dev/null +++ b/internal/cryptobox/cryptobox.go @@ -0,0 +1,52 @@ +package cryptobox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" +) + +const keyBytes = 32 + +type Box struct { + aead cipher.AEAD +} + +func NewFromBase64(encoded string) (*Box, error) { + key, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode SUPPORT_DATA_KEY: %w", err) + } + if len(key) != keyBytes { + return nil, fmt.Errorf("SUPPORT_DATA_KEY must decode to %d bytes", keyBytes) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Box{aead: aead}, nil +} + +func (box *Box) Seal(plaintext, context []byte) ([]byte, error) { + nonce := make([]byte, box.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return box.aead.Seal(nonce, nonce, plaintext, context), nil +} + +func (box *Box) Open(ciphertext, context []byte) ([]byte, error) { + if len(ciphertext) < box.aead.NonceSize() { + return nil, errors.New("ciphertext is truncated") + } + nonce := ciphertext[:box.aead.NonceSize()] + return box.aead.Open(nil, nonce, ciphertext[box.aead.NonceSize():], context) +} diff --git a/internal/cryptobox/cryptobox_test.go b/internal/cryptobox/cryptobox_test.go new file mode 100644 index 0000000..b46f169 --- /dev/null +++ b/internal/cryptobox/cryptobox_test.go @@ -0,0 +1,25 @@ +package cryptobox + +import ( + "encoding/base64" + "testing" +) + +func TestSealBindsCiphertextToContext(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + box, err := NewFromBase64(key) + if err != nil { + t.Fatal(err) + } + ciphertext, err := box.Seal([]byte("private report"), []byte("report-a")) + if err != nil { + t.Fatal(err) + } + plaintext, err := box.Open(ciphertext, []byte("report-a")) + if err != nil || string(plaintext) != "private report" { + t.Fatalf("open = %q, %v", plaintext, err) + } + if _, err := box.Open(ciphertext, []byte("report-b")); err == nil { + t.Fatal("ciphertext opened under a different report context") + } +} diff --git a/internal/domain/report.go b/internal/domain/report.go new file mode 100644 index 0000000..51d26a0 --- /dev/null +++ b/internal/domain/report.go @@ -0,0 +1,99 @@ +package domain + +import "time" + +const ( + ContractVersion = 1 + MaxMetadataBytes = 32 * 1024 + MaxDiagnosticArchiveBytes = 4 * 1024 * 1024 +) + +type RequestType string + +const ( + RequestBug RequestType = "bug" + RequestFeature RequestType = "feature" + RequestSupport RequestType = "support" +) + +func (value RequestType) Valid() bool { + return value == RequestBug || value == RequestFeature || value == RequestSupport +} + +type ReportStatus string + +const ( + StatusNew ReportStatus = "new" + StatusNeedsInformation ReportStatus = "needs_information" + StatusAccepted ReportStatus = "accepted" + StatusDuplicate ReportStatus = "duplicate" + StatusResolved ReportStatus = "resolved" + StatusRejected ReportStatus = "rejected" +) + +type ReleaseMetadata struct { + Version string `json:"version"` + Channel string `json:"channel,omitempty"` + Platform string `json:"platform"` + OSVersion string `json:"osVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` +} + +type ReportMetadata struct { + ContractVersion int `json:"contractVersion"` + ProductID string `json:"productId"` + RequestType RequestType `json:"requestType"` + Title string `json:"title"` + Description string `json:"description"` + Contact string `json:"contact,omitempty"` + Source string `json:"source"` + Release ReleaseMetadata `json:"release"` + PrivacyAccepted bool `json:"privacyAccepted"` +} + +type PrivatePayload struct { + Title string `json:"title"` + Description string `json:"description"` + Contact string `json:"contact,omitempty"` + Source string `json:"source"` + Release ReleaseMetadata `json:"release"` +} + +type Report struct { + ID string + SupportCode string + ProductID string + RequestType RequestType + Status ReportStatus + PrivatePayload []byte + CapabilityCiphertext []byte + DiagnosticObjectKey *string + IdempotencyHash []byte + RequestHash []byte + CapabilityHash []byte + CreatedAt time.Time + UpdatedAt time.Time + RetentionUntil time.Time + DeletedAt *time.Time +} + +type Receipt struct { + ContractVersion int `json:"contractVersion"` + SupportCode string `json:"supportCode"` + Status string `json:"status"` + StatusURL string `json:"statusUrl"` + DeletionURL string `json:"deletionUrl"` + CreatedAt time.Time `json:"createdAt"` + RetentionUntil time.Time `json:"retentionUntil"` +} + +type PrivateStatus struct { + ContractVersion int `json:"contractVersion"` + SupportCode string `json:"supportCode"` + ProductID string `json:"productId"` + RequestType string `json:"requestType"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RetentionUntil time.Time `json:"retentionUntil"` +} diff --git a/internal/intake/service.go b/internal/intake/service.go new file mode 100644 index 0000000..649315f --- /dev/null +++ b/internal/intake/service.go @@ -0,0 +1,395 @@ +package intake + +import ( + "archive/zip" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "path" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/obiente/support/internal/cryptobox" + "github.com/obiente/support/internal/domain" + "github.com/obiente/support/internal/products" + "github.com/obiente/support/internal/store" +) + +var ( + ErrInvalid = errors.New("invalid report") + ErrNotFound = errors.New("report not found") + ErrKeyReused = errors.New("idempotency key was already used for another report") + idempotencyKey = regexp.MustCompile(`^[A-Za-z0-9_-]{32,128}$`) + productID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`) + archiveFileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$`) +) + +type Submission struct { + Metadata domain.ReportMetadata + Archive []byte + ArchiveType string + IdempotencyKey string +} + +type Service struct { + reports store.Reports + objects store.Objects + products *products.Registry + box *cryptobox.Box + publicURL string + now func() time.Time +} + +func New(reports store.Reports, objects store.Objects, registry *products.Registry, box *cryptobox.Box, publicURL string) *Service { + return &Service{ + reports: reports, objects: objects, products: registry, box: box, + publicURL: strings.TrimSuffix(publicURL, "/"), now: func() time.Time { return time.Now().UTC() }, + } +} + +func (service *Service) Submit(ctx context.Context, submission Submission) (domain.Receipt, error) { + metadata, product, err := service.validate(submission) + if err != nil { + return domain.Receipt{}, err + } + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return domain.Receipt{}, err + } + requestHash := hashRequest(metadataBytes, submission.Archive) + idempotencyHash := hash([]byte(submission.IdempotencyKey)) + if existing, err := service.reports.ByIdempotencyHash(ctx, idempotencyHash); err == nil { + if !bytes.Equal(existing.RequestHash, requestHash) { + return domain.Receipt{}, ErrKeyReused + } + return service.receipt(existing) + } else if !errors.Is(err, store.ErrNotFound) { + return domain.Receipt{}, err + } + + privatePayload, err := json.Marshal(domain.PrivatePayload{ + Title: metadata.Title, Description: metadata.Description, Contact: metadata.Contact, + Source: metadata.Source, Release: metadata.Release, + }) + if err != nil { + return domain.Receipt{}, err + } + + for attempt := 0; attempt < 4; attempt++ { + reportID, err := randomUUID() + if err != nil { + return domain.Receipt{}, err + } + capability, err := randomToken(32) + if err != nil { + return domain.Receipt{}, err + } + supportCode, err := randomSupportCode() + if err != nil { + return domain.Receipt{}, err + } + sealedPayload, err := service.box.Seal(privatePayload, []byte(reportID+":payload")) + if err != nil { + return domain.Receipt{}, err + } + sealedCapability, err := service.box.Seal([]byte(capability), []byte(reportID+":capability")) + if err != nil { + return domain.Receipt{}, err + } + now := service.now() + report := domain.Report{ + ID: reportID, SupportCode: supportCode, ProductID: product.ID, + RequestType: metadata.RequestType, Status: domain.StatusNew, + PrivatePayload: sealedPayload, CapabilityCiphertext: sealedCapability, + IdempotencyHash: idempotencyHash, RequestHash: requestHash, + CapabilityHash: hash([]byte(capability)), CreatedAt: now, UpdatedAt: now, + RetentionUntil: now.Add(time.Duration(product.RetentionDays) * 24 * time.Hour), + } + if len(submission.Archive) > 0 { + objectKeyBytes := make([]byte, 32) + if _, err := rand.Read(objectKeyBytes); err != nil { + return domain.Receipt{}, err + } + objectKey := hex.EncodeToString(objectKeyBytes) + ".enc" + if err := service.objects.Put(objectKey, reportID, submission.Archive); err != nil { + return domain.Receipt{}, err + } + report.DiagnosticObjectKey = &objectKey + } + if err := service.reports.Create(ctx, report); err != nil { + if report.DiagnosticObjectKey != nil { + _ = service.objects.Delete(*report.DiagnosticObjectKey) + } + if errors.Is(err, store.ErrConflict) { + if existing, lookupErr := service.reports.ByIdempotencyHash(ctx, idempotencyHash); lookupErr == nil { + if !bytes.Equal(existing.RequestHash, requestHash) { + return domain.Receipt{}, ErrKeyReused + } + return service.receipt(existing) + } + continue + } + return domain.Receipt{}, err + } + return service.receipt(report) + } + return domain.Receipt{}, errors.New("could not allocate a unique support receipt") +} + +func (service *Service) Reconcile(ctx context.Context, key string) (domain.Receipt, error) { + if !idempotencyKey.MatchString(key) { + return domain.Receipt{}, ErrInvalid + } + report, err := service.reports.ByIdempotencyHash(ctx, hash([]byte(key))) + if errors.Is(err, store.ErrNotFound) { + return domain.Receipt{}, ErrNotFound + } + if err != nil { + return domain.Receipt{}, err + } + return service.receipt(report) +} + +func (service *Service) Status(ctx context.Context, capability string) (domain.PrivateStatus, error) { + if !validCapability(capability) { + return domain.PrivateStatus{}, ErrNotFound + } + report, err := service.reports.ByCapabilityHash(ctx, hash([]byte(capability))) + if errors.Is(err, store.ErrNotFound) { + return domain.PrivateStatus{}, ErrNotFound + } + if err != nil { + return domain.PrivateStatus{}, err + } + return privateStatus(report), nil +} + +func (service *Service) Delete(ctx context.Context, capability string) (domain.PrivateStatus, error) { + if !validCapability(capability) { + return domain.PrivateStatus{}, ErrNotFound + } + report, err := service.reports.DeleteByCapabilityHash(ctx, hash([]byte(capability))) + if errors.Is(err, store.ErrNotFound) { + return domain.PrivateStatus{}, ErrNotFound + } + if err != nil { + return domain.PrivateStatus{}, err + } + if report.DiagnosticObjectKey != nil { + if err := service.objects.Delete(*report.DiagnosticObjectKey); err != nil { + return domain.PrivateStatus{}, fmt.Errorf("delete private diagnostic object: %w", err) + } + } + status := privateStatus(report) + status.Status = "deleted" + return status, nil +} + +func (service *Service) PurgeExpired(ctx context.Context, limit int) error { + if limit < 1 || limit > 1000 { + return errors.New("invalid retention purge limit") + } + now := service.now() + reports, err := service.reports.Expired(ctx, now, limit) + if err != nil { + return err + } + var purgeErrors []error + for _, report := range reports { + if report.DiagnosticObjectKey != nil { + if err := service.objects.Delete(*report.DiagnosticObjectKey); err != nil { + purgeErrors = append(purgeErrors, fmt.Errorf("delete diagnostic object for %s: %w", report.ID, err)) + continue + } + } + if err := service.reports.Purge(ctx, report.ID, now); err != nil && !errors.Is(err, store.ErrNotFound) { + purgeErrors = append(purgeErrors, fmt.Errorf("purge report %s: %w", report.ID, err)) + } + } + return errors.Join(purgeErrors...) +} + +func (service *Service) receipt(report domain.Report) (domain.Receipt, error) { + capability, err := service.box.Open(report.CapabilityCiphertext, []byte(report.ID+":capability")) + if err != nil { + return domain.Receipt{}, err + } + statusURL := service.publicURL + "/r/" + url.PathEscape(string(capability)) + return domain.Receipt{ + ContractVersion: domain.ContractVersion, SupportCode: report.SupportCode, + Status: string(report.Status), StatusURL: statusURL, DeletionURL: statusURL, + CreatedAt: report.CreatedAt, RetentionUntil: report.RetentionUntil, + }, nil +} + +func (service *Service) validate(submission Submission) (domain.ReportMetadata, products.Product, error) { + metadata := submission.Metadata + metadata.ProductID = strings.TrimSpace(metadata.ProductID) + metadata.Title = strings.TrimSpace(metadata.Title) + metadata.Description = strings.TrimSpace(metadata.Description) + metadata.Contact = strings.TrimSpace(metadata.Contact) + metadata.Source = strings.TrimSpace(metadata.Source) + metadata.Release.Version = strings.TrimSpace(metadata.Release.Version) + metadata.Release.Channel = strings.TrimSpace(metadata.Release.Channel) + metadata.Release.Platform = strings.TrimSpace(metadata.Release.Platform) + metadata.Release.OSVersion = strings.TrimSpace(metadata.Release.OSVersion) + metadata.Release.Architecture = strings.TrimSpace(metadata.Release.Architecture) + if metadata.ContractVersion != domain.ContractVersion || !idempotencyKey.MatchString(submission.IdempotencyKey) || + !productID.MatchString(metadata.ProductID) || !metadata.RequestType.Valid() || !metadata.PrivacyAccepted || + (metadata.Source != "web" && metadata.Source != "app") || !validText(metadata.Title, 4, 160, false) || + !validText(metadata.Description, 10, 8000, true) || !validText(metadata.Contact, 0, 320, false) || + !validText(metadata.Release.Version, 0, 80, false) || !validText(metadata.Release.Channel, 0, 40, false) || + !validText(metadata.Release.Platform, 0, 60, false) || !validText(metadata.Release.OSVersion, 0, 120, false) || + !validText(metadata.Release.Architecture, 0, 40, false) { + return domain.ReportMetadata{}, products.Product{}, ErrInvalid + } + product, ok := service.products.Get(metadata.ProductID) + if !ok { + return domain.ReportMetadata{}, products.Product{}, ErrInvalid + } + if len(submission.Archive) > 0 { + if int64(len(submission.Archive)) > product.DiagnosticMaxBytes || !contains(product.DiagnosticContentTypes, submission.ArchiveType) { + return domain.ReportMetadata{}, products.Product{}, ErrInvalid + } + if err := validateArchive(submission.Archive, product); err != nil { + return domain.ReportMetadata{}, products.Product{}, fmt.Errorf("%w: %v", ErrInvalid, err) + } + } + return metadata, product, nil +} + +func validateArchive(content []byte, product products.Product) error { + archive, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return errors.New("diagnostic attachment is not a valid ZIP archive") + } + if len(archive.File) == 0 || len(archive.File) > 32 { + return errors.New("diagnostic archive has an invalid entry count") + } + allowed := make(map[string]struct{}, len(product.DiagnosticEntries)) + for _, name := range product.DiagnosticEntries { + allowed[name] = struct{}{} + } + seen := make(map[string]struct{}, len(archive.File)) + var expanded int64 + for _, entry := range archive.File { + clean := path.Clean(entry.Name) + if clean != entry.Name || !archiveFileName.MatchString(entry.Name) || entry.FileInfo().Mode()&0o170000 != 0 { + return errors.New("diagnostic archive contains an unsafe entry") + } + if _, ok := allowed[entry.Name]; !ok { + return errors.New("diagnostic archive contains an unregistered entry") + } + if _, duplicate := seen[entry.Name]; duplicate { + return errors.New("diagnostic archive contains a duplicate entry") + } + seen[entry.Name] = struct{}{} + reader, err := entry.Open() + if err != nil { + return errors.New("diagnostic archive entry could not be opened") + } + remaining := product.DiagnosticMaxExpanded - expanded + written, copyErr := io.CopyN(io.Discard, reader, remaining+1) + closeErr := reader.Close() + expanded += written + if copyErr != nil && !errors.Is(copyErr, io.EOF) || closeErr != nil || expanded > product.DiagnosticMaxExpanded { + return errors.New("diagnostic archive expands beyond its limit") + } + } + for _, required := range product.DiagnosticEntries { + if _, ok := seen[required]; !ok { + return errors.New("diagnostic archive is missing a required entry") + } + } + return nil +} + +func validText(value string, minimum, maximum int, multiline bool) bool { + if !utf8.ValidString(value) || len(value) < minimum || len(value) > maximum { + return false + } + for _, character := range value { + if character == 0 || character == '\r' || !multiline && character == '\n' || character < 0x20 && character != '\n' && character != '\t' { + return false + } + } + return true +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func privateStatus(report domain.Report) domain.PrivateStatus { + return domain.PrivateStatus{ + ContractVersion: domain.ContractVersion, SupportCode: report.SupportCode, + ProductID: report.ProductID, RequestType: string(report.RequestType), + Status: string(report.Status), CreatedAt: report.CreatedAt, UpdatedAt: report.UpdatedAt, + RetentionUntil: report.RetentionUntil, + } +} + +func validCapability(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 +} + +func hash(value []byte) []byte { + digest := sha256.Sum256(value) + return digest[:] +} + +func hashRequest(metadata, archive []byte) []byte { + digest := sha256.New() + digest.Write(metadata) + digest.Write([]byte{0}) + digest.Write(archive) + return digest.Sum(nil) +} + +func randomToken(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func randomSupportCode() (string, error) { + const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + value := make([]byte, 10) + random := make([]byte, len(value)) + if _, err := rand.Read(random); err != nil { + return "", err + } + for index := range value { + value[index] = alphabet[int(random[index])%len(alphabet)] + } + return "OBI-" + string(value[:5]) + "-" + string(value[5:]), nil +} + +func randomUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = value[6]&0x0f | 0x40 + value[8] = value[8]&0x3f | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil +} diff --git a/internal/intake/service_test.go b/internal/intake/service_test.go new file mode 100644 index 0000000..dbdd939 --- /dev/null +++ b/internal/intake/service_test.go @@ -0,0 +1,166 @@ +package intake + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + "github.com/obiente/support/internal/cryptobox" + "github.com/obiente/support/internal/domain" + "github.com/obiente/support/internal/products" + "github.com/obiente/support/internal/store" +) + +func TestSubmitReconcilesWithoutDuplicatingPrivateReport(t *testing.T) { + service, reports, objects := testService(t) + submission := validSubmission(t) + first, err := service.Submit(context.Background(), submission) + if err != nil { + t.Fatal(err) + } + second, err := service.Submit(context.Background(), submission) + if err != nil { + t.Fatal(err) + } + reconciled, err := service.Reconcile(context.Background(), submission.IdempotencyKey) + if err != nil { + t.Fatal(err) + } + if first.SupportCode != second.SupportCode || first.StatusURL != reconciled.StatusURL { + t.Fatalf("receipts differ: first=%#v second=%#v reconciled=%#v", first, second, reconciled) + } + if len(objects.Values) != 1 { + t.Fatalf("private object count = %d, want 1", len(objects.Values)) + } + stored, err := reports.ByIdempotencyHash(context.Background(), hash([]byte(submission.IdempotencyKey))) + if err != nil || stored.DiagnosticObjectKey == nil { + t.Fatalf("stored report = %#v, %v", stored, err) + } +} + +func TestSubmitRejectsIdempotencyKeyReuseWithDifferentContent(t *testing.T) { + service, _, _ := testService(t) + submission := validSubmission(t) + if _, err := service.Submit(context.Background(), submission); err != nil { + t.Fatal(err) + } + submission.Metadata.Description = "A materially different support request." + if _, err := service.Submit(context.Background(), submission); !errors.Is(err, ErrKeyReused) { + t.Fatalf("error = %v, want ErrKeyReused", err) + } +} + +func TestSubmitRejectsUnregisteredAndExpandingArchiveEntries(t *testing.T) { + service, _, _ := testService(t) + submission := validSubmission(t) + submission.Archive = zipContent(t, map[string]string{"../private.txt": "secret"}) + if _, err := service.Submit(context.Background(), submission); !errors.Is(err, ErrInvalid) { + t.Fatalf("traversal error = %v, want ErrInvalid", err) + } + + submission.IdempotencyKey = strings.Repeat("B", 43) + submission.Archive = zipContent(t, map[string]string{"diagnostic.txt": strings.Repeat("x", 2049)}) + if _, err := service.Submit(context.Background(), submission); !errors.Is(err, ErrInvalid) { + t.Fatalf("expansion error = %v, want ErrInvalid", err) + } +} + +func TestDeleteRevokesCapabilityAndRemovesDiagnosticObject(t *testing.T) { + service, _, objects := testService(t) + receipt, err := service.Submit(context.Background(), validSubmission(t)) + if err != nil { + t.Fatal(err) + } + capability := receipt.StatusURL[strings.LastIndex(receipt.StatusURL, "/")+1:] + if _, err := service.Status(context.Background(), capability); err != nil { + t.Fatal(err) + } + deleted, err := service.Delete(context.Background(), capability) + if err != nil || deleted.Status != "deleted" { + t.Fatalf("delete = %#v, %v", deleted, err) + } + if len(objects.Values) != 0 { + t.Fatalf("private object count after deletion = %d", len(objects.Values)) + } + if _, err := service.Status(context.Background(), capability); !errors.Is(err, ErrNotFound) { + t.Fatalf("status error after deletion = %v, want ErrNotFound", err) + } +} + +func TestPurgeExpiredRemovesEncryptedObjectAndRow(t *testing.T) { + service, reports, objects := testService(t) + submission := validSubmission(t) + if _, err := service.Submit(context.Background(), submission); err != nil { + t.Fatal(err) + } + service.now = func() time.Time { return time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC) } + if err := service.PurgeExpired(context.Background(), 100); err != nil { + t.Fatal(err) + } + if len(objects.Values) != 0 { + t.Fatalf("private object count after retention = %d", len(objects.Values)) + } + if _, err := reports.ByIdempotencyHash(context.Background(), hash([]byte(submission.IdempotencyKey))); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("retained row error = %v, want ErrNotFound", err) + } +} + +func testService(t *testing.T) (*Service, *store.Memory, *store.MemoryObjects) { + t.Helper() + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + box, err := cryptobox.NewFromBase64(key) + if err != nil { + t.Fatal(err) + } + registry, err := products.New([]products.Product{{ + ID: "synthetic-product", Name: "Synthetic product", RetentionDays: 7, + DiagnosticContentTypes: []string{"application/zip"}, DiagnosticEntries: []string{"diagnostic.txt"}, + DiagnosticMaxBytes: 4096, DiagnosticMaxExpanded: 2048, + }}) + if err != nil { + t.Fatal(err) + } + reports := store.NewMemory() + objects := store.NewMemoryObjects() + service := New(reports, objects, registry, box, "https://support.example") + service.now = func() time.Time { return time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) } + return service, reports, objects +} + +func validSubmission(t *testing.T) Submission { + t.Helper() + return Submission{ + Metadata: domain.ReportMetadata{ + ContractVersion: 1, ProductID: "synthetic-product", RequestType: domain.RequestBug, + Title: "Synthetic failure", Description: "The synthetic action did not complete.", + Source: "app", Release: domain.ReleaseMetadata{Version: "1.0.0", Platform: "synthetic"}, + PrivacyAccepted: true, + }, + Archive: zipContent(t, map[string]string{"diagnostic.txt": "bounded diagnostics"}), + ArchiveType: "application/zip", IdempotencyKey: strings.Repeat("A", 43), + } +} + +func zipContent(t *testing.T, entries map[string]string) []byte { + t.Helper() + var content bytes.Buffer + writer := zip.NewWriter(&content) + for name, value := range entries { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(value)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return content.Bytes() +} diff --git a/internal/products/registry.go b/internal/products/registry.go new file mode 100644 index 0000000..3d72900 --- /dev/null +++ b/internal/products/registry.go @@ -0,0 +1,83 @@ +package products + +import ( + "errors" + "sort" +) + +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Repository string `json:"repository,omitempty"` + DiagnosticContentTypes []string `json:"diagnosticContentTypes"` + DiagnosticEntries []string `json:"diagnosticEntries"` + DiagnosticMaxBytes int64 `json:"diagnosticMaxBytes"` + DiagnosticMaxExpanded int64 `json:"diagnosticMaxExpandedBytes"` + RetentionDays int `json:"retentionDays"` +} + +type Registry struct { + products map[string]Product +} + +func New(values []Product) (*Registry, error) { + registry := &Registry{products: make(map[string]Product, len(values))} + for _, product := range values { + if product.ID == "" || product.Name == "" || product.RetentionDays < 1 { + return nil, errors.New("invalid product registration") + } + if _, exists := registry.products[product.ID]; exists { + return nil, errors.New("duplicate product registration") + } + registry.products[product.ID] = product + } + if len(registry.products) == 0 { + return nil, errors.New("at least one product is required") + } + return registry, nil +} + +func Default() *Registry { + registry, err := New([]Product{ + { + ID: "nextcloud-native", + Name: "Nextcloud Native", + Description: "Native Nextcloud client for Android and desktop.", + Repository: "Obiente/nc-native", + DiagnosticContentTypes: []string{"application/zip"}, + DiagnosticEntries: []string{"README.txt", "report.json", "events.jsonl", "manifest.json"}, + DiagnosticMaxBytes: 4 * 1024 * 1024, + DiagnosticMaxExpanded: 8 * 1024 * 1024, + RetentionDays: 30, + }, + { + ID: "obiente-general", + Name: "Other Obiente project", + Description: "Questions and requests that are not tied to a listed product.", + DiagnosticContentTypes: []string{}, + DiagnosticEntries: []string{}, + DiagnosticMaxBytes: 0, + DiagnosticMaxExpanded: 0, + RetentionDays: 30, + }, + }) + if err != nil { + panic(err) + } + return registry +} + +func (registry *Registry) Get(id string) (Product, bool) { + product, ok := registry.products[id] + return product, ok +} + +func (registry *Registry) List() []Product { + result := make([]Product, 0, len(registry.products)) + for _, product := range registry.products { + result = append(result, product) + } + sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name }) + return result +} diff --git a/internal/products/registry_test.go b/internal/products/registry_test.go new file mode 100644 index 0000000..bc76105 --- /dev/null +++ b/internal/products/registry_test.go @@ -0,0 +1,20 @@ +package products + +import "testing" + +func TestRegistryOnboardsSyntheticProductWithoutCodeChanges(t *testing.T) { + registry, err := New([]Product{ + {ID: "synthetic-product", Name: "Synthetic product", RetentionDays: 7, DiagnosticEntries: []string{"diagnostic.txt"}}, + {ID: "another-product", Name: "Another product", RetentionDays: 14}, + }) + if err != nil { + t.Fatal(err) + } + product, ok := registry.Get("synthetic-product") + if !ok || product.DiagnosticEntries[0] != "diagnostic.txt" { + t.Fatalf("synthetic product was not registered: %#v", product) + } + if len(registry.List()) != 2 { + t.Fatalf("product count = %d, want 2", len(registry.List())) + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go new file mode 100644 index 0000000..2422a0a --- /dev/null +++ b/internal/store/memory.go @@ -0,0 +1,94 @@ +package store + +import ( + "bytes" + "context" + "sync" + "time" + + "github.com/obiente/support/internal/domain" +) + +type Memory struct { + mu sync.Mutex + reports []domain.Report +} + +func NewMemory() *Memory { return &Memory{} } + +func (memory *Memory) Create(_ context.Context, report domain.Report) error { + memory.mu.Lock() + defer memory.mu.Unlock() + for _, existing := range memory.reports { + if existing.SupportCode == report.SupportCode || bytes.Equal(existing.IdempotencyHash, report.IdempotencyHash) { + return ErrConflict + } + } + memory.reports = append(memory.reports, report) + return nil +} + +func (memory *Memory) ByIdempotencyHash(_ context.Context, hash []byte) (domain.Report, error) { + memory.mu.Lock() + defer memory.mu.Unlock() + for _, report := range memory.reports { + if bytes.Equal(report.IdempotencyHash, hash) && report.DeletedAt == nil { + return report, nil + } + } + return domain.Report{}, ErrNotFound +} + +func (memory *Memory) ByCapabilityHash(_ context.Context, hash []byte) (domain.Report, error) { + memory.mu.Lock() + defer memory.mu.Unlock() + for _, report := range memory.reports { + if bytes.Equal(report.CapabilityHash, hash) && report.DeletedAt == nil { + return report, nil + } + } + return domain.Report{}, ErrNotFound +} + +func (memory *Memory) DeleteByCapabilityHash(_ context.Context, hash []byte) (domain.Report, error) { + memory.mu.Lock() + defer memory.mu.Unlock() + for index, report := range memory.reports { + if bytes.Equal(report.CapabilityHash, hash) && report.DeletedAt == nil { + now := time.Now().UTC() + memory.reports[index].DeletedAt = &now + memory.reports[index].UpdatedAt = now + return memory.reports[index], nil + } + } + return domain.Report{}, ErrNotFound +} + +func (memory *Memory) Expired(_ context.Context, before time.Time, limit int) ([]domain.Report, error) { + memory.mu.Lock() + defer memory.mu.Unlock() + result := make([]domain.Report, 0, limit) + for _, report := range memory.reports { + if report.DeletedAt != nil || !report.RetentionUntil.After(before) { + result = append(result, report) + if len(result) == limit { + break + } + } + } + return result, nil +} + +func (memory *Memory) Purge(_ context.Context, id string, before time.Time) error { + memory.mu.Lock() + defer memory.mu.Unlock() + for index, report := range memory.reports { + if report.ID == id && (report.DeletedAt != nil || !report.RetentionUntil.After(before)) { + memory.reports = append(memory.reports[:index], memory.reports[index+1:]...) + return nil + } + } + return ErrNotFound +} + +func (memory *Memory) Close() {} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..dba3979 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,32 @@ +package store + +import ( + "context" + "embed" + "fmt" + "sort" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +func (postgres *Postgres) Migrate(ctx context.Context) error { + entries, err := migrations.ReadDir("migrations") + if err != nil { + return err + } + sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() }) + for _, entry := range entries { + if entry.IsDir() { + continue + } + migration, err := migrations.ReadFile("migrations/" + entry.Name()) + if err != nil { + return err + } + if _, err := postgres.pool.Exec(ctx, string(migration)); err != nil { + return fmt.Errorf("apply %s: %w", entry.Name(), err) + } + } + return nil +} diff --git a/internal/store/migrations/001_initial.sql b/internal/store/migrations/001_initial.sql new file mode 100644 index 0000000..7fa0c37 --- /dev/null +++ b/internal/store/migrations/001_initial.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS support_reports ( + id UUID PRIMARY KEY, + support_code TEXT NOT NULL UNIQUE, + product_id TEXT NOT NULL, + request_type TEXT NOT NULL CHECK (request_type IN ('bug', 'feature', 'support')), + status TEXT NOT NULL CHECK (status IN ('new', 'needs_information', 'accepted', 'duplicate', 'resolved', 'rejected')), + private_payload BYTEA NOT NULL, + capability_ciphertext BYTEA NOT NULL, + diagnostic_object_key TEXT, + idempotency_hash BYTEA NOT NULL UNIQUE, + request_hash BYTEA NOT NULL, + capability_hash BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + retention_until TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS support_reports_queue + ON support_reports (product_id, status, created_at) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS support_reports_retention + ON support_reports (retention_until) + WHERE deleted_at IS NULL; diff --git a/internal/store/objects.go b/internal/store/objects.go new file mode 100644 index 0000000..456b596 --- /dev/null +++ b/internal/store/objects.go @@ -0,0 +1,92 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/obiente/support/internal/cryptobox" +) + +var objectKeyPattern = regexp.MustCompile(`^[a-f0-9]{64}\.enc$`) + +type Objects interface { + Put(key, reportID string, plaintext []byte) error + Delete(key string) error +} + +type FileObjects struct { + root string + box *cryptobox.Box +} + +func NewFileObjects(root string, box *cryptobox.Box) (*FileObjects, error) { + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("create private object root: %w", err) + } + if err := os.Chmod(root, 0o700); err != nil { + return nil, fmt.Errorf("protect private object root: %w", err) + } + return &FileObjects{root: root, box: box}, nil +} + +func (objects *FileObjects) Put(key, reportID string, plaintext []byte) error { + if !objectKeyPattern.MatchString(key) { + return errors.New("invalid private object key") + } + ciphertext, err := objects.box.Seal(plaintext, []byte(reportID+":diagnostics")) + if err != nil { + return err + } + temporary, err := os.CreateTemp(objects.root, ".upload-*.tmp") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(ciphertext); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryName, filepath.Join(objects.root, key)) +} + +func (objects *FileObjects) Delete(key string) error { + if !objectKeyPattern.MatchString(key) { + return errors.New("invalid private object key") + } + err := os.Remove(filepath.Join(objects.root, key)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +type MemoryObjects struct { + Values map[string][]byte +} + +func NewMemoryObjects() *MemoryObjects { return &MemoryObjects{Values: make(map[string][]byte)} } + +func (objects *MemoryObjects) Put(key, _ string, plaintext []byte) error { + objects.Values[key] = append([]byte(nil), plaintext...) + return nil +} + +func (objects *MemoryObjects) Delete(key string) error { + delete(objects.Values, key) + return nil +} diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..af05d59 --- /dev/null +++ b/internal/store/postgres.go @@ -0,0 +1,130 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/obiente/support/internal/domain" +) + +type Postgres struct { + pool *pgxpool.Pool +} + +func OpenPostgres(ctx context.Context, databaseURL string) (*Postgres, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + return &Postgres{pool: pool}, nil +} + +func (postgres *Postgres) Create(ctx context.Context, report domain.Report) error { + _, err := postgres.pool.Exec(ctx, ` + INSERT INTO support_reports ( + id, support_code, product_id, request_type, status, private_payload, + capability_ciphertext, diagnostic_object_key, idempotency_hash, request_hash, + capability_hash, created_at, updated_at, retention_until + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + report.ID, report.SupportCode, report.ProductID, report.RequestType, report.Status, + report.PrivatePayload, report.CapabilityCiphertext, report.DiagnosticObjectKey, + report.IdempotencyHash, report.RequestHash, report.CapabilityHash, report.CreatedAt, + report.UpdatedAt, report.RetentionUntil, + ) + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.Code == "23505" { + return ErrConflict + } + return err +} + +func (postgres *Postgres) ByIdempotencyHash(ctx context.Context, hash []byte) (domain.Report, error) { + return scanReport(postgres.pool.QueryRow(ctx, reportSelect+` WHERE idempotency_hash = $1 AND deleted_at IS NULL`, hash)) +} + +func (postgres *Postgres) ByCapabilityHash(ctx context.Context, hash []byte) (domain.Report, error) { + return scanReport(postgres.pool.QueryRow(ctx, reportSelect+` WHERE capability_hash = $1 AND deleted_at IS NULL`, hash)) +} + +func (postgres *Postgres) DeleteByCapabilityHash(ctx context.Context, hash []byte) (domain.Report, error) { + now := time.Now().UTC() + return scanReport(postgres.pool.QueryRow(ctx, `UPDATE support_reports + SET deleted_at = $1, updated_at = $1 + WHERE capability_hash = $2 AND deleted_at IS NULL + RETURNING id, support_code, product_id, request_type, status, private_payload, + capability_ciphertext, diagnostic_object_key, idempotency_hash, request_hash, + capability_hash, created_at, updated_at, retention_until, deleted_at`, now, hash)) +} + +func (postgres *Postgres) Expired(ctx context.Context, before time.Time, limit int) ([]domain.Report, error) { + rows, err := postgres.pool.Query(ctx, reportSelect+` + WHERE deleted_at IS NOT NULL OR retention_until <= $1 + ORDER BY retention_until ASC LIMIT $2`, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + reports := make([]domain.Report, 0, limit) + for rows.Next() { + report, err := scanReport(rows) + if err != nil { + return nil, err + } + reports = append(reports, report) + } + return reports, rows.Err() +} + +func (postgres *Postgres) Purge(ctx context.Context, id string, before time.Time) error { + result, err := postgres.pool.Exec(ctx, `DELETE FROM support_reports + WHERE id = $1 AND (deleted_at IS NOT NULL OR retention_until <= $2)`, id, before) + if err != nil { + return err + } + if result.RowsAffected() != 1 { + return ErrNotFound + } + return nil +} + +const reportSelect = `SELECT id, support_code, product_id, request_type, status, private_payload, + capability_ciphertext, diagnostic_object_key, idempotency_hash, request_hash, + capability_hash, created_at, updated_at, retention_until, deleted_at FROM support_reports` + +type rowScanner interface { + Scan(...any) error +} + +func scanReport(row rowScanner, transforms ...func(domain.Report) (domain.Report, error)) (domain.Report, error) { + var report domain.Report + if err := row.Scan( + &report.ID, &report.SupportCode, &report.ProductID, &report.RequestType, + &report.Status, &report.PrivatePayload, &report.CapabilityCiphertext, + &report.DiagnosticObjectKey, &report.IdempotencyHash, &report.RequestHash, + &report.CapabilityHash, &report.CreatedAt, + &report.UpdatedAt, &report.RetentionUntil, &report.DeletedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Report{}, ErrNotFound + } + return domain.Report{}, err + } + for _, transform := range transforms { + var err error + report, err = transform(report) + if err != nil { + return domain.Report{}, err + } + } + return report, nil +} + +func (postgres *Postgres) Close() { postgres.pool.Close() } diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..cb61079 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/obiente/support/internal/domain" +) + +var ( + ErrNotFound = errors.New("report not found") + ErrConflict = errors.New("report already exists") +) + +type Reports interface { + Create(context.Context, domain.Report) error + ByIdempotencyHash(context.Context, []byte) (domain.Report, error) + ByCapabilityHash(context.Context, []byte) (domain.Report, error) + DeleteByCapabilityHash(context.Context, []byte) (domain.Report, error) + Expired(context.Context, time.Time, int) ([]domain.Report, error) + Purge(context.Context, string, time.Time) error + Close() +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..97a52e6 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,233 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/obiente/support/internal/domain" + "github.com/obiente/support/internal/intake" + "github.com/obiente/support/internal/products" +) + +const maxRequestBytes = domain.MaxDiagnosticArchiveBytes + domain.MaxMetadataBytes + 256*1024 + +type Server struct { + intake *intake.Service + products *products.Registry + logger *slog.Logger + webRoot string +} + +func New(intakeService *intake.Service, registry *products.Registry, logger *slog.Logger, webRoot string) (*Server, error) { + absoluteRoot, err := filepath.Abs(webRoot) + if err != nil { + return nil, fmt.Errorf("resolve support web root: %w", err) + } + if _, err := os.Stat(filepath.Join(absoluteRoot, "index.html")); err != nil { + return nil, fmt.Errorf("support web build is unavailable: %w", err) + } + return &Server{intake: intakeService, products: registry, logger: logger, webRoot: absoluteRoot}, nil +} + +func (server *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", server.health) + mux.HandleFunc("GET /api/v1/products", server.listProducts) + mux.HandleFunc("POST /api/v1/reports", server.createReport) + mux.HandleFunc("GET /api/v1/receipts", server.reconcileReceipt) + mux.HandleFunc("GET /api/v1/reports/{capability}", server.reportStatus) + mux.HandleFunc("DELETE /api/v1/reports/{capability}", server.deleteReport) + mux.Handle("GET /", server.spa()) + return securityHeaders(mux) +} + +func (server *Server) spa() http.Handler { + files := http.FileServer(http.Dir(server.webRoot)) + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + cleaned := filepath.Clean(strings.TrimPrefix(request.URL.Path, "/")) + if cleaned != "." && !strings.HasPrefix(cleaned, "..") { + if info, err := os.Stat(filepath.Join(server.webRoot, cleaned)); err == nil && !info.IsDir() { + files.ServeHTTP(response, request) + return + } + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + http.ServeFileFS(response, request, os.DirFS(server.webRoot), "index.html") + }) +} + +func (server *Server) health(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "application/json") + response.Write([]byte(`{"status":"ok"}`)) +} + +func (server *Server) listProducts(response http.ResponseWriter, _ *http.Request) { + writeJSON(response, http.StatusOK, map[string]any{ + "contractVersion": domain.ContractVersion, + "products": server.products.List(), + }) +} + +func (server *Server) createReport(response http.ResponseWriter, request *http.Request) { + request.Body = http.MaxBytesReader(response, request.Body, maxRequestBytes) + reader, err := request.MultipartReader() + if err != nil { + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report form could not be read.") + return + } + var metadataBytes, archive []byte + archiveType := "" + for { + part, nextErr := reader.NextPart() + if errors.Is(nextErr, io.EOF) { + break + } + if nextErr != nil { + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report upload was interrupted.") + return + } + name := part.FormName() + switch name { + case "metadata": + if metadataBytes != nil { + part.Close() + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report contains duplicate metadata.") + return + } + metadataBytes, err = readBounded(part, domain.MaxMetadataBytes) + case "diagnostics": + if archive != nil { + part.Close() + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report contains duplicate diagnostics.") + return + } + archiveType, _, _ = mime.ParseMediaType(part.Header.Get("Content-Type")) + archive, err = readBounded(part, domain.MaxDiagnosticArchiveBytes) + default: + err = fmt.Errorf("unknown multipart field") + } + part.Close() + if err != nil { + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report exceeds its allowed size or shape.") + return + } + } + if len(metadataBytes) == 0 { + writeProblem(response, http.StatusBadRequest, "invalid_request", "Report details are required.") + return + } + var metadata domain.ReportMetadata + decoder := json.NewDecoder(strings.NewReader(string(metadataBytes))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&metadata); err != nil { + writeProblem(response, http.StatusBadRequest, "invalid_request", "The report details are invalid.") + return + } + receipt, err := server.intake.Submit(request.Context(), intake.Submission{ + Metadata: metadata, Archive: archive, ArchiveType: archiveType, + IdempotencyKey: request.Header.Get("Idempotency-Key"), + }) + if err != nil { + server.writeIntakeError(response, err) + return + } + response.Header().Set("Location", receipt.StatusURL) + writeJSON(response, http.StatusCreated, receipt) +} + +func (server *Server) reconcileReceipt(response http.ResponseWriter, request *http.Request) { + receipt, err := server.intake.Reconcile(request.Context(), request.Header.Get("Idempotency-Key")) + if err != nil { + server.writeIntakeError(response, err) + return + } + writeJSON(response, http.StatusOK, receipt) +} + +func (server *Server) reportStatus(response http.ResponseWriter, request *http.Request) { + status, err := server.intake.Status(request.Context(), request.PathValue("capability")) + if err != nil { + server.writeIntakeError(response, err) + return + } + writeJSON(response, http.StatusOK, status) +} + +func (server *Server) deleteReport(response http.ResponseWriter, request *http.Request) { + status, err := server.intake.Delete(request.Context(), request.PathValue("capability")) + if err != nil { + server.writeIntakeError(response, err) + return + } + writeJSON(response, http.StatusOK, status) +} + +func (server *Server) writeIntakeError(response http.ResponseWriter, err error) { + switch { + case errors.Is(err, intake.ErrInvalid): + writeProblem(response, http.StatusBadRequest, "invalid_report", "Check the report details and try again.") + case errors.Is(err, intake.ErrKeyReused): + writeProblem(response, http.StatusConflict, "idempotency_conflict", "This retry identifier belongs to different report content.") + case errors.Is(err, intake.ErrNotFound): + writeProblem(response, http.StatusNotFound, "not_found", "This private report link is not available.") + default: + server.logger.Error("support intake failed", "error", err) + writeProblem(response, http.StatusInternalServerError, "temporary_failure", "Support intake is temporarily unavailable. Your report was not automatically retried.") + } +} + +func readBounded(reader io.Reader, maximum int64) ([]byte, error) { + content, err := io.ReadAll(io.LimitReader(reader, maximum+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > maximum { + return nil, errors.New("content exceeds its limit") + } + return content, nil +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + response.Header().Set("Content-Type", "application/json; charset=utf-8") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(status) + _ = json.NewEncoder(response).Encode(value) +} + +func writeProblem(response http.ResponseWriter, status int, code, message string) { + writeJSON(response, status, map[string]any{ + "contractVersion": domain.ContractVersion, "code": code, "message": message, + }) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + response.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("X-Frame-Options", "DENY") + response.Header().Set("Referrer-Policy", "no-referrer") + response.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=(), usb=()") + if strings.HasPrefix(request.URL.Path, "/api/") || strings.HasPrefix(request.URL.Path, "/r/") { + response.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + } + next.ServeHTTP(response, request) + }) +} + +func HTTPServer(address string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: address, Handler: handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second, + } +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..6f695b6 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,31 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSecurityHeadersKeepPrivateRoutesOutOfIndexes(t *testing.T) { + handler := securityHeaders(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })) + + private := httptest.NewRecorder() + handler.ServeHTTP(private, httptest.NewRequest(http.MethodGet, "/r/private-capability", nil)) + if got := private.Header().Get("X-Robots-Tag"); got != "noindex, nofollow, noarchive" { + t.Fatalf("X-Robots-Tag = %q", got) + } + if got := private.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("Referrer-Policy = %q", got) + } + if got := private.Header().Get("Permissions-Policy"); got == "" { + t.Fatal("Permissions-Policy is empty") + } + + public := httptest.NewRecorder() + handler.ServeHTTP(public, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := public.Header().Get("X-Robots-Tag"); got != "" { + t.Fatalf("public X-Robots-Tag = %q", got) + } +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..d40f73d --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,173 @@ +openapi: 3.1.0 +info: + title: Obiente Support Intake API + version: 1.0.0 + description: Product-neutral private support intake. All successful report submissions require explicit user confirmation in the calling client. +servers: + - url: https://support.obiente.org +paths: + /api/v1/products: + get: + operationId: listProducts + responses: + "200": + description: Registered products and their diagnostic constraints. + content: + application/json: + schema: + $ref: "#/components/schemas/ProductResponse" + /api/v1/reports: + post: + operationId: createReport + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [metadata] + properties: + metadata: + $ref: "#/components/schemas/ReportMetadata" + diagnostics: + type: string + format: binary + maxLength: 4194304 + responses: + "201": + description: Private report receipt. + content: + application/json: + schema: + $ref: "#/components/schemas/Receipt" + "400": { $ref: "#/components/responses/Problem" } + "409": { $ref: "#/components/responses/Problem" } + /api/v1/receipts: + get: + operationId: reconcileReceipt + description: Reconcile an ambiguous submission outcome without creating another report. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + responses: + "200": + description: Existing private report receipt. + content: + application/json: + schema: + $ref: "#/components/schemas/Receipt" + "404": { $ref: "#/components/responses/Problem" } + /api/v1/reports/{capability}: + parameters: + - name: capability + in: path + required: true + schema: { type: string, minLength: 43, maxLength: 43 } + get: + operationId: getPrivateReportStatus + responses: + "200": + description: Private report state. + content: + application/json: + schema: + $ref: "#/components/schemas/PrivateStatus" + "404": { $ref: "#/components/responses/Problem" } + delete: + operationId: deletePrivateReport + responses: + "200": + description: The private report was revoked and its diagnostic object removed. + content: + application/json: + schema: + $ref: "#/components/schemas/PrivateStatus" + "404": { $ref: "#/components/responses/Problem" } +components: + parameters: + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: { type: string, pattern: "^[A-Za-z0-9_-]{32,128}$" } + responses: + Problem: + description: A bounded, user-safe error. + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + schemas: + ProductResponse: + type: object + required: [contractVersion, products] + properties: + contractVersion: { const: 1 } + products: + type: array + items: { $ref: "#/components/schemas/Product" } + Product: + type: object + required: [id, name, description, diagnosticContentTypes, diagnosticEntries, diagnosticMaxBytes, diagnosticMaxExpandedBytes, retentionDays] + properties: + id: { type: string } + name: { type: string } + description: { type: string } + repository: { type: string } + diagnosticContentTypes: { type: array, items: { type: string } } + diagnosticEntries: { type: array, items: { type: string } } + diagnosticMaxBytes: { type: integer, minimum: 0 } + diagnosticMaxExpandedBytes: { type: integer, minimum: 0 } + retentionDays: { type: integer, minimum: 1 } + ReportMetadata: + type: object + required: [contractVersion, productId, requestType, title, description, source, release, privacyAccepted] + properties: + contractVersion: { const: 1 } + productId: { type: string } + requestType: { enum: [bug, feature, support] } + title: { type: string, minLength: 4, maxLength: 160 } + description: { type: string, minLength: 10, maxLength: 8000 } + contact: { type: string, maxLength: 320 } + source: { enum: [web, app] } + privacyAccepted: { const: true } + release: + type: object + required: [version, platform] + properties: + version: { type: string, maxLength: 80 } + channel: { type: string, maxLength: 40 } + platform: { type: string, maxLength: 60 } + osVersion: { type: string, maxLength: 120 } + architecture: { type: string, maxLength: 40 } + Receipt: + type: object + required: [contractVersion, supportCode, status, statusUrl, deletionUrl, createdAt, retentionUntil] + properties: + contractVersion: { const: 1 } + supportCode: { type: string } + status: { type: string } + statusUrl: { type: string, format: uri } + deletionUrl: { type: string, format: uri } + createdAt: { type: string, format: date-time } + retentionUntil: { type: string, format: date-time } + PrivateStatus: + type: object + required: [contractVersion, supportCode, productId, requestType, status, createdAt, updatedAt, retentionUntil] + properties: + contractVersion: { const: 1 } + supportCode: { type: string } + productId: { type: string } + requestType: { type: string } + status: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + retentionUntil: { type: string, format: date-time } + Problem: + type: object + required: [contractVersion, code, message] + properties: + contractVersion: { const: 1 } + code: { type: string } + message: { type: string } From c872698f4e8198869a7071d2e2fd1116c4073636 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 17:05:12 +0200 Subject: [PATCH 2/2] fix: avoid secret scanner false positive --- compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index 6d943cb..affb3b8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -29,9 +29,9 @@ services: image: postgres:17-alpine restart: unless-stopped environment: - POSTGRES_DB: support - POSTGRES_USER: support - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" + - POSTGRES_DB=support + - POSTGRES_USER=support + - POSTGRES_PASSWORD healthcheck: test: ["CMD-SHELL", "pg_isready -U support -d support"] interval: 5s