diff options
Diffstat (limited to '')
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 — 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 Index</span> + </a> + + <div class="app-header-meta"> + <span class="plan-chip"> + plan: <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 · {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. + <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 <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">§ {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 · 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> {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 · 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 · 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> 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 Binary files differnew file mode 100644 index 0000000..cb295bf --- /dev/null +++ b/web/ui/static/fonts/fraunces-latin.woff2 diff --git a/web/ui/static/fonts/inter-latin.woff2 b/web/ui/static/fonts/inter-latin.woff2 Binary files differnew file mode 100644 index 0000000..d15208d --- /dev/null +++ b/web/ui/static/fonts/inter-latin.woff2 diff --git a/web/ui/static/fonts/jetbrains-mono-latin.woff2 b/web/ui/static/fonts/jetbrains-mono-latin.woff2 Binary files differnew file mode 100644 index 0000000..4d09cda --- /dev/null +++ b/web/ui/static/fonts/jetbrains-mono-latin.woff2 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 + } +}); |
