aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
committerbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
commit493746b14c1251a45b061d2e3edd9160c929d2b9 (patch)
tree1607cceb94c1aac1a17a01bb5c0d71b97342e892 /web
parentc041641634650c31e03c70dcad132fd94cb08e63 (diff)
downloadtidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.gz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.bz2
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.lz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.xz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.zst
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.zip
a basic ui and landing web interface for tidyindex.com
Diffstat (limited to '')
-rw-r--r--web/.gitignore8
-rw-r--r--web/api/.gitignore3
-rw-r--r--web/api/package.json20
-rw-r--r--web/api/src/index.ts20
-rw-r--r--web/api/tsconfig.json12
-rw-r--r--web/api/wrangler.toml31
-rw-r--r--web/core/package.json28
-rw-r--r--web/core/src/index.ts15
-rw-r--r--web/core/tsconfig.json10
-rw-r--r--web/package.json15
-rw-r--r--web/pnpm-workspace.yaml4
-rw-r--r--web/tsconfig.base.json27
-rw-r--r--web/ui/.env.example12
-rw-r--r--web/ui/.gitignore21
-rw-r--r--web/ui/README.md53
-rw-r--r--web/ui/package-lock.json2423
-rw-r--r--web/ui/package.json30
-rw-r--r--web/ui/scripts/seed.js191
-rw-r--r--web/ui/src/app.css1170
-rw-r--r--web/ui/src/app.d.ts16
-rw-r--r--web/ui/src/app.html42
-rw-r--r--web/ui/src/hooks.server.ts16
-rw-r--r--web/ui/src/lib/components/BrandMark.svelte7
-rw-r--r--web/ui/src/lib/components/Footer.svelte8
-rw-r--r--web/ui/src/lib/components/Toasts.svelte12
-rw-r--r--web/ui/src/lib/datasets.ts46
-rw-r--r--web/ui/src/lib/keys.ts24
-rw-r--r--web/ui/src/lib/plans.ts82
-rw-r--r--web/ui/src/lib/server/auth.ts153
-rw-r--r--web/ui/src/lib/server/db.ts233
-rw-r--r--web/ui/src/lib/server/keys.ts79
-rw-r--r--web/ui/src/lib/server/usage.ts33
-rw-r--r--web/ui/src/lib/stores/toasts.ts21
-rw-r--r--web/ui/src/routes/+layout.server.ts7
-rw-r--r--web/ui/src/routes/+layout.svelte9
-rw-r--r--web/ui/src/routes/+page.server.ts20
-rw-r--r--web/ui/src/routes/auth/callback/+server.ts14
-rw-r--r--web/ui/src/routes/auth/logout/+server.ts13
-rw-r--r--web/ui/src/routes/dashboard/+layout.server.ts12
-rw-r--r--web/ui/src/routes/dashboard/+layout.svelte90
-rw-r--r--web/ui/src/routes/dashboard/+page.server.ts7
-rw-r--r--web/ui/src/routes/dashboard/account/+page.server.ts107
-rw-r--r--web/ui/src/routes/dashboard/account/+page.svelte238
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.server.ts68
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.svelte220
-rw-r--r--web/ui/src/routes/dashboard/usage/+page.server.ts13
-rw-r--r--web/ui/src/routes/dashboard/usage/+page.svelte152
-rw-r--r--web/ui/static/favicon.svg6
-rw-r--r--web/ui/static/fonts/fraunces-latin.woff2bin0 -> 67304 bytes
-rw-r--r--web/ui/static/fonts/inter-latin.woff2bin0 -> 48256 bytes
-rw-r--r--web/ui/static/fonts/jetbrains-mono-latin.woff2bin0 -> 31432 bytes
-rw-r--r--web/ui/svelte.config.js15
-rw-r--r--web/ui/tsconfig.json18
-rw-r--r--web/ui/vite.config.js9
54 files changed, 5883 insertions, 0 deletions
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..32fd319
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,8 @@
+# Workspace-level Node state
+node_modules/
+.pnpm-store/
+
+# Editor
+*.swp
+.idea/
+.vscode/
diff --git a/web/api/.gitignore b/web/api/.gitignore
new file mode 100644
index 0000000..a934447
--- /dev/null
+++ b/web/api/.gitignore
@@ -0,0 +1,3 @@
+.wrangler/
+.dev.vars
+node_modules/
diff --git a/web/api/package.json b/web/api/package.json
new file mode 100644
index 0000000..108f52b
--- /dev/null
+++ b/web/api/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@tidyindex/api",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tidyindex/core": "workspace:*",
+ "hono": "^4.6.0"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20240925.0",
+ "typescript": "^5.6.0",
+ "wrangler": "^3.78.0"
+ }
+}
diff --git a/web/api/src/index.ts b/web/api/src/index.ts
new file mode 100644
index 0000000..f30fda1
--- /dev/null
+++ b/web/api/src/index.ts
@@ -0,0 +1,20 @@
+import { Hono } from 'hono';
+import { CORE_VERSION } from '@tidyindex/core';
+
+export interface Env {
+ // DATABASE_URL: string;
+ // KEY_CACHE: KVNamespace;
+ // ACCOUNT_METER: DurableObjectNamespace;
+}
+
+const app = new Hono<{ Bindings: Env }>();
+
+app.get('/', (c) =>
+ c.json({
+ name: 'tidyindex-api',
+ version: '0',
+ core: CORE_VERSION
+ })
+);
+
+export default app;
diff --git a/web/api/tsconfig.json b/web/api/tsconfig.json
new file mode 100644
index 0000000..7446044
--- /dev/null
+++ b/web/api/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/web/api/wrangler.toml b/web/api/wrangler.toml
new file mode 100644
index 0000000..29feff1
--- /dev/null
+++ b/web/api/wrangler.toml
@@ -0,0 +1,31 @@
+name = "tidyindex-api"
+main = "src/index.ts"
+compatibility_date = "2026-04-01"
+compatibility_flags = ["nodejs_compat"]
+
+# Deploy the public route. Uncomment once the zone + DNS are wired up.
+# workers_dev = false
+# routes = [
+# { pattern = "api.tidyindex.com/*", zone_name = "tidyindex.com" }
+# ]
+
+# Vars / secrets are loaded from .dev.vars locally and `wrangler secret put`
+# in production. DATABASE_URL points at Neon (or Postgres via Hyperdrive).
+# [vars]
+# (none yet)
+
+# KV namespace for hashed-key → account cache. Create with:
+# wrangler kv namespace create KEY_CACHE
+# [[kv_namespaces]]
+# binding = "KEY_CACHE"
+# id = "REPLACE_ME"
+
+# Per-account rate-limit + usage buffer Durable Object. Wired up once
+# the AccountMeter class lands.
+# [[durable_objects.bindings]]
+# name = "ACCOUNT_METER"
+# class_name = "AccountMeter"
+#
+# [[migrations]]
+# tag = "v1"
+# new_classes = ["AccountMeter"]
diff --git a/web/core/package.json b/web/core/package.json
new file mode 100644
index 0000000..ca4db41
--- /dev/null
+++ b/web/core/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@tidyindex/core",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./db": "./src/db/client.ts",
+ "./schema": "./src/db/schema.ts",
+ "./keys": "./src/keys.ts",
+ "./auth": "./src/auth.ts",
+ "./usage": "./src/usage.ts",
+ "./plans": "./src/plans.ts",
+ "./accounts": "./src/accounts.ts",
+ "./crypto": "./src/crypto.ts"
+ },
+ "scripts": {
+ "check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@neondatabase/serverless": "^0.9.5",
+ "drizzle-orm": "^0.33.0",
+ "jose": "^5.9.0"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.0"
+ }
+}
diff --git a/web/core/src/index.ts b/web/core/src/index.ts
new file mode 100644
index 0000000..5e965e9
--- /dev/null
+++ b/web/core/src/index.ts
@@ -0,0 +1,15 @@
+// @tidyindex/core — shared, platform-agnostic domain code for the
+// SvelteKit UI (web/ui) and the Hono API (web/api).
+//
+// Rules of the road:
+// - No imports from SvelteKit, Vite, Workers, Node-only modules, or
+// anything platform-specific. Pass platform values in as arguments.
+// - Use Web Crypto (./crypto), not node:crypto.
+// - No module-level state. Functions take a Db (or other context) as
+// their first argument.
+//
+// Real submodules will be added in subsequent passes (db, keys, auth,
+// usage, plans, accounts). For now this barrel just exists so the
+// workspace wires together.
+
+export const CORE_VERSION = '0.0.0';
diff --git a/web/core/tsconfig.json b/web/core/tsconfig.json
new file mode 100644
index 0000000..9717b38
--- /dev/null
+++ b/web/core/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "lib": ["ES2022", "DOM"],
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..39b2c06
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "tidyindex-web",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev:ui": "pnpm --filter @tidyindex/ui dev",
+ "dev:api": "pnpm --filter @tidyindex/api dev",
+ "build": "pnpm -r build",
+ "check": "pnpm -r check"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.0"
+ },
+ "packageManager": "pnpm@9.12.0"
+}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 0000000..0c4ae65
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+ - ui
+ - api
+ - core
diff --git a/web/tsconfig.base.json b/web/tsconfig.base.json
new file mode 100644
index 0000000..69c73ed
--- /dev/null
+++ b/web/tsconfig.base.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "types": [],
+
+ "strict": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "useUnknownInCatchVariables": true,
+
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+
+ "declaration": true,
+ "sourceMap": true
+ }
+}
diff --git a/web/ui/.env.example b/web/ui/.env.example
new file mode 100644
index 0000000..7c46cee
--- /dev/null
+++ b/web/ui/.env.example
@@ -0,0 +1,12 @@
+# Copy to .env and fill in.
+#
+# Required. Sign the session cookie with something long and random.
+# Generate one with: openssl rand -base64 48
+JWT_SECRET=rUEqmzPFEzTx1SS1AC2k8XcXaQwoy6sDmMARjpYFlq6BYA0Dxw1PqcY7S5mVpTWi
+
+# Optional. Where the SQLite file lives. Defaults to ./data/dashboard.db
+DATABASE_PATH=./data/dashboard.db
+
+# Optional. Base URL the magic-link emails point back at.
+# Used when stubbed emails are logged to the console.
+PUBLIC_BASE_URL=http://localhost:5174
diff --git a/web/ui/.gitignore b/web/ui/.gitignore
new file mode 100644
index 0000000..22606ff
--- /dev/null
+++ b/web/ui/.gitignore
@@ -0,0 +1,21 @@
+node_modules
+.DS_Store
+.env
+.env.*
+!.env.example
+
+# SvelteKit build output
+.svelte-kit
+build
+
+# SQLite data
+data/
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+
+# Editor
+*.swp
+.idea
+.vscode
diff --git a/web/ui/README.md b/web/ui/README.md
new file mode 100644
index 0000000..f00e22c
--- /dev/null
+++ b/web/ui/README.md
@@ -0,0 +1,53 @@
+# Tidy Index — Dashboard
+
+A minimal SvelteKit dashboard for managing Tidy Index API keys, plans, and
+account info. Designed to share a visual system with the marketing site in
+`../landing/`.
+
+## Quick start
+
+```bash
+npm install
+cp .env.example .env
+# Generate and paste a JWT_SECRET:
+openssl rand -base64 48
+npm run seed # optional — populates fake usage data
+npm run dev
+```
+
+Dev server runs on http://localhost:5174.
+
+## Stack
+
+- SvelteKit 2 + Svelte 5 (runes)
+- better-sqlite3 (file at `./data/dashboard.db`)
+- jsonwebtoken (HS256, httpOnly cookie session)
+- Plain CSS matching the landing page design system
+
+## Auth model
+
+No passwords, no third-party auth, no required email.
+
+- **Anonymous:** click "Skip — just give me an API key" on `/`. Server
+ creates an account with `email = NULL`, sets a signed session cookie,
+ drops you on the dashboard.
+- **Magic link:** enter an email on `/`. If an account exists for that
+ email we re-use it, otherwise we create a new one. We generate a
+ single-use token valid for 15 minutes, write it to `magic_links`, and
+ log the resulting URL to the server console (email sending is stubbed).
+
+Adding an email to an existing anonymous account on `/dashboard/account`
+will merge into any other account already using that email — all keys and
+usage events move over, and the anonymous account is deleted.
+
+## Data model
+
+See `src/lib/server/db.ts` for the schema. Four tables:
+
+- `accounts` — id, email (nullable, unique), plan, created_at
+- `api_keys` — id, account_id, key_hash, key_prefix, name, scopes, active, timestamps
+- `magic_links` — id, account_id, token, expires_at, used
+- `usage_events` — id, api_key_id, dataset, timestamp
+
+The `seed` script populates `usage_events` with a few weeks of fake traffic
+against the first account in the DB so the Usage tab has something to show.
diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json
new file mode 100644
index 0000000..d88dc61
--- /dev/null
+++ b/web/ui/package-lock.json
@@ -0,0 +1,2423 @@
+{
+ "name": "tidyindex-web",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tidyindex-web",
+ "version": "0.0.1",
+ "dependencies": {
+ "better-sqlite3": "^11.3.0",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "^5.2.0",
+ "@sveltejs/kit": "^2.8.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "@types/better-sqlite3": "^7.6.11",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^22.7.0",
+ "svelte": "^5.1.0",
+ "svelte-check": "^4.0.0",
+ "tslib": "^2.8.0",
+ "typescript": "^5.6.0",
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-commonjs": {
+ "version": "29.0.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz",
+ "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "commondir": "^1.0.1",
+ "estree-walker": "^2.0.2",
+ "fdir": "^6.2.0",
+ "is-reference": "1.2.1",
+ "magic-string": "^0.30.3",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=16.0.0 || 14 >= 14.17"
+ },
+ "peerDependencies": {
+ "rollup": "^2.68.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-json": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
+ "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
+ "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "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/@sveltejs/acorn-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-node": {
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz",
+ "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/plugin-commonjs": "^29.0.0",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.0",
+ "rollup": "^4.59.0"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.4.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.57.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.0.tgz",
+ "integrity": "sha512-TMiqCTy9ZW4KBHvmTgeWU/hF6jcFpeMgR+9ekE06uhhGnbUZ7wpIY6l1Uk4ThRzlWYJnCVfzmtVNaHaDjaSiSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "set-cookie-parser": "^3.0.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3 || ^6.0.0",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
+ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
+ "debug": "^4.3.7",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.12",
+ "vitefu": "^1.0.3"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
+ "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
+ "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
+ "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
+ "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@typescript-eslint/types": "^8.2.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/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/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
+ "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+ "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
+ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.55.2",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.2.tgz",
+ "integrity": "sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.4",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz",
+ "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte/node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/ui/package.json b/web/ui/package.json
new file mode 100644
index 0000000..8725812
--- /dev/null
+++ b/web/ui/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@tidyindex/ui",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "seed": "node --env-file=.env scripts/seed.js"
+ },
+ "dependencies": {
+ "better-sqlite3": "^11.3.0",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "^5.2.0",
+ "@sveltejs/kit": "^2.8.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "@types/better-sqlite3": "^7.6.11",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^22.7.0",
+ "svelte": "^5.1.0",
+ "svelte-check": "^4.0.0",
+ "tslib": "^2.8.0",
+ "typescript": "^5.6.0",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/web/ui/scripts/seed.js b/web/ui/scripts/seed.js
new file mode 100644
index 0000000..f4d27c2
--- /dev/null
+++ b/web/ui/scripts/seed.js
@@ -0,0 +1,191 @@
+/**
+ * Populate the dashboard SQLite database with fake usage data against
+ * the first account in the DB so the Usage tab isn't empty.
+ *
+ * Run with: `npm run seed` (which uses node --env-file=.env).
+ *
+ * If there's no account yet, we create an anonymous one and also mint
+ * two keys so the dashboard has something to show when you first log in.
+ */
+
+import Database from 'better-sqlite3';
+import { mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { createHash, randomBytes, randomUUID } from 'node:crypto';
+
+const DB_PATH = resolve(process.env.DATABASE_PATH || './data/dashboard.db');
+mkdirSync(dirname(DB_PATH), { recursive: true });
+
+const db = new Database(DB_PATH);
+db.pragma('journal_mode = WAL');
+db.pragma('foreign_keys = ON');
+
+// Make sure the schema exists (same as src/lib/server/db.ts).
+db.exec(`
+ CREATE TABLE IF NOT EXISTS accounts (
+ id TEXT PRIMARY KEY,
+ email TEXT UNIQUE,
+ plan TEXT NOT NULL DEFAULT 'free',
+ created_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS api_keys (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ key_hash TEXT NOT NULL UNIQUE,
+ key_prefix TEXT NOT NULL,
+ name TEXT NOT NULL,
+ scopes TEXT NOT NULL DEFAULT '["*"]',
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id);
+ CREATE TABLE IF NOT EXISTS magic_links (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ used INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token);
+ CREATE TABLE IF NOT EXISTS usage_events (
+ id TEXT PRIMARY KEY,
+ api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
+ dataset TEXT NOT NULL,
+ timestamp INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_events(api_key_id);
+ CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(timestamp);
+ CREATE INDEX IF NOT EXISTS idx_usage_dataset ON usage_events(dataset);
+`);
+
+const now = Date.now();
+
+const ALPHABET =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+function mintKey() {
+ const bytes = randomBytes(32);
+ let random = '';
+ for (let i = 0; i < 32; i++) {
+ random += ALPHABET[bytes[i] % ALPHABET.length];
+ }
+ const key = `ti_${random}`;
+ const hash = createHash('sha256').update(key).digest('hex');
+ const prefix = key.slice(0, 8);
+ return { key, hash, prefix };
+}
+
+// --- find or create an account ---
+
+/** @typedef {{ id: string, plan: string }} AccountRow */
+/** @typedef {{ id: string, name: string }} KeyRow */
+
+let account = /** @type {AccountRow | undefined} */ (
+ db
+ .prepare(`SELECT id, plan FROM accounts ORDER BY created_at ASC LIMIT 1`)
+ .get()
+);
+
+if (!account) {
+ const id = randomUUID();
+ db.prepare(
+ `INSERT INTO accounts (id, email, plan, created_at) VALUES (?, NULL, 'dev', ?)`
+ ).run(id, now - 1000 * 60 * 60 * 24 * 14); // created 2 weeks ago
+ account = { id, plan: 'dev' };
+ console.log(`created demo account ${id}`);
+} else {
+ console.log(`seeding against existing account ${account.id}`);
+}
+
+// --- make sure there are at least two active keys ---
+
+const existingKeys = /** @type {KeyRow[]} */ (
+ db
+ .prepare(`SELECT id, name FROM api_keys WHERE account_id = ? AND active = 1`)
+ .all(account.id)
+);
+
+/** @type {KeyRow[]} */
+const keyRows =
+ existingKeys.length >= 2
+ ? existingKeys
+ : (() => {
+ /** @type {KeyRow[]} */
+ const created = [];
+ for (const name of ['production ingest', 'research notebook']) {
+ const { hash, prefix } = mintKey();
+ const id = randomUUID();
+ db.prepare(
+ `INSERT INTO api_keys
+ (id, account_id, key_hash, key_prefix, name, scopes, active, created_at)
+ VALUES (?, ?, ?, ?, ?, '["*"]', 1, ?)`
+ ).run(id, account.id, hash, prefix, name, now - 1000 * 60 * 60 * 24 * 10);
+ created.push({ id, name });
+ }
+ return [...existingKeys, ...created];
+ })();
+
+// --- wipe usage events we seeded previously and re-generate ---
+
+db.prepare(
+ `DELETE FROM usage_events WHERE api_key_id IN
+ (SELECT id FROM api_keys WHERE account_id = ?)`
+).run(account.id);
+
+// Generate roughly 2,400 events distributed over the past 30 days, heavily
+// weighted toward irs-990 and sec-edgar (the flagship datasets).
+const weights = {
+ 'irs-990': 28,
+ 'sec-edgar': 22,
+ 'sec-13f': 14,
+ 'fec-contributions': 10,
+ 'nih-reporter': 8,
+ 'pacer': 6,
+ 'fda-faers': 4,
+ 'osha': 3,
+ 'usaspending': 3,
+ 'cfpb-complaints': 2
+};
+const weighted = /** @type {string[]} */ ([]);
+for (const [slug, w] of Object.entries(weights)) {
+ for (let i = 0; i < w; i++) weighted.push(slug);
+}
+
+const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
+const insertEvent = db.prepare(
+ `INSERT INTO usage_events (id, api_key_id, dataset, timestamp) VALUES (?, ?, ?, ?)`
+);
+
+const TOTAL = 2400;
+const tx = db.transaction(() => {
+ for (let i = 0; i < TOTAL; i++) {
+ const dataset = weighted[Math.floor(Math.random() * weighted.length)];
+ const keyRow = keyRows[Math.floor(Math.random() * keyRows.length)];
+ const age = Math.floor(Math.random() * THIRTY_DAYS);
+ insertEvent.run(randomUUID(), keyRow.id, dataset, now - age);
+ }
+});
+tx();
+
+// Update last_used_at on each key to the most recent event timestamp.
+for (const row of keyRows) {
+ const latest = /** @type {{ ts: number | null } | undefined} */ (
+ db
+ .prepare(
+ `SELECT MAX(timestamp) AS ts FROM usage_events WHERE api_key_id = ?`
+ )
+ .get(row.id)
+ );
+ if (latest?.ts) {
+ db.prepare(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`).run(
+ latest.ts,
+ row.id
+ );
+ }
+}
+
+console.log(`seeded ${TOTAL} usage events against ${keyRows.length} keys`);
+console.log(`db: ${DB_PATH}`);
+db.close();
diff --git a/web/ui/src/app.css b/web/ui/src/app.css
new file mode 100644
index 0000000..db2f519
--- /dev/null
+++ b/web/ui/src/app.css
@@ -0,0 +1,1170 @@
+/* ----------------------------------------------------------------
+ Tidy Index — dashboard styles
+
+ Shares the exact design tokens from the landing page. Adds
+ dashboard-specific components (tabs, cards, badges, toasts,
+ form inputs, danger variants).
+---------------------------------------------------------------- */
+
+/* ---------- self-hosted webfonts ---------- */
+
+@font-face {
+ font-family: 'Fraunces';
+ font-style: normal;
+ font-weight: 300 600;
+ font-display: swap;
+ src: url('/fonts/fraunces-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 400 600;
+ font-display: swap;
+ src: url('/fonts/inter-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400 500;
+ font-display: swap;
+ src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+:root {
+ --c-bg: #ffffff;
+ --c-bg-soft: #f7f9fc;
+ --c-bg-tint: #eef4fb;
+
+ --c-rule: #e3ecf5;
+ --c-rule-strong: #cbd6e6;
+
+ --c-ink: #0b1f3a;
+ --c-ink-soft: #3b4f6b;
+ --c-ink-mute: #6b7c93;
+
+ --c-blue: #2563eb;
+ --c-blue-deep: #1d4ed8;
+ --c-blue-soft: #dbeafe;
+ --c-blue-tint: #eef4ff;
+
+ --c-danger: #dc2626;
+ --c-danger-deep: #b91c1c;
+ --c-danger-soft: #fee2e2;
+ --c-danger-tint: #fef2f2;
+
+ --c-success: #059669;
+ --c-success-soft: #d1fae5;
+
+ --c-warn: #b45309;
+ --c-warn-soft: #fef3c7;
+
+ --shadow-sm: 0 1px 2px rgba(15, 38, 73, 0.04);
+ --shadow-md: 0 8px 28px -10px rgba(37, 99, 235, 0.18),
+ 0 2px 6px rgba(15, 38, 73, 0.05);
+ --shadow-lg: 0 24px 60px -20px rgba(37, 99, 235, 0.25),
+ 0 6px 16px rgba(15, 38, 73, 0.06);
+
+ --radius-sm: 6px;
+ --radius-md: 12px;
+ --radius-lg: 18px;
+
+ --font-serif: 'Fraunces', 'Iowan Old Style', Georgia, 'Times New Roman', serif;
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ Helvetica, Arial, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo,
+ Consolas, monospace;
+
+ --max-w: 960px;
+ --max-w-wide: 1080px;
+ --gutter: clamp(20px, 4vw, 40px);
+}
+
+* { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ font-family: var(--font-sans);
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--c-ink);
+ background: var(--c-bg);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+a {
+ color: var(--c-blue);
+ text-decoration: none;
+ transition: color 160ms ease;
+}
+a:hover { color: var(--c-blue-deep); }
+
+img, svg { display: block; max-width: 100%; }
+
+::selection {
+ background: var(--c-blue-soft);
+ color: var(--c-ink);
+}
+
+/* ---------- layout ---------- */
+
+.container {
+ width: 100%;
+ max-width: var(--max-w);
+ margin: 0 auto;
+ padding: 0 var(--gutter);
+}
+
+/* ---------- typographic primitives (match landing) ---------- */
+
+.serif {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 144, 'SOFT' 30;
+ font-weight: 400;
+ letter-spacing: -0.018em;
+ color: var(--c-ink);
+}
+
+.serif-italic {
+ font-style: italic;
+ font-variation-settings: 'opsz' 144, 'SOFT' 100;
+}
+
+.section-marker {
+ margin: 0 0 18px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ color: var(--c-blue);
+ text-transform: lowercase;
+}
+
+.page-title {
+ margin: 0 0 8px;
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 144, 'SOFT' 30;
+ font-weight: 400;
+ font-size: clamp(32px, 4.6vw, 44px);
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ color: var(--c-ink);
+}
+
+.page-subtitle {
+ margin: 0 0 32px;
+ font-size: 16px;
+ color: var(--c-ink-soft);
+ max-width: 60ch;
+}
+
+/* ---------- dashboard app shell ---------- */
+
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: saturate(180%) blur(14px);
+ -webkit-backdrop-filter: saturate(180%) blur(14px);
+ border-bottom: 1px solid var(--c-rule);
+}
+
+.app-header-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 66px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 24;
+ font-weight: 500;
+ font-size: 19px;
+ letter-spacing: -0.005em;
+ color: var(--c-ink);
+}
+.brand:hover { color: var(--c-blue); }
+
+.brand-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ color: var(--c-blue);
+}
+.brand-mark svg { width: 22px; height: 22px; }
+
+.app-header-meta {
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--c-ink-mute);
+}
+
+.app-header-meta .plan-chip {
+ padding: 5px 10px;
+ border: 1px solid var(--c-rule);
+ border-radius: 999px;
+ color: var(--c-ink-soft);
+ text-transform: lowercase;
+ letter-spacing: 0.04em;
+}
+
+.app-header-meta .plan-chip strong {
+ color: var(--c-blue);
+ font-weight: 500;
+}
+
+.app-header-meta .pending-chip {
+ padding: 5px 10px;
+ border: 1px solid #fde68a;
+ background: var(--c-warn-soft);
+ border-radius: 999px;
+ color: var(--c-warn);
+ letter-spacing: 0.02em;
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.app-header-meta a {
+ color: var(--c-ink-soft);
+ border-bottom: 1px solid transparent;
+}
+.app-header-meta a:hover {
+ color: var(--c-blue);
+ border-color: var(--c-blue);
+}
+
+/* ---------- tab nav ---------- */
+
+.tabs {
+ border-bottom: 1px solid var(--c-rule);
+ margin-bottom: 48px;
+}
+
+.tabs-inner {
+ display: flex;
+ gap: 4px;
+ overflow-x: auto;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+.tabs-inner::-webkit-scrollbar { display: none; }
+
+.tab {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 18px 18px 16px;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--c-ink-mute);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ white-space: nowrap;
+ transition: color 160ms ease, border-color 160ms ease;
+}
+
+.tab:hover { color: var(--c-ink-soft); }
+
+.tab.active {
+ color: var(--c-ink);
+ border-bottom-color: var(--c-blue);
+}
+
+.tab-num {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--c-blue);
+ font-weight: 500;
+}
+
+/* ---------- main content ---------- */
+
+main.app-main {
+ padding: 56px 0 120px;
+ min-height: calc(100vh - 66px - 64px);
+}
+
+.entry-main {
+ padding: 0;
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ---------- cards ---------- */
+
+.card {
+ background: #fff;
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-md);
+ padding: 26px 28px;
+ box-shadow: var(--shadow-sm);
+ transition: border-color 180ms ease, box-shadow 180ms ease;
+}
+
+.card + .card { margin-top: 14px; }
+
+.card.card-feature {
+ border-color: var(--c-blue-soft);
+ background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 140%);
+ box-shadow: var(--shadow-md);
+}
+
+.card.card-accent {
+ border-left: 3px solid var(--c-blue);
+}
+
+.card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 10px;
+}
+
+.card-title {
+ margin: 0;
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 36, 'SOFT' 30;
+ font-weight: 500;
+ font-size: 19px;
+ line-height: 1.2;
+ letter-spacing: -0.005em;
+ color: var(--c-ink);
+}
+
+.card-sub {
+ margin: 0;
+ font-size: 14px;
+ color: var(--c-ink-mute);
+ line-height: 1.55;
+}
+
+.card-body { font-size: 15px; color: var(--c-ink-soft); }
+
+.card-footer {
+ margin-top: 18px;
+ padding-top: 16px;
+ border-top: 1px solid var(--c-rule);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--c-ink-mute);
+}
+
+/* ---------- buttons ---------- */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ padding: 10px 18px;
+ border-radius: 999px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: all 180ms ease;
+ letter-spacing: -0.005em;
+ text-decoration: none;
+ background: transparent;
+ color: inherit;
+}
+
+.btn svg { width: 15px; height: 15px; }
+
+.btn-primary {
+ background: var(--c-ink);
+ color: #fff;
+ box-shadow: 0 6px 18px -8px rgba(11, 31, 58, 0.5);
+}
+.btn-primary:hover {
+ background: var(--c-blue);
+ color: #fff;
+ transform: translateY(-1px);
+ box-shadow: 0 10px 22px -8px rgba(37, 99, 235, 0.5);
+}
+
+.btn-accent {
+ background: var(--c-blue);
+ color: #fff;
+ box-shadow: 0 6px 18px -8px rgba(37, 99, 235, 0.5);
+}
+.btn-accent:hover {
+ background: var(--c-blue-deep);
+ color: #fff;
+ transform: translateY(-1px);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--c-ink);
+ border-color: var(--c-rule);
+}
+.btn-ghost:hover {
+ background: var(--c-bg-soft);
+ border-color: var(--c-blue-soft);
+ color: var(--c-blue-deep);
+}
+
+.btn-danger {
+ background: transparent;
+ color: var(--c-danger);
+ border-color: var(--c-danger-soft);
+}
+.btn-danger:hover {
+ background: var(--c-danger-tint);
+ border-color: var(--c-danger);
+ color: var(--c-danger-deep);
+}
+
+.btn-sm {
+ padding: 7px 14px;
+ font-size: 13px;
+}
+.btn-lg {
+ padding: 14px 26px;
+ font-size: 15px;
+}
+
+.btn-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--c-ink-soft);
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--c-rule-strong);
+}
+.btn-link:hover {
+ color: var(--c-blue);
+ border-color: var(--c-blue);
+}
+
+/* ---------- form inputs ---------- */
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 18px;
+}
+
+.field-label {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: lowercase;
+ color: var(--c-ink-mute);
+}
+
+.input, .select, .textarea {
+ width: 100%;
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--c-ink);
+ background: #fff;
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-sm);
+ padding: 11px 14px;
+ transition: border-color 160ms ease, box-shadow 160ms ease;
+}
+.input:focus, .select:focus, .textarea:focus {
+ outline: none;
+ border-color: var(--c-blue);
+ box-shadow: 0 0 0 3px var(--c-blue-tint);
+}
+
+.input.mono {
+ font-family: var(--font-mono);
+ font-size: 13px;
+}
+
+.input.input-lg {
+ padding: 14px 18px;
+ font-size: 16px;
+}
+
+.help {
+ font-size: 13px;
+ color: var(--c-ink-mute);
+ line-height: 1.5;
+}
+
+/* ---------- chips / scope toggles ---------- */
+
+.chip-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.chip {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 7px 13px;
+ border-radius: 999px;
+ border: 1px solid var(--c-rule);
+ background: #fff;
+ color: var(--c-ink-soft);
+ cursor: pointer;
+ transition: all 140ms ease;
+ user-select: none;
+}
+.chip:hover {
+ border-color: var(--c-blue-soft);
+ color: var(--c-ink);
+}
+.chip.active {
+ background: var(--c-blue-tint);
+ border-color: var(--c-blue);
+ color: var(--c-blue-deep);
+}
+
+.chip-all {
+ font-weight: 500;
+}
+
+/* ---------- badges ---------- */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 4px 9px;
+ border-radius: 999px;
+ background: var(--c-blue-tint);
+ color: var(--c-blue-deep);
+ border: 1px solid var(--c-blue-soft);
+}
+
+.badge-success {
+ background: var(--c-success-soft);
+ color: var(--c-success);
+ border-color: #a7f3d0;
+}
+
+.badge-muted {
+ background: var(--c-bg-soft);
+ color: var(--c-ink-mute);
+ border-color: var(--c-rule);
+}
+
+.badge-danger {
+ background: var(--c-danger-soft);
+ color: var(--c-danger-deep);
+ border-color: #fecaca;
+}
+
+.badge-warn {
+ background: var(--c-warn-soft);
+ color: var(--c-warn);
+ border-color: #fde68a;
+}
+
+/* ---------- banner ---------- */
+
+.banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 14px 20px;
+ background: var(--c-warn-soft);
+ border: 1px solid #fde68a;
+ border-radius: var(--radius-sm);
+ font-size: 14px;
+ color: var(--c-warn);
+ margin-bottom: 28px;
+}
+.banner strong { color: #78350f; font-weight: 500; }
+
+/* ---------- toasts ---------- */
+
+.toast-container {
+ position: fixed;
+ top: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ pointer-events: none;
+}
+
+.toast {
+ pointer-events: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ background: #fff;
+ border: 1px solid var(--c-rule-strong);
+ border-radius: 999px;
+ padding: 11px 22px;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ color: var(--c-ink);
+ box-shadow: var(--shadow-md);
+ animation: toast-in 220ms ease-out;
+}
+
+.toast.toast-success {
+ border-color: #a7f3d0;
+ background: linear-gradient(180deg, #ffffff 0%, var(--c-success-soft) 180%);
+}
+.toast.toast-success .toast-dot { background: var(--c-success); }
+
+.toast.toast-error {
+ border-color: #fecaca;
+ background: linear-gradient(180deg, #ffffff 0%, var(--c-danger-soft) 180%);
+}
+.toast.toast-error .toast-dot { background: var(--c-danger); }
+
+.toast-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--c-blue);
+}
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ---------- key reveal banner ---------- */
+
+.key-reveal {
+ background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 140%);
+ border: 1px solid var(--c-blue-soft);
+ border-left: 3px solid var(--c-blue);
+ border-radius: var(--radius-md);
+ padding: 22px 26px;
+ margin-bottom: 24px;
+ box-shadow: var(--shadow-md);
+}
+
+.key-reveal-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.key-reveal-head strong {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 36, 'SOFT' 30;
+ font-size: 17px;
+ font-weight: 500;
+ color: var(--c-ink);
+}
+
+.key-reveal-value {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 18px;
+ background: #fff;
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--c-ink);
+ word-break: break-all;
+}
+
+.key-reveal-warn {
+ margin: 14px 0 0;
+ font-size: 13px;
+ color: var(--c-warn);
+}
+
+/* ---------- key list ---------- */
+
+.key-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ flex-wrap: wrap;
+}
+
+.key-row-main {
+ flex: 1;
+ min-width: 240px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.key-name {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 36, 'SOFT' 30;
+ font-weight: 500;
+ font-size: 18px;
+ color: var(--c-ink);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.key-mask {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--c-ink-soft);
+ letter-spacing: 0.02em;
+}
+
+.key-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--c-ink-mute);
+ margin-top: 6px;
+}
+
+.key-meta .meta-label {
+ color: var(--c-blue);
+ text-transform: lowercase;
+ margin-right: 4px;
+}
+
+.scope-summary {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--c-ink-mute);
+}
+
+.scope-summary code {
+ background: var(--c-bg-soft);
+ padding: 2px 6px;
+ border-radius: 4px;
+ margin-right: 4px;
+}
+
+/* ---------- plan grid ---------- */
+
+.plan-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 18px;
+ margin-top: 8px;
+}
+
+.plan-card {
+ position: relative;
+ background: #fff;
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-md);
+ padding: 28px 28px 24px;
+ box-shadow: var(--shadow-sm);
+ transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
+ display: flex;
+ flex-direction: column;
+}
+.plan-card:hover {
+ border-color: var(--c-blue-soft);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.plan-card.current {
+ border-color: var(--c-blue);
+ box-shadow: 0 10px 30px -12px rgba(37, 99, 235, 0.25);
+ background: linear-gradient(180deg, #ffffff 0%, var(--c-blue-tint) 160%);
+}
+
+.plan-card .badge {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+}
+
+.plan-name {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 36, 'SOFT' 30;
+ font-weight: 500;
+ font-size: 22px;
+ color: var(--c-ink);
+ margin: 0 0 4px;
+}
+
+.plan-price {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 144, 'SOFT' 30;
+ font-size: 42px;
+ font-weight: 400;
+ line-height: 1;
+ color: var(--c-ink);
+ margin: 8px 0 14px;
+}
+.plan-price small {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--c-ink-mute);
+}
+
+.plan-features {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 22px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.plan-features li {
+ font-size: 14px;
+ color: var(--c-ink-soft);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.plan-features li::before {
+ content: "·";
+ color: var(--c-blue);
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 1;
+ margin-top: 2px;
+}
+
+.plan-cta {
+ margin-top: auto;
+}
+
+/* ---------- usage ---------- */
+
+.usage-summary {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 24px;
+ align-items: center;
+ margin-bottom: 14px;
+}
+
+.usage-count {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 144, 'SOFT' 30;
+ font-size: 44px;
+ line-height: 1;
+ font-weight: 400;
+ color: var(--c-ink);
+}
+.usage-count small {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 400;
+ color: var(--c-ink-mute);
+}
+
+.usage-bar {
+ width: 100%;
+ height: 6px;
+ background: var(--c-bg-soft);
+ border: 1px solid var(--c-rule);
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 18px;
+}
+
+.usage-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--c-blue) 0%, #60a5fa 100%);
+ border-radius: 999px;
+ transition: width 300ms ease;
+}
+
+.usage-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+ margin-top: 12px;
+}
+
+.usage-table th {
+ text-align: left;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: lowercase;
+ color: var(--c-ink-mute);
+ padding: 12px 0;
+ border-bottom: 1px solid var(--c-rule);
+ font-weight: 500;
+}
+
+.usage-table td {
+ padding: 14px 0;
+ border-bottom: 1px solid var(--c-rule);
+ color: var(--c-ink-soft);
+}
+.usage-table tr:last-child td { border-bottom: 0; }
+.usage-table td:first-child {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--c-ink);
+}
+.usage-table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--c-ink);
+}
+
+/* ---------- entry / landing page of dashboard ---------- */
+
+.entry-card {
+ width: 100%;
+ max-width: 440px;
+ background: #fff;
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-lg);
+ padding: 48px 44px 40px;
+ box-shadow: var(--shadow-md);
+ text-align: left;
+}
+
+.entry-brand {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.22em;
+ color: var(--c-blue);
+ margin-bottom: 16px;
+}
+
+.entry-tagline {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 144, 'SOFT' 30;
+ font-weight: 400;
+ font-size: 28px;
+ line-height: 1.15;
+ letter-spacing: -0.015em;
+ color: var(--c-ink);
+ margin: 0 0 32px;
+}
+
+.entry-tagline em {
+ font-style: italic;
+ font-variation-settings: 'opsz' 144, 'SOFT' 100;
+ color: var(--c-blue);
+}
+
+.entry-divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 24px 0;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--c-ink-mute);
+ text-transform: lowercase;
+ letter-spacing: 0.06em;
+}
+.entry-divider::before,
+.entry-divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--c-rule);
+}
+
+.entry-magic-note {
+ margin: 16px 0 0;
+ padding: 14px 16px;
+ background: var(--c-blue-tint);
+ border: 1px solid var(--c-blue-soft);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--c-ink-soft);
+ word-break: break-all;
+}
+.entry-magic-note strong {
+ display: block;
+ color: var(--c-blue);
+ text-transform: lowercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 6px;
+ font-weight: 500;
+}
+.entry-magic-note a { color: var(--c-blue-deep); }
+
+/* ---------- footer ---------- */
+
+.app-footer {
+ border-top: 1px solid var(--c-rule);
+ background: var(--c-bg);
+ padding: 28px 0;
+ margin-top: auto;
+}
+
+.app-footer-inner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 18px;
+ flex-wrap: wrap;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--c-ink-mute);
+}
+
+.app-footer-line {
+ margin: 0;
+ letter-spacing: 0.02em;
+}
+
+.app-footer-line a {
+ color: var(--c-ink-soft);
+ border-bottom: 1px solid var(--c-rule-strong);
+ padding-bottom: 1px;
+ transition: color 160ms ease, border-color 160ms ease;
+}
+.app-footer-line a:hover {
+ color: var(--c-blue);
+ border-color: var(--c-blue);
+}
+
+/* ---------- inline flex helpers ---------- */
+
+.row { display: flex; align-items: center; gap: 12px; }
+.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.row-wrap { flex-wrap: wrap; }
+.col { display: flex; flex-direction: column; gap: 12px; }
+
+.stack-sm > * + * { margin-top: 8px; }
+.stack > * + * { margin-top: 14px; }
+.stack-lg > * + * { margin-top: 22px; }
+
+.mt-0 { margin-top: 0; }
+.mt-8 { margin-top: 8px; }
+.mt-16 { margin-top: 16px; }
+.mt-24 { margin-top: 24px; }
+.mt-40 { margin-top: 40px; }
+
+.mb-0 { margin-bottom: 0; }
+.mb-8 { margin-bottom: 8px; }
+.mb-16 { margin-bottom: 16px; }
+.mb-24 { margin-bottom: 24px; }
+
+.text-mute { color: var(--c-ink-mute); }
+.text-soft { color: var(--c-ink-soft); }
+.text-mono { font-family: var(--font-mono); font-size: 13px; }
+
+/* ---------- code blocks ---------- */
+
+code, pre {
+ font-family: var(--font-mono);
+}
+
+.code-block {
+ background: var(--c-bg-soft);
+ border: 1px solid var(--c-rule);
+ border-radius: var(--radius-sm);
+ padding: 14px 18px;
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ line-height: 1.6;
+ color: var(--c-ink);
+ overflow-x: auto;
+ white-space: pre;
+ margin: 0;
+}
+
+/* ---------- empty state ---------- */
+
+.empty-state {
+ text-align: center;
+ padding: 56px 24px;
+ border: 1px dashed var(--c-rule-strong);
+ border-radius: var(--radius-md);
+ color: var(--c-ink-mute);
+}
+.empty-state p { margin: 0 0 8px; font-size: 15px; }
+.empty-state p.empty-title {
+ font-family: var(--font-serif);
+ font-variation-settings: 'opsz' 36, 'SOFT' 30;
+ font-weight: 500;
+ font-size: 18px;
+ color: var(--c-ink-soft);
+}
+
+/* ---------- danger zone ---------- */
+
+.danger-zone {
+ margin-top: 40px;
+ border: 1px solid var(--c-danger-soft);
+ background: var(--c-danger-tint);
+ border-radius: var(--radius-md);
+ padding: 22px 26px;
+}
+.danger-zone-title {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--c-danger);
+ margin: 0 0 8px;
+}
+.danger-zone-body {
+ font-size: 14px;
+ color: var(--c-ink-soft);
+ margin: 0 0 16px;
+}
+
+/* ---------- responsive ---------- */
+
+@media (max-width: 760px) {
+ .plan-grid { grid-template-columns: 1fr; }
+ .usage-summary { grid-template-columns: 1fr; }
+ .app-header-meta .plan-chip { display: none; }
+ main.app-main { padding: 40px 0 80px; }
+ .tabs { margin-bottom: 32px; }
+ .card { padding: 22px 20px; }
+ .entry-card { padding: 36px 28px 32px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; animation: none !important; }
+}
diff --git a/web/ui/src/app.d.ts b/web/ui/src/app.d.ts
new file mode 100644
index 0000000..f609a0b
--- /dev/null
+++ b/web/ui/src/app.d.ts
@@ -0,0 +1,16 @@
+// See https://kit.svelte.dev/docs/types#app
+
+import type { Account } from '$lib/server/db';
+
+declare global {
+ namespace App {
+ interface Locals {
+ account: Account | null;
+ }
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/web/ui/src/app.html b/web/ui/src/app.html
new file mode 100644
index 0000000..beb1a42
--- /dev/null
+++ b/web/ui/src/app.html
@@ -0,0 +1,42 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="description" content="Tidy Index — dashboard" />
+ <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
+
+ <!-- Preload the three self-hosted webfonts so the browser fetches
+ them in parallel with the CSS, not after it. -->
+ <link rel="preload" href="%sveltekit.assets%/fonts/fraunces-latin.woff2" as="font" type="font/woff2" crossorigin />
+ <link rel="preload" href="%sveltekit.assets%/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
+ <link rel="preload" href="%sveltekit.assets%/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin />
+
+ <!-- Prevent FOUT: hide the body until webfonts are loaded, with a
+ 1500ms safety timeout so the page never stays blank forever. -->
+ <style>html.fonts-pending body { visibility: hidden; }</style>
+ <script>
+ (function () {
+ var d = document;
+ d.documentElement.classList.add('fonts-pending');
+ var done = false;
+ function reveal() {
+ if (done) return;
+ done = true;
+ d.documentElement.classList.remove('fonts-pending');
+ }
+ if (d.fonts && d.fonts.ready && typeof d.fonts.ready.then === 'function') {
+ d.fonts.ready.then(reveal);
+ } else {
+ reveal();
+ }
+ setTimeout(reveal, 1500);
+ })();
+ </script>
+
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/web/ui/src/hooks.server.ts b/web/ui/src/hooks.server.ts
new file mode 100644
index 0000000..1204a94
--- /dev/null
+++ b/web/ui/src/hooks.server.ts
@@ -0,0 +1,16 @@
+import { redirect, type Handle } from '@sveltejs/kit';
+
+import { getAccountFromCookies } from '$lib/server/auth';
+
+export const handle: Handle = async ({ event, resolve }) => {
+ event.locals.account = getAccountFromCookies(event.cookies);
+
+ const path = event.url.pathname;
+
+ // /dashboard/* requires a session. Bounce to the entry page if missing.
+ if (path.startsWith('/dashboard') && !event.locals.account) {
+ throw redirect(303, '/');
+ }
+
+ return resolve(event);
+};
diff --git a/web/ui/src/lib/components/BrandMark.svelte b/web/ui/src/lib/components/BrandMark.svelte
new file mode 100644
index 0000000..0feab32
--- /dev/null
+++ b/web/ui/src/lib/components/BrandMark.svelte
@@ -0,0 +1,7 @@
+<span class="brand-mark" aria-hidden="true">
+ <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <rect x="4" y="6" width="24" height="3" rx="1.5" fill="currentColor" />
+ <rect x="4" y="14" width="18" height="3" rx="1.5" fill="currentColor" opacity="0.7" />
+ <rect x="4" y="22" width="12" height="3" rx="1.5" fill="currentColor" opacity="0.4" />
+ </svg>
+</span>
diff --git a/web/ui/src/lib/components/Footer.svelte b/web/ui/src/lib/components/Footer.svelte
new file mode 100644
index 0000000..8042f64
--- /dev/null
+++ b/web/ui/src/lib/components/Footer.svelte
@@ -0,0 +1,8 @@
+<footer class="app-footer">
+ <div class="container app-footer-inner">
+ <p class="app-footer-line">
+ Need something &mdash; email us at
+ <a href="mailto:contact@tidyindex.com">contact@tidyindex.com</a>
+ </p>
+ </div>
+</footer>
diff --git a/web/ui/src/lib/components/Toasts.svelte b/web/ui/src/lib/components/Toasts.svelte
new file mode 100644
index 0000000..3d66b3f
--- /dev/null
+++ b/web/ui/src/lib/components/Toasts.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import { toasts } from '$lib/stores/toasts';
+</script>
+
+<div class="toast-container" aria-live="polite" aria-atomic="true">
+ {#each $toasts as t (t.id)}
+ <div class="toast toast-{t.kind}">
+ <span class="toast-dot"></span>
+ <span>{t.message}</span>
+ </div>
+ {/each}
+</div>
diff --git a/web/ui/src/lib/datasets.ts b/web/ui/src/lib/datasets.ts
new file mode 100644
index 0000000..83c2e4e
--- /dev/null
+++ b/web/ui/src/lib/datasets.ts
@@ -0,0 +1,46 @@
+/**
+ * The shared list of dataset slugs a user can scope an API key to.
+ * Used by the create-key form, the key scope display, and the usage
+ * breakdown. The order here is the order they show up in the UI.
+ */
+export const DATASETS = [
+ 'irs-990',
+ 'irs-990pf',
+ 'sec-edgar',
+ 'sec-13f',
+ 'sec-form4',
+ 'fec-contributions',
+ 'lobbying-federal',
+ 'usaspending',
+ 'pacer',
+ 'state-corps',
+ 'ucc-filings',
+ 'fda-faers',
+ 'osha',
+ 'nih-reporter',
+ 'cfpb-complaints'
+] as const;
+
+export type DatasetSlug = (typeof DATASETS)[number];
+
+/** Friendly short label for a dataset slug. */
+export function datasetLabel(slug: string): string {
+ switch (slug) {
+ case 'irs-990': return 'IRS 990';
+ case 'irs-990pf': return 'IRS 990-PF';
+ case 'sec-edgar': return 'SEC EDGAR';
+ case 'sec-13f': return 'SEC 13-F';
+ case 'sec-form4': return 'SEC Form 4';
+ case 'fec-contributions': return 'FEC contributions';
+ case 'lobbying-federal': return 'Federal lobbying';
+ case 'usaspending': return 'USAspending';
+ case 'pacer': return 'PACER';
+ case 'state-corps': return 'State incorporation';
+ case 'ucc-filings': return 'UCC filings';
+ case 'fda-faers': return 'FDA FAERS';
+ case 'osha': return 'OSHA inspections';
+ case 'nih-reporter': return 'NIH RePORTER';
+ case 'cfpb-complaints': return 'CFPB complaints';
+ default: return slug;
+ }
+}
diff --git a/web/ui/src/lib/keys.ts b/web/ui/src/lib/keys.ts
new file mode 100644
index 0000000..5fddedc
--- /dev/null
+++ b/web/ui/src/lib/keys.ts
@@ -0,0 +1,24 @@
+import { DATASETS } from './datasets';
+
+/** Display mask: prefix + bullets. We don't store the full key, so we
+ * cannot show any trailing characters from it after creation. */
+export function maskKey(prefix: string): string {
+ return `${prefix}${'\u2022'.repeat(24)}`;
+}
+
+/** Validate + normalize a scope list from user input. Used server-side
+ * when persisting a new key; kept in the client-safe module so the
+ * create-key form can mirror its logic if it wants. */
+export function normalizeScopes(input: string[]): string[] {
+ if (!input || input.length === 0) return ['*'];
+ if (input.includes('*')) return ['*'];
+ const allowed = new Set<string>(DATASETS);
+ const out = input.filter((s) => allowed.has(s));
+ return out.length === 0 ? ['*'] : out;
+}
+
+export function scopeSummary(scopes: string[]): string {
+ if (scopes.length === 1 && scopes[0] === '*') return 'all datasets';
+ if (scopes.length <= 3) return scopes.join(', ');
+ return `${scopes.slice(0, 2).join(', ')} +${scopes.length - 2} more`;
+}
diff --git a/web/ui/src/lib/plans.ts b/web/ui/src/lib/plans.ts
new file mode 100644
index 0000000..bf6c474
--- /dev/null
+++ b/web/ui/src/lib/plans.ts
@@ -0,0 +1,82 @@
+export type PlanId = 'free' | 'dev' | 'pro' | 'enterprise';
+
+export interface Plan {
+ id: PlanId;
+ name: string;
+ price: number | null; // null = custom / contact us
+ priceLabel: string;
+ period: string;
+ requestsPerMonth: number; // Infinity for enterprise
+ maxKeys: number; // Infinity for enterprise
+ features: string[];
+ cta: string;
+}
+
+export const PLANS: Record<PlanId, Plan> = {
+ free: {
+ id: 'free',
+ name: 'Free',
+ price: 0,
+ priceLabel: '$0',
+ period: 'forever',
+ requestsPerMonth: 1_000,
+ maxKeys: 1,
+ features: [
+ '1,000 requests per month',
+ '1 API key',
+ 'JSON + JSONL access',
+ 'Community support'
+ ],
+ cta: 'Switch to Free'
+ },
+ dev: {
+ id: 'dev',
+ name: 'Developer',
+ price: 100,
+ priceLabel: '$100',
+ period: '/ month',
+ requestsPerMonth: 50_000,
+ maxKeys: 5,
+ features: [
+ '50,000 requests per month',
+ '5 API keys',
+ 'All response shapes',
+ 'Email support'
+ ],
+ cta: 'Switch to Developer'
+ },
+ pro: {
+ id: 'pro',
+ name: 'Professional',
+ price: 1000,
+ priceLabel: '$1,000',
+ period: '/ month',
+ requestsPerMonth: 500_000,
+ maxKeys: 20,
+ features: [
+ '500,000 requests per month',
+ '20 API keys',
+ 'Priority support',
+ 'SLA on uptime'
+ ],
+ cta: 'Switch to Pro'
+ },
+ enterprise: {
+ id: 'enterprise',
+ name: 'Enterprise',
+ price: null,
+ priceLabel: 'Custom',
+ period: '',
+ requestsPerMonth: Number.POSITIVE_INFINITY,
+ maxKeys: Number.POSITIVE_INFINITY,
+ features: [
+ 'Unlimited requests',
+ 'Unlimited keys',
+ 'Dedicated support',
+ 'Custom datasets and SLAs'
+ ],
+ cta: 'Contact us'
+ }
+};
+
+export const PLAN_ORDER: PlanId[] = ['free', 'dev', 'pro', 'enterprise'];
diff --git a/web/ui/src/lib/server/auth.ts b/web/ui/src/lib/server/auth.ts
new file mode 100644
index 0000000..6f202d3
--- /dev/null
+++ b/web/ui/src/lib/server/auth.ts
@@ -0,0 +1,153 @@
+import jwt from 'jsonwebtoken';
+import { randomBytes } from 'node:crypto';
+import { dev } from '$app/environment';
+import { env } from '$env/dynamic/private';
+import type { Cookies } from '@sveltejs/kit';
+
+import { db, queries, newId, now, type Account } from './db';
+
+function getJwtSecret(): string {
+ const secret = env.JWT_SECRET;
+ if (!secret) {
+ throw new Error(
+ 'JWT_SECRET env variable is required. Copy .env.example to .env ' +
+ 'and generate one with `openssl rand -base64 48`.'
+ );
+ }
+ return secret;
+}
+
+const COOKIE_NAME = 'ti_sess';
+const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
+
+interface SessionPayload {
+ sub: string; // account id
+}
+
+export function createSession(cookies: Cookies, accountId: string): void {
+ const token = jwt.sign(
+ { sub: accountId } satisfies SessionPayload,
+ getJwtSecret(),
+ { algorithm: 'HS256' }
+ );
+ cookies.set(COOKIE_NAME, token, {
+ path: '/',
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: !dev,
+ maxAge: COOKIE_MAX_AGE
+ });
+}
+
+export function clearSession(cookies: Cookies): void {
+ cookies.delete(COOKIE_NAME, { path: '/' });
+}
+
+export function getAccountFromCookies(cookies: Cookies): Account | null {
+ const raw = cookies.get(COOKIE_NAME);
+ if (!raw) return null;
+ try {
+ const decoded = jwt.verify(raw, getJwtSecret(), {
+ algorithms: ['HS256']
+ }) as SessionPayload;
+ if (!decoded.sub) return null;
+ const account = queries.accountById.get(decoded.sub);
+ return account ?? null;
+ } catch {
+ return null;
+ }
+}
+
+// -------- account helpers --------
+
+/** Create a brand-new anonymous account (no email). */
+export function createAnonymousAccount(): Account {
+ const id = newId();
+ const ts = now();
+ queries.insertAccount.run(id, null, 'free', ts);
+ return { id, email: null, pending_email: null, plan: 'free', created_at: ts };
+}
+
+/** Find an account by email or create a new one with that email. */
+export function getOrCreateAccountByEmail(email: string): Account {
+ const existing = queries.accountByEmail.get(email);
+ if (existing) return existing;
+ const id = newId();
+ const ts = now();
+ queries.insertAccount.run(id, email, 'free', ts);
+ return { id, email, pending_email: null, plan: 'free', created_at: ts };
+}
+
+// -------- magic links --------
+
+export interface MagicLink {
+ token: string;
+ expires_at: number;
+ url: string;
+}
+
+export function generateMagicLink(
+ accountId: string,
+ baseUrl: string
+): MagicLink {
+ const token = randomBytes(24).toString('base64url');
+ const id = newId();
+ const ts = now();
+ const expires = ts + 15 * 60 * 1000; // 15 minutes
+ queries.insertMagicLink.run(id, accountId, token, expires, ts);
+ const url = `${baseUrl.replace(/\/$/, '')}/auth/callback?token=${token}`;
+ return { token, expires_at: expires, url };
+}
+
+export function consumeMagicLink(token: string): Account | null {
+ const row = queries.magicLinkByToken.get(token);
+ if (!row) return null;
+ if (row.used === 1) return null;
+ if (row.expires_at < now()) return null;
+ queries.markMagicLinkUsed.run(row.id);
+ const account = queries.accountById.get(row.account_id);
+ return account ?? null;
+}
+
+// -------- email attach / merge --------
+
+/**
+ * Attach an email to an existing (anonymous) account. If another account
+ * already uses that email, merge this account into the other one — all
+ * API keys move over, the anonymous account is deleted, and the caller
+ * receives the surviving account id (which may be different from the
+ * one passed in).
+ */
+export function attachEmailToAccount(
+ accountId: string,
+ email: string
+): { accountId: string; merged: boolean } {
+ const normalized = email.trim().toLowerCase();
+ if (!normalized) throw new Error('Email is empty');
+
+ const current = queries.accountById.get(accountId);
+ if (!current) throw new Error('Account not found');
+
+ const existing = queries.accountByEmail.get(normalized);
+
+ // Same account already has this email — no-op.
+ if (existing && existing.id === current.id) {
+ return { accountId: current.id, merged: false };
+ }
+
+ // No other account has this email — just attach it.
+ if (!existing) {
+ queries.updateAccountEmail.run(normalized, current.id);
+ return { accountId: current.id, merged: false };
+ }
+
+ // Another account already owns the email. Merge current into existing:
+ // move all keys over, then delete the current account.
+ const merge = db.transaction(() => {
+ queries.reassignKeys.run(existing.id, current.id);
+ queries.deleteAccount.run(current.id);
+ });
+ merge();
+
+ return { accountId: existing.id, merged: true };
+}
diff --git a/web/ui/src/lib/server/db.ts b/web/ui/src/lib/server/db.ts
new file mode 100644
index 0000000..ee5736d
--- /dev/null
+++ b/web/ui/src/lib/server/db.ts
@@ -0,0 +1,233 @@
+import Database from 'better-sqlite3';
+import { mkdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { env } from '$env/dynamic/private';
+
+import type { PlanId } from '$lib/plans';
+
+const DB_PATH = resolve(env.DATABASE_PATH || './data/dashboard.db');
+
+// Make sure the data/ directory exists before better-sqlite3 tries to open.
+mkdirSync(dirname(DB_PATH), { recursive: true });
+
+export const db = new Database(DB_PATH);
+db.pragma('journal_mode = WAL');
+db.pragma('foreign_keys = ON');
+
+// -------- schema --------
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS accounts (
+ id TEXT PRIMARY KEY,
+ email TEXT UNIQUE,
+ pending_email TEXT,
+ plan TEXT NOT NULL DEFAULT 'free',
+ created_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS api_keys (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ key_hash TEXT NOT NULL UNIQUE,
+ key_prefix TEXT NOT NULL,
+ name TEXT NOT NULL,
+ scopes TEXT NOT NULL DEFAULT '["*"]',
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id);
+
+ CREATE TABLE IF NOT EXISTS magic_links (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ used INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token);
+
+ CREATE TABLE IF NOT EXISTS usage_events (
+ id TEXT PRIMARY KEY,
+ api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
+ dataset TEXT NOT NULL,
+ timestamp INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_events(api_key_id);
+ CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(timestamp);
+ CREATE INDEX IF NOT EXISTS idx_usage_dataset ON usage_events(dataset);
+`);
+
+// Idempotent migration: pre-existing dashboard.db files won't have
+// pending_email yet. SQLite has no IF NOT EXISTS for ALTER, so check
+// PRAGMA table_info first.
+{
+ const cols = db
+ .prepare(`PRAGMA table_info(accounts)`)
+ .all() as Array<{ name: string }>;
+ if (!cols.some((c) => c.name === 'pending_email')) {
+ db.exec(`ALTER TABLE accounts ADD COLUMN pending_email TEXT`);
+ }
+}
+
+// -------- types --------
+
+export interface Account {
+ id: string;
+ email: string | null;
+ /** Email the user is mid-verifying. Cleared on completion or cancel. */
+ pending_email: string | null;
+ plan: PlanId;
+ created_at: number;
+}
+
+export interface ApiKeyRow {
+ id: string;
+ account_id: string;
+ key_hash: string;
+ key_prefix: string;
+ name: string;
+ scopes: string; // JSON string on disk
+ active: number; // 0 / 1
+ created_at: number;
+ last_used_at: number | null;
+}
+
+export interface ApiKey {
+ id: string;
+ account_id: string;
+ key_prefix: string;
+ name: string;
+ scopes: string[]; // decoded
+ active: boolean;
+ created_at: number;
+ last_used_at: number | null;
+}
+
+export function rowToKey(row: ApiKeyRow): ApiKey {
+ return {
+ id: row.id,
+ account_id: row.account_id,
+ key_prefix: row.key_prefix,
+ name: row.name,
+ scopes: JSON.parse(row.scopes),
+ active: row.active === 1,
+ created_at: row.created_at,
+ last_used_at: row.last_used_at
+ };
+}
+
+// -------- helpers --------
+
+export function now(): number {
+ return Date.now();
+}
+
+export function newId(): string {
+ return randomUUID();
+}
+
+// -------- prepared statements --------
+
+const stmts = {
+ accountById: db.prepare<string, Account>(
+ `SELECT id, email, pending_email, plan, created_at FROM accounts WHERE id = ?`
+ ),
+ accountByEmail: db.prepare<string, Account>(
+ `SELECT id, email, pending_email, plan, created_at FROM accounts WHERE email = ?`
+ ),
+ insertAccount: db.prepare(
+ `INSERT INTO accounts (id, email, plan, created_at) VALUES (?, ?, ?, ?)`
+ ),
+ updateAccountEmail: db.prepare(
+ `UPDATE accounts SET email = ? WHERE id = ?`
+ ),
+ setPendingEmail: db.prepare(
+ `UPDATE accounts SET pending_email = ? WHERE id = ?`
+ ),
+ clearPendingEmail: db.prepare(
+ `UPDATE accounts SET pending_email = NULL WHERE id = ?`
+ ),
+ promotePendingEmail: db.prepare(
+ `UPDATE accounts SET email = pending_email, pending_email = NULL WHERE id = ?`
+ ),
+ updateAccountPlan: db.prepare(
+ `UPDATE accounts SET plan = ? WHERE id = ?`
+ ),
+ deleteAccount: db.prepare(
+ `DELETE FROM accounts WHERE id = ?`
+ ),
+ reassignKeys: db.prepare(
+ `UPDATE api_keys SET account_id = ? WHERE account_id = ?`
+ ),
+ reassignUsage: db.prepare(
+ `UPDATE usage_events SET api_key_id = api_key_id WHERE api_key_id IN
+ (SELECT id FROM api_keys WHERE account_id = ?)`
+ ),
+ keysForAccount: db.prepare<string, ApiKeyRow>(
+ `SELECT * FROM api_keys WHERE account_id = ? ORDER BY created_at DESC`
+ ),
+ keyById: db.prepare<[string, string], ApiKeyRow>(
+ `SELECT * FROM api_keys WHERE id = ? AND account_id = ?`
+ ),
+ insertKey: db.prepare(
+ `INSERT INTO api_keys
+ (id, account_id, key_hash, key_prefix, name, scopes, active, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?)`
+ ),
+ revokeKey: db.prepare(
+ `UPDATE api_keys SET active = 0 WHERE id = ? AND account_id = ?`
+ ),
+ countActiveKeys: db.prepare<string, { c: number }>(
+ `SELECT COUNT(*) AS c FROM api_keys WHERE account_id = ? AND active = 1`
+ ),
+ insertMagicLink: db.prepare(
+ `INSERT INTO magic_links (id, account_id, token, expires_at, used, created_at)
+ VALUES (?, ?, ?, ?, 0, ?)`
+ ),
+ magicLinkByToken: db.prepare<
+ string,
+ { id: string; account_id: string; expires_at: number; used: number }
+ >(
+ `SELECT id, account_id, expires_at, used FROM magic_links WHERE token = ?`
+ ),
+ markMagicLinkUsed: db.prepare(
+ `UPDATE magic_links SET used = 1 WHERE id = ?`
+ ),
+ insertUsageEvent: db.prepare(
+ `INSERT INTO usage_events (id, api_key_id, dataset, timestamp)
+ VALUES (?, ?, ?, ?)`
+ ),
+ usageCountSince: db.prepare<[string, number], { c: number }>(
+ `SELECT COUNT(*) AS c FROM usage_events e
+ JOIN api_keys k ON k.id = e.api_key_id
+ WHERE k.account_id = ? AND e.timestamp >= ?`
+ ),
+ usageByDataset: db.prepare<
+ [string, number],
+ { dataset: string; c: number }
+ >(
+ `SELECT e.dataset AS dataset, COUNT(*) AS c
+ FROM usage_events e
+ JOIN api_keys k ON k.id = e.api_key_id
+ WHERE k.account_id = ? AND e.timestamp >= ?
+ GROUP BY e.dataset
+ ORDER BY c DESC`
+ ),
+ usageByKey: db.prepare<
+ [number, string],
+ { key_id: string; name: string; c: number }
+ >(
+ `SELECT k.id AS key_id, k.name AS name, COUNT(e.id) AS c
+ FROM api_keys k
+ LEFT JOIN usage_events e
+ ON e.api_key_id = k.id AND e.timestamp >= ?
+ WHERE k.account_id = ?
+ GROUP BY k.id
+ ORDER BY c DESC`
+ )
+};
+
+export const queries = stmts;
diff --git a/web/ui/src/lib/server/keys.ts b/web/ui/src/lib/server/keys.ts
new file mode 100644
index 0000000..350839b
--- /dev/null
+++ b/web/ui/src/lib/server/keys.ts
@@ -0,0 +1,79 @@
+import { createHash, randomBytes } from 'node:crypto';
+
+import { queries, newId, now, rowToKey, type ApiKey } from './db';
+import { normalizeScopes } from '$lib/keys';
+
+const ALPHABET =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+/**
+ * Generate a new API key string (not yet persisted).
+ * Format: "ti_" + 32 random alphanumeric characters.
+ */
+export function mintKey(): { key: string; hash: string; prefix: string } {
+ const bytes = randomBytes(32);
+ let random = '';
+ for (let i = 0; i < 32; i++) {
+ random += ALPHABET[bytes[i] % ALPHABET.length];
+ }
+ const key = `ti_${random}`;
+ const hash = createHash('sha256').update(key).digest('hex');
+ const prefix = key.slice(0, 8); // "ti_" + 5 chars
+ return { key, hash, prefix };
+}
+
+// -------- persistence --------
+
+export interface CreateKeyInput {
+ accountId: string;
+ name: string;
+ scopes: string[];
+}
+
+export interface CreatedKey extends ApiKey {
+ /** The full plaintext key. Only returned on creation — never stored. */
+ plaintext: string;
+}
+
+export function createKey(input: CreateKeyInput): CreatedKey {
+ const { key, hash, prefix } = mintKey();
+ const id = newId();
+ const ts = now();
+ const name = input.name.trim() || 'Untitled key';
+ const scopes = normalizeScopes(input.scopes);
+ queries.insertKey.run(
+ id,
+ input.accountId,
+ hash,
+ prefix,
+ name,
+ JSON.stringify(scopes),
+ ts
+ );
+ return {
+ id,
+ account_id: input.accountId,
+ key_prefix: prefix,
+ name,
+ scopes,
+ active: true,
+ created_at: ts,
+ last_used_at: null,
+ plaintext: key
+ };
+}
+
+export function listKeys(accountId: string): ApiKey[] {
+ return queries.keysForAccount.all(accountId).map(rowToKey);
+}
+
+export function revokeKey(accountId: string, keyId: string): boolean {
+ const row = queries.keyById.get(keyId, accountId);
+ if (!row) return false;
+ queries.revokeKey.run(keyId, accountId);
+ return true;
+}
+
+export function countActiveKeys(accountId: string): number {
+ return queries.countActiveKeys.get(accountId)?.c ?? 0;
+}
diff --git a/web/ui/src/lib/server/usage.ts b/web/ui/src/lib/server/usage.ts
new file mode 100644
index 0000000..721cc3c
--- /dev/null
+++ b/web/ui/src/lib/server/usage.ts
@@ -0,0 +1,33 @@
+import { queries } from './db';
+
+export function startOfCurrentMonth(): number {
+ const d = new Date();
+ d.setUTCDate(1);
+ d.setUTCHours(0, 0, 0, 0);
+ return d.getTime();
+}
+
+export function usageCountThisMonth(accountId: string): number {
+ return queries.usageCountSince.get(accountId, startOfCurrentMonth())?.c ?? 0;
+}
+
+export function usageByDataset(
+ accountId: string
+): Array<{ dataset: string; count: number }> {
+ const rows = queries.usageByDataset.all(accountId, startOfCurrentMonth());
+ return rows.map((r: { dataset: string; c: number }) => ({
+ dataset: r.dataset,
+ count: r.c
+ }));
+}
+
+export function usageByKey(
+ accountId: string
+): Array<{ keyId: string; name: string; count: number }> {
+ const rows = queries.usageByKey.all(startOfCurrentMonth(), accountId);
+ return rows.map((r: { key_id: string; name: string; c: number }) => ({
+ keyId: r.key_id,
+ name: r.name,
+ count: r.c
+ }));
+}
diff --git a/web/ui/src/lib/stores/toasts.ts b/web/ui/src/lib/stores/toasts.ts
new file mode 100644
index 0000000..27dd8c4
--- /dev/null
+++ b/web/ui/src/lib/stores/toasts.ts
@@ -0,0 +1,21 @@
+import { writable } from 'svelte/store';
+
+export type ToastKind = 'info' | 'success' | 'error';
+
+export interface Toast {
+ id: number;
+ kind: ToastKind;
+ message: string;
+}
+
+let nextId = 1;
+
+export const toasts = writable<Toast[]>([]);
+
+export function pushToast(message: string, kind: ToastKind = 'info'): void {
+ const id = nextId++;
+ toasts.update((list) => [...list, { id, kind, message }]);
+ setTimeout(() => {
+ toasts.update((list) => list.filter((t) => t.id !== id));
+ }, 2500);
+}
diff --git a/web/ui/src/routes/+layout.server.ts b/web/ui/src/routes/+layout.server.ts
new file mode 100644
index 0000000..37c08a0
--- /dev/null
+++ b/web/ui/src/routes/+layout.server.ts
@@ -0,0 +1,7 @@
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ return {
+ account: locals.account
+ };
+};
diff --git a/web/ui/src/routes/+layout.svelte b/web/ui/src/routes/+layout.svelte
new file mode 100644
index 0000000..ddd7c4c
--- /dev/null
+++ b/web/ui/src/routes/+layout.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import '../app.css';
+ import Toasts from '$lib/components/Toasts.svelte';
+
+ let { children } = $props();
+</script>
+
+<Toasts />
+{@render children()}
diff --git a/web/ui/src/routes/+page.server.ts b/web/ui/src/routes/+page.server.ts
new file mode 100644
index 0000000..2daf03d
--- /dev/null
+++ b/web/ui/src/routes/+page.server.ts
@@ -0,0 +1,20 @@
+import { redirect } from '@sveltejs/kit';
+
+import { createAnonymousAccount, createSession } from '$lib/server/auth';
+import type { PageServerLoad } from './$types';
+
+/**
+ * The root route is just a bouncer: if you already have a session,
+ * go to the dashboard; if not, we mint an anonymous account on the
+ * spot, set a session cookie, and go to the dashboard.
+ *
+ * There is intentionally no sign-in page. Users can add an email on
+ * the Account tab later if they want a way to recover their keys.
+ */
+export const load: PageServerLoad = async ({ locals, cookies }) => {
+ if (!locals.account) {
+ const account = createAnonymousAccount();
+ createSession(cookies, account.id);
+ }
+ throw redirect(303, '/dashboard');
+};
diff --git a/web/ui/src/routes/auth/callback/+server.ts b/web/ui/src/routes/auth/callback/+server.ts
new file mode 100644
index 0000000..da8181b
--- /dev/null
+++ b/web/ui/src/routes/auth/callback/+server.ts
@@ -0,0 +1,14 @@
+import { redirect, type RequestHandler } from '@sveltejs/kit';
+
+import { consumeMagicLink, createSession } from '$lib/server/auth';
+
+export const GET: RequestHandler = async ({ url, cookies }) => {
+ const token = url.searchParams.get('token');
+ if (!token) throw redirect(303, '/?error=missing-token');
+
+ const account = consumeMagicLink(token);
+ if (!account) throw redirect(303, '/?error=invalid-token');
+
+ createSession(cookies, account.id);
+ throw redirect(303, '/dashboard');
+};
diff --git a/web/ui/src/routes/auth/logout/+server.ts b/web/ui/src/routes/auth/logout/+server.ts
new file mode 100644
index 0000000..b17d1d5
--- /dev/null
+++ b/web/ui/src/routes/auth/logout/+server.ts
@@ -0,0 +1,13 @@
+import { redirect, type RequestHandler } from '@sveltejs/kit';
+
+import { clearSession } from '$lib/server/auth';
+
+export const POST: RequestHandler = async ({ cookies }) => {
+ clearSession(cookies);
+ throw redirect(303, '/');
+};
+
+export const GET: RequestHandler = async ({ cookies }) => {
+ clearSession(cookies);
+ throw redirect(303, '/');
+};
diff --git a/web/ui/src/routes/dashboard/+layout.server.ts b/web/ui/src/routes/dashboard/+layout.server.ts
new file mode 100644
index 0000000..8eebc76
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+layout.server.ts
@@ -0,0 +1,12 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ if (!locals.account) {
+ throw redirect(303, '/');
+ }
+ return {
+ account: locals.account
+ };
+};
diff --git a/web/ui/src/routes/dashboard/+layout.svelte b/web/ui/src/routes/dashboard/+layout.svelte
new file mode 100644
index 0000000..9137c75
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+layout.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import BrandMark from '$lib/components/BrandMark.svelte';
+ import Footer from '$lib/components/Footer.svelte';
+ import { PLANS } from '$lib/plans';
+
+ let { data, children } = $props();
+
+ const tabs = [
+ { id: '01', slug: 'keys', label: 'Keys' },
+ { id: '02', slug: 'usage', label: 'Usage' },
+ { id: '03', slug: 'account', label: 'Account' }
+ ];
+
+ let currentPath = $derived($page.url.pathname);
+ let account = $derived(data.account);
+ let planInfo = $derived(account ? PLANS[account.plan] : PLANS.free);
+</script>
+
+<svelte:head>
+ <title>Tidy Index — Dashboard</title>
+</svelte:head>
+
+<header class="app-header">
+ <div class="container app-header-inner">
+ <a href="/dashboard/keys" class="brand" aria-label="Tidy Index dashboard home">
+ <BrandMark />
+ <span>Tidy&nbsp;Index</span>
+ </a>
+
+ <div class="app-header-meta">
+ <span class="plan-chip">
+ plan:&nbsp;<strong>{planInfo.name.toLowerCase()}</strong>
+ </span>
+ {#if account?.email}
+ <span title={account.email}>{account.email}</span>
+ {:else if account?.pending_email}
+ <span class="pending-chip" title="Sign-in link sent — awaiting verification">
+ pending&nbsp;·&nbsp;{account.pending_email}
+ </span>
+ {:else}
+ <span class="text-mute">anonymous</span>
+ {/if}
+ </div>
+ </div>
+</header>
+
+<div class="container">
+ {#if !account?.email && !account?.pending_email}
+ <div class="banner">
+ <span>
+ You're using an anonymous session.&nbsp;
+ <strong>Sign in with an email so you don't lose access to your keys.</strong>
+ </span>
+ <a href="/dashboard/account?focus=email" class="btn btn-sm btn-ghost">Sign in</a>
+ </div>
+ {:else if account?.pending_email && !account?.email}
+ <div class="banner">
+ <span>
+ We sent a sign-in link to&nbsp;<strong>{account.pending_email}</strong>.
+ Check your inbox to finish signing in.
+ </span>
+ <a href="/dashboard/account" class="btn btn-sm btn-ghost">Manage</a>
+ </div>
+ {/if}
+
+ <nav class="tabs" aria-label="Dashboard sections">
+ <div class="tabs-inner">
+ {#each tabs as tab}
+ {@const active = currentPath.startsWith(`/dashboard/${tab.slug}`)}
+ <a
+ href="/dashboard/{tab.slug}"
+ class="tab {active ? 'active' : ''}"
+ aria-current={active ? 'page' : undefined}
+ >
+ <span class="tab-num">§&nbsp;{tab.id}</span>
+ <span>{tab.label}</span>
+ </a>
+ {/each}
+ </div>
+ </nav>
+</div>
+
+<main class="app-main">
+ <div class="container">
+ {@render children()}
+ </div>
+</main>
+
+<Footer />
diff --git a/web/ui/src/routes/dashboard/+page.server.ts b/web/ui/src/routes/dashboard/+page.server.ts
new file mode 100644
index 0000000..32e2307
--- /dev/null
+++ b/web/ui/src/routes/dashboard/+page.server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ throw redirect(303, '/dashboard/keys');
+};
diff --git a/web/ui/src/routes/dashboard/account/+page.server.ts b/web/ui/src/routes/dashboard/account/+page.server.ts
new file mode 100644
index 0000000..5581b52
--- /dev/null
+++ b/web/ui/src/routes/dashboard/account/+page.server.ts
@@ -0,0 +1,107 @@
+import { fail, redirect, type Actions } from '@sveltejs/kit';
+
+import { queries } from '$lib/server/db';
+import {
+ attachEmailToAccount,
+ clearSession,
+ createSession
+} from '$lib/server/auth';
+import { PLANS, PLAN_ORDER, type PlanId } from '$lib/plans';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ const keys = queries.keysForAccount.all(account.id);
+ return {
+ account,
+ keyCount: keys.length,
+ plans: PLAN_ORDER.map((id) => PLANS[id]),
+ currentPlan: account.plan
+ };
+};
+
+export const actions: Actions = {
+ /**
+ * Anonymous → pending. The user has typed an email; we save it as
+ * pending_email and (eventually) send a magic link. Email sending is
+ * not wired up yet — the UI just transitions to the pending state.
+ */
+ requestSignInLink: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const email = ((form.get('email') ?? '') as string).trim().toLowerCase();
+
+ if (!email || !email.includes('@') || email.length > 254) {
+ return fail(400, { error: 'Enter a valid email address.' });
+ }
+
+ queries.setPendingEmail.run(email, account.id);
+ return { linkSent: true, email };
+ },
+
+ /** Pending → anonymous. User abandons the verification. */
+ cancelPendingSignIn: async ({ locals }) => {
+ const account = locals.account!;
+ queries.clearPendingEmail.run(account.id);
+ return { cancelled: true };
+ },
+
+ /**
+ * Pending → signed in. Dev-only shortcut so the signed-in UI is
+ * reachable without a working magic-link verification flow. Will be
+ * removed once /auth/callback promotes pending_email itself.
+ *
+ * Goes through attachEmailToAccount so we get the same conflict
+ * handling as real verification: if another account already owns the
+ * email, current account merges into it (keys move over, current is
+ * deleted, session cookie reissued).
+ */
+ markVerified: async ({ locals, cookies }) => {
+ const account = locals.account!;
+ if (!account.pending_email) {
+ return fail(400, { error: 'No pending email to verify.' });
+ }
+ try {
+ const result = attachEmailToAccount(account.id, account.pending_email);
+ if (result.accountId === account.id) {
+ // Email attached cleanly to this account — clear pending state.
+ queries.clearPendingEmail.run(account.id);
+ } else {
+ // Merged into a pre-existing account that already owned this
+ // email. The current account is gone; reissue the cookie so
+ // subsequent requests load the surviving account.
+ createSession(cookies, result.accountId);
+ }
+ return { verified: true, merged: result.accountId !== account.id };
+ } catch (e) {
+ return fail(400, {
+ error: e instanceof Error ? e.message : 'Could not verify.'
+ });
+ }
+ },
+
+ switchPlan: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const planId = ((form.get('plan') ?? '') as string) as PlanId;
+
+ if (!PLAN_ORDER.includes(planId)) {
+ return fail(400, { error: 'Unknown plan' });
+ }
+ if (planId === 'enterprise') {
+ return fail(400, {
+ error: 'Enterprise is contact-only. Email contact@tidyindex.com.'
+ });
+ }
+
+ queries.updateAccountPlan.run(planId, account.id);
+ return { switchedTo: planId };
+ },
+
+ deleteAccount: async ({ locals, cookies }) => {
+ const account = locals.account!;
+ queries.deleteAccount.run(account.id);
+ clearSession(cookies);
+ throw redirect(303, '/');
+ }
+};
diff --git a/web/ui/src/routes/dashboard/account/+page.svelte b/web/ui/src/routes/dashboard/account/+page.svelte
new file mode 100644
index 0000000..287c210
--- /dev/null
+++ b/web/ui/src/routes/dashboard/account/+page.svelte
@@ -0,0 +1,238 @@
+<script lang="ts">
+ import { tick } from 'svelte';
+ import { enhance } from '$app/forms';
+ import { afterNavigate, invalidateAll } from '$app/navigation';
+ import { pushToast } from '$lib/stores/toasts';
+ import { PLANS } from '$lib/plans';
+ import type { PageData, ActionData } from './$types';
+
+ let { data, form }: { data: PageData; form: ActionData } = $props();
+
+ let confirmDelete = $state(false);
+
+ // When arriving via "Sign in" (which links to .../account?focus=email),
+ // drop the cursor straight into the email field. preventScroll keeps the
+ // page from jumping when focus() is called.
+ afterNavigate(async (nav) => {
+ if (nav.to?.url.searchParams.get('focus') !== 'email') return;
+ await tick();
+ document.getElementById('email')?.focus({ preventScroll: true });
+ });
+
+ $effect(() => {
+ if (!form) return;
+ if ('linkSent' in form && form.linkSent) {
+ pushToast(`Sign-in link sent to ${form.email}`, 'success');
+ invalidateAll();
+ } else if ('cancelled' in form && form.cancelled) {
+ pushToast('Sign-in cancelled', 'success');
+ invalidateAll();
+ } else if ('verified' in form && form.verified) {
+ pushToast('Signed in', 'success');
+ invalidateAll();
+ } else if ('switchedTo' in form && form.switchedTo) {
+ pushToast(`Switched to ${PLANS[form.switchedTo].name}`, 'success');
+ invalidateAll();
+ } else if ('error' in form && form.error) {
+ pushToast(form.error, 'error');
+ }
+ });
+</script>
+
+<p class="section-marker">§ 03 &nbsp;&middot;&nbsp; account</p>
+<h1 class="page-title">Account.</h1>
+<p class="page-subtitle">
+ {#if data.account.email}
+ Manage your sign-in and review what we know about you.
+ {:else if data.account.pending_email}
+ Finish signing in to lock your keys to this email.
+ {:else}
+ Sign in with email so your keys follow you across browsers.
+ {/if}
+</p>
+
+<div class="card">
+ <div class="card-head">
+ <h2 class="card-title">Sign in</h2>
+ </div>
+
+ {#if data.account.email}
+ <!-- Signed in -->
+ <p class="card-sub">
+ Signed in as <strong>{data.account.email}</strong>.
+ </p>
+ <form method="POST" action="/auth/logout" class="mt-16">
+ <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
+ </form>
+ {:else if data.account.pending_email}
+ <!-- Pending verification -->
+ <p class="card-sub">
+ We sent a sign-in link to
+ <strong>{data.account.pending_email}</strong>. Click it from your inbox
+ to finish signing in.
+ </p>
+ <div class="row mt-16">
+ <form method="POST" action="?/requestSignInLink" use:enhance>
+ <input type="hidden" name="email" value={data.account.pending_email} />
+ <button type="submit" class="btn btn-ghost btn-sm">Resend link</button>
+ </form>
+ <form method="POST" action="?/cancelPendingSignIn" use:enhance>
+ <button type="submit" class="btn btn-ghost btn-sm">Cancel</button>
+ </form>
+ <form method="POST" action="?/markVerified" use:enhance>
+ <button type="submit" class="btn btn-accent btn-sm" title="Dev shortcut: skips real verification">
+ (dev) Mark verified
+ </button>
+ </form>
+ </div>
+ {:else}
+ <!-- Anonymous -->
+ <p class="card-sub">
+ You're using an anonymous session. Sign in with an email so you don't
+ lose access to your keys if you clear cookies or switch browsers.
+ </p>
+ <form method="POST" action="?/requestSignInLink" use:enhance class="mt-16">
+ <div class="field">
+ <label class="field-label" for="email">email address</label>
+ <input
+ id="email"
+ name="email"
+ type="email"
+ class="input"
+ placeholder="you@company.com"
+ required
+ />
+ </div>
+ <button type="submit" class="btn btn-accent btn-sm">
+ Send sign-in link
+ </button>
+ </form>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">Plan</h2>
+ </div>
+ <p class="card-sub">
+ Usage resets on the first of every month. You can change plans any time, and
+ we prorate mid-cycle. (Billing is stubbed in this demo — switching plans just
+ updates the dashboard.)
+ </p>
+
+ <div class="plan-grid mt-16">
+ {#each data.plans as plan}
+ {@const isCurrent = plan.id === data.currentPlan}
+ <div class="plan-card {isCurrent ? 'current' : ''}">
+ {#if isCurrent}
+ <span class="badge">Current</span>
+ {/if}
+
+ <p class="plan-name">{plan.name}</p>
+ <p class="plan-price">
+ {plan.priceLabel}
+ {#if plan.period}<small>&nbsp;{plan.period}</small>{/if}
+ </p>
+
+ <ul class="plan-features">
+ {#each plan.features as f}
+ <li>{f}</li>
+ {/each}
+ </ul>
+
+ <div class="plan-cta">
+ {#if plan.id === 'enterprise'}
+ <a
+ href="mailto:contact@tidyindex.com?subject=Enterprise%20inquiry"
+ class="btn btn-ghost"
+ style="width: 100%;"
+ >
+ Contact us
+ </a>
+ {:else if isCurrent}
+ <button class="btn btn-ghost" style="width: 100%;" disabled>
+ Current plan
+ </button>
+ {:else}
+ <form method="POST" action="?/switchPlan" use:enhance>
+ <input type="hidden" name="plan" value={plan.id} />
+ <button
+ class="btn {plan.id === 'pro' ? 'btn-accent' : 'btn-ghost'}"
+ style="width: 100%;"
+ type="submit"
+ >
+ {plan.cta}
+ </button>
+ </form>
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+
+ <p class="help mt-24">
+ Need something not listed here — custom datasets, on-prem deployment, higher
+ rate limits? Reply to any email from us, or reach out at
+ <a href="mailto:contact@tidyindex.com">contact@tidyindex.com</a>.
+ </p>
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">What we know about you</h2>
+ </div>
+ <dl class="usage-table" style="display:block;">
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">account id</dt>
+ <dd class="text-mono" style="margin:0;">{data.account.id}</dd>
+ </div>
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">plan</dt>
+ <dd class="text-mono" style="margin:0;">{data.account.plan}</dd>
+ </div>
+ <div class="row-between" style="padding:10px 0; border-bottom:1px solid var(--c-rule);">
+ <dt class="text-mono text-mute">created</dt>
+ <dd class="text-mono" style="margin:0;">
+ {new Date(data.account.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ })}
+ </dd>
+ </div>
+ <div class="row-between" style="padding:10px 0;">
+ <dt class="text-mono text-mute">keys on file</dt>
+ <dd class="text-mono" style="margin:0;">{data.keyCount}</dd>
+ </div>
+ </dl>
+</div>
+
+<div class="danger-zone">
+ <p class="danger-zone-title">Danger zone</p>
+ <p class="danger-zone-body">
+ Deleting your account will revoke every key and permanently erase your
+ usage history. This cannot be undone.
+ </p>
+ {#if confirmDelete}
+ <div class="row">
+ <form method="POST" action="?/deleteAccount" use:enhance>
+ <button type="submit" class="btn btn-danger btn-sm">
+ Yes, delete everything
+ </button>
+ </form>
+ <button
+ class="btn btn-ghost btn-sm"
+ onclick={() => (confirmDelete = false)}
+ >
+ Cancel
+ </button>
+ </div>
+ {:else}
+ <button
+ class="btn btn-danger btn-sm"
+ onclick={() => (confirmDelete = true)}
+ >
+ Delete account
+ </button>
+ {/if}
+</div>
diff --git a/web/ui/src/routes/dashboard/keys/+page.server.ts b/web/ui/src/routes/dashboard/keys/+page.server.ts
new file mode 100644
index 0000000..5491283
--- /dev/null
+++ b/web/ui/src/routes/dashboard/keys/+page.server.ts
@@ -0,0 +1,68 @@
+import { fail, type Actions } from '@sveltejs/kit';
+
+import {
+ createKey,
+ listKeys,
+ revokeKey,
+ countActiveKeys
+} from '$lib/server/keys';
+import { PLANS } from '$lib/plans';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ const keys = listKeys(account.id);
+ return {
+ keys,
+ activeCount: countActiveKeys(account.id),
+ plan: PLANS[account.plan]
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const name = ((form.get('name') ?? '') as string).trim();
+ const scopes = form.getAll('scopes').map((s) => s.toString());
+
+ if (!name) {
+ return fail(400, { error: 'Give the key a name so you can recognize it later.' });
+ }
+
+ const plan = PLANS[account.plan];
+ const active = countActiveKeys(account.id);
+ if (Number.isFinite(plan.maxKeys) && active >= plan.maxKeys) {
+ return fail(403, {
+ error: `Your ${plan.name} plan allows ${plan.maxKeys} active key${
+ plan.maxKeys === 1 ? '' : 's'
+ }. Revoke one or upgrade your plan first.`
+ });
+ }
+
+ const created = createKey({
+ accountId: account.id,
+ name,
+ scopes
+ });
+
+ return {
+ created: {
+ id: created.id,
+ plaintext: created.plaintext,
+ name: created.name
+ }
+ };
+ },
+
+ revoke: async ({ request, locals }) => {
+ const account = locals.account!;
+ const form = await request.formData();
+ const id = (form.get('id') ?? '').toString();
+ if (!id) return fail(400, { error: 'Missing key id.' });
+
+ const ok = revokeKey(account.id, id);
+ if (!ok) return fail(404, { error: 'Key not found.' });
+ return { revokedId: id };
+ }
+};
diff --git a/web/ui/src/routes/dashboard/keys/+page.svelte b/web/ui/src/routes/dashboard/keys/+page.svelte
new file mode 100644
index 0000000..b0a066f
--- /dev/null
+++ b/web/ui/src/routes/dashboard/keys/+page.svelte
@@ -0,0 +1,220 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import { DATASETS } from '$lib/datasets';
+ import { maskKey, scopeSummary } from '$lib/keys';
+ import { pushToast } from '$lib/stores/toasts';
+ import type { PageData, ActionData } from './$types';
+
+ let { data, form }: { data: PageData; form: ActionData } = $props();
+
+ let showCreate = $state(false);
+ let name = $state('');
+ let selected = $state<Set<string>>(new Set());
+ let allScopes = $state(true);
+
+ function toggleChip(slug: string) {
+ if (allScopes) {
+ allScopes = false;
+ selected = new Set([slug]);
+ return;
+ }
+ const next = new Set(selected);
+ if (next.has(slug)) next.delete(slug);
+ else next.add(slug);
+ if (next.size === 0) {
+ allScopes = true;
+ selected = new Set();
+ } else {
+ selected = next;
+ }
+ }
+
+ function selectAll() {
+ allScopes = true;
+ selected = new Set();
+ }
+
+ function fmtDate(ts: number): string {
+ return new Date(ts).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ }
+
+ async function copy(text: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ pushToast('Copied to clipboard', 'success');
+ } catch {
+ pushToast("Couldn't copy — select it manually", 'error');
+ }
+ }
+
+ // When a new key is returned in `form`, show toast + scroll to top.
+ $effect(() => {
+ if (form && 'created' in form && form.created) {
+ pushToast(`Key "${form.created.name}" created`, 'success');
+ showCreate = false;
+ name = '';
+ selected = new Set();
+ allScopes = true;
+ } else if (form && 'revokedId' in form && form.revokedId) {
+ pushToast('Key revoked', 'success');
+ } else if (form && 'error' in form && form.error) {
+ pushToast(form.error, 'error');
+ }
+ });
+</script>
+
+<p class="section-marker">§ 01 &nbsp;&middot;&nbsp; api keys</p>
+<div class="row-between mb-24">
+ <div>
+ <h1 class="page-title">Your API keys.</h1>
+ <p class="page-subtitle">
+ {data.activeCount} active, {data.keys.length - data.activeCount} revoked. Your
+ {data.plan.name} plan allows
+ {Number.isFinite(data.plan.maxKeys) ? data.plan.maxKeys : 'unlimited'}
+ active key{data.plan.maxKeys === 1 ? '' : 's'}.
+ </p>
+ </div>
+ {#if !showCreate}
+ <button class="btn btn-primary" onclick={() => (showCreate = true)}>
+ + New key
+ </button>
+ {/if}
+</div>
+
+{#if form && 'created' in form && form.created}
+ <div class="key-reveal">
+ <div class="key-reveal-head">
+ <span class="badge">New key</span>
+ <strong>Copy this key now — it won't be shown again.</strong>
+ </div>
+ <div class="key-reveal-value">
+ <span style="flex: 1;">{form.created.plaintext}</span>
+ <button class="btn btn-sm btn-ghost" onclick={() => copy(form.created!.plaintext)}>
+ Copy
+ </button>
+ </div>
+ <p class="key-reveal-warn">
+ Store it somewhere safe. Once you leave this page we only keep the hash.
+ </p>
+ </div>
+{/if}
+
+{#if showCreate}
+ <form
+ method="POST"
+ action="?/create"
+ class="card card-accent mb-24"
+ use:enhance
+ >
+ <div class="card-head">
+ <h2 class="card-title">New key</h2>
+ <button type="button" class="btn btn-sm btn-ghost" onclick={() => (showCreate = false)}>
+ Cancel
+ </button>
+ </div>
+
+ <div class="field">
+ <label class="field-label" for="name">name</label>
+ <input
+ id="name"
+ name="name"
+ class="input"
+ placeholder="e.g. production ingest"
+ bind:value={name}
+ required
+ />
+ <p class="help">Just for you — how you'll recognize this key in the list.</p>
+ </div>
+
+ <div class="field">
+ <span class="field-label">dataset scope</span>
+ <div class="chip-grid">
+ <button
+ type="button"
+ class="chip chip-all {allScopes ? 'active' : ''}"
+ onclick={selectAll}
+ >
+ all datasets
+ </button>
+ {#each DATASETS as slug}
+ <button
+ type="button"
+ class="chip {selected.has(slug) ? 'active' : ''}"
+ onclick={() => toggleChip(slug)}
+ >
+ {slug}
+ </button>
+ {/each}
+ </div>
+ <p class="help">
+ {#if allScopes}
+ This key will work against every dataset in the catalog.
+ {:else}
+ This key will only work against the {selected.size} selected dataset{selected.size === 1 ? '' : 's'}.
+ {/if}
+ </p>
+ </div>
+
+ {#if allScopes}
+ <input type="hidden" name="scopes" value="*" />
+ {:else}
+ {#each [...selected] as slug}
+ <input type="hidden" name="scopes" value={slug} />
+ {/each}
+ {/if}
+
+ <div class="row mt-16">
+ <button type="submit" class="btn btn-accent">Create key</button>
+ <button type="button" class="btn btn-ghost" onclick={() => (showCreate = false)}>
+ Cancel
+ </button>
+ </div>
+ </form>
+{/if}
+
+{#if data.keys.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No keys yet.</p>
+ <p>Click "+ New key" to create your first one.</p>
+ </div>
+{:else}
+ {#each data.keys as key (key.id)}
+ <div class="card">
+ <div class="key-row">
+ <div class="key-row-main">
+ <div class="key-name">
+ {key.name}
+ {#if key.active}
+ <span class="badge badge-success">Active</span>
+ {:else}
+ <span class="badge badge-muted">Revoked</span>
+ {/if}
+ </div>
+ <div class="key-mask">{maskKey(key.key_prefix)}</div>
+ <div class="scope-summary">
+ scope: <code>{scopeSummary(key.scopes)}</code>
+ </div>
+ <div class="key-meta">
+ <span><span class="meta-label">created</span> {fmtDate(key.created_at)}</span>
+ {#if key.last_used_at}
+ <span><span class="meta-label">last used</span> {fmtDate(key.last_used_at)}</span>
+ {:else}
+ <span><span class="meta-label">last used</span> never</span>
+ {/if}
+ </div>
+ </div>
+
+ {#if key.active}
+ <form method="POST" action="?/revoke" use:enhance>
+ <input type="hidden" name="id" value={key.id} />
+ <button class="btn btn-sm btn-danger" type="submit">Revoke</button>
+ </form>
+ {/if}
+ </div>
+ </div>
+ {/each}
+{/if}
diff --git a/web/ui/src/routes/dashboard/usage/+page.server.ts b/web/ui/src/routes/dashboard/usage/+page.server.ts
new file mode 100644
index 0000000..34ff002
--- /dev/null
+++ b/web/ui/src/routes/dashboard/usage/+page.server.ts
@@ -0,0 +1,13 @@
+import { PLANS } from '$lib/plans';
+import { usageByDataset, usageByKey, usageCountThisMonth } from '$lib/server/usage';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const account = locals.account!;
+ return {
+ plan: PLANS[account.plan],
+ total: usageCountThisMonth(account.id),
+ byDataset: usageByDataset(account.id),
+ byKey: usageByKey(account.id)
+ };
+};
diff --git a/web/ui/src/routes/dashboard/usage/+page.svelte b/web/ui/src/routes/dashboard/usage/+page.svelte
new file mode 100644
index 0000000..883460c
--- /dev/null
+++ b/web/ui/src/routes/dashboard/usage/+page.svelte
@@ -0,0 +1,152 @@
+<script lang="ts">
+ import { pushToast } from '$lib/stores/toasts';
+ import type { PageData } from './$types';
+
+ let { data }: { data: PageData } = $props();
+
+ const monthName = new Date().toLocaleDateString('en-US', {
+ month: 'long',
+ year: 'numeric'
+ });
+
+ function fmt(n: number): string {
+ return n.toLocaleString('en-US');
+ }
+
+ const curlExample = `curl https://api.tidyindex.com/v1/datasets/irs-990/records/20-0049703 \\
+ -H "Authorization: Bearer YOUR_API_KEY"`;
+
+ async function copyCurl() {
+ try {
+ await navigator.clipboard.writeText(curlExample);
+ pushToast('Copied', 'success');
+ } catch {
+ pushToast("Couldn't copy", 'error');
+ }
+ }
+
+ let pct = $derived.by(() => {
+ if (!Number.isFinite(data.plan.requestsPerMonth)) return 0;
+ const p = (data.total / data.plan.requestsPerMonth) * 100;
+ return Math.min(100, Math.max(0, p));
+ });
+
+ let limitLabel = $derived(
+ Number.isFinite(data.plan.requestsPerMonth)
+ ? fmt(data.plan.requestsPerMonth)
+ : 'unlimited'
+ );
+</script>
+
+<p class="section-marker">§ 02 &nbsp;&middot;&nbsp; usage</p>
+<h1 class="page-title">This month.</h1>
+<p class="page-subtitle">
+ Requests counted against your plan limit for {monthName}.
+</p>
+
+<div class="card">
+ <div class="usage-summary">
+ <div>
+ <p class="text-mono text-mute" style="margin:0 0 6px;">
+ requests / {limitLabel}
+ </p>
+ <p class="usage-count">
+ {fmt(data.total)}
+ {#if Number.isFinite(data.plan.requestsPerMonth)}
+ <small>&nbsp;of {limitLabel}</small>
+ {/if}
+ </p>
+ </div>
+ <div>
+ <span class="badge">{data.plan.name.toLowerCase()} plan</span>
+ </div>
+ </div>
+ {#if Number.isFinite(data.plan.requestsPerMonth)}
+ <div class="usage-bar" aria-label="Usage bar">
+ <div class="usage-bar-fill" style="width: {pct}%"></div>
+ </div>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">By dataset</h2>
+ <p class="card-sub">Which datasets drew the most traffic.</p>
+ </div>
+ {#if data.byDataset.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No requests yet this month.</p>
+ <p>Usage data will show up here once your keys start making calls.</p>
+ </div>
+ {:else}
+ <table class="usage-table">
+ <thead>
+ <tr>
+ <th>dataset</th>
+ <th style="text-align:right;">requests</th>
+ <th style="text-align:right; width: 140px;">share</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each data.byDataset as row}
+ <tr>
+ <td>{row.dataset}</td>
+ <td class="num">{fmt(row.count)}</td>
+ <td class="num">
+ {data.total > 0
+ ? ((row.count / data.total) * 100).toFixed(1)
+ : '0.0'}%
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">By key</h2>
+ <p class="card-sub">Traffic attributed to each of your keys.</p>
+ </div>
+ {#if data.byKey.length === 0}
+ <div class="empty-state">
+ <p class="empty-title">No keys yet.</p>
+ <p>Create one on the Keys tab.</p>
+ </div>
+ {:else}
+ <table class="usage-table">
+ <thead>
+ <tr>
+ <th>key</th>
+ <th style="text-align:right;">requests</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each data.byKey as row}
+ <tr>
+ <td>{row.name}</td>
+ <td class="num">{fmt(row.count)}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">API base URL</h2>
+ </div>
+ <p class="card-sub">All endpoints live under this host.</p>
+ <pre class="code-block mt-16">https://api.tidyindex.com/v1</pre>
+</div>
+
+<div class="card mt-24">
+ <div class="card-head">
+ <h2 class="card-title">Quick start</h2>
+ <button class="btn btn-sm btn-ghost" onclick={copyCurl}>Copy curl</button>
+ </div>
+ <p class="card-sub">Fetch one record from the IRS 990 dataset.</p>
+ <pre class="code-block mt-16">{curlExample}</pre>
+</div>
diff --git a/web/ui/static/favicon.svg b/web/ui/static/favicon.svg
new file mode 100644
index 0000000..256ac13
--- /dev/null
+++ b/web/ui/static/favicon.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <rect width="32" height="32" rx="7" fill="#eef4ff"/>
+ <rect x="6" y="8" width="20" height="3" rx="1.5" fill="#2563eb"/>
+ <rect x="6" y="15" width="15" height="3" rx="1.5" fill="#2563eb" opacity="0.7"/>
+ <rect x="6" y="22" width="10" height="3" rx="1.5" fill="#2563eb" opacity="0.4"/>
+</svg>
diff --git a/web/ui/static/fonts/fraunces-latin.woff2 b/web/ui/static/fonts/fraunces-latin.woff2
new file mode 100644
index 0000000..cb295bf
--- /dev/null
+++ b/web/ui/static/fonts/fraunces-latin.woff2
Binary files differ
diff --git a/web/ui/static/fonts/inter-latin.woff2 b/web/ui/static/fonts/inter-latin.woff2
new file mode 100644
index 0000000..d15208d
--- /dev/null
+++ b/web/ui/static/fonts/inter-latin.woff2
Binary files differ
diff --git a/web/ui/static/fonts/jetbrains-mono-latin.woff2 b/web/ui/static/fonts/jetbrains-mono-latin.woff2
new file mode 100644
index 0000000..4d09cda
--- /dev/null
+++ b/web/ui/static/fonts/jetbrains-mono-latin.woff2
Binary files differ
diff --git a/web/ui/svelte.config.js b/web/ui/svelte.config.js
new file mode 100644
index 0000000..139266d
--- /dev/null
+++ b/web/ui/svelte.config.js
@@ -0,0 +1,15 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter(),
+ alias: {
+ $lib: 'src/lib'
+ }
+ }
+};
+
+export default config;
diff --git a/web/ui/tsconfig.json b/web/ui/tsconfig.json
new file mode 100644
index 0000000..85e9194
--- /dev/null
+++ b/web/ui/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "types": ["node"]
+ }
+}
diff --git a/web/ui/vite.config.js b/web/ui/vite.config.js
new file mode 100644
index 0000000..7c321da
--- /dev/null
+++ b/web/ui/vite.config.js
@@ -0,0 +1,9 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ server: {
+ port: 5174
+ }
+});