From d2133aa457ca65a638503571e1f33bc04203764a Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 26 Jan 2026 00:22:05 +1030 Subject: [PATCH] feat(auth): implement authentication system - Username/password login with bcrypt and session cookies - API key authentication (X-Api-Key header or ?apikey query param) - AUTH env var modes: on, local, off, oidc - Generic OIDC support for external providers - Session metadata tracking (IP, browser, device) - Security settings page (password, sessions, API key) - Login analysis with typo and attack detection - Auth event logging throughout --- .gitignore | 6 + README.md | 10 + deno.json | 10 +- deno.lock | 2039 +---------------- docs/todo/auth.md | 1130 +++++++++ package-lock.json | 1 + src/app.d.ts | 8 +- src/deno.d.ts | 6 + src/hooks.server.ts | 76 + src/lib/client/ui/form/FormInput.svelte | 49 +- .../ui/navigation/pageNav/pageNav.svelte | 2 + src/lib/client/ui/table/Table.svelte | 3 +- src/lib/server/db/migrations.ts | 6 +- .../db/migrations/036_create_auth_tables.ts | 58 + .../db/migrations/037_add_session_metadata.ts | 47 + src/lib/server/db/queries/authSettings.ts | 104 + src/lib/server/db/queries/sessions.ts | 145 ++ src/lib/server/db/queries/users.ts | 122 + src/lib/server/db/schema.sql | 56 +- src/lib/server/utils/auth/README.md | 207 ++ src/lib/server/utils/auth/apiKey.ts | 12 + src/lib/server/utils/auth/loginAnalysis.ts | 126 + src/lib/server/utils/auth/middleware.ts | 152 ++ src/lib/server/utils/auth/network.ts | 136 ++ src/lib/server/utils/auth/oidc.ts | 194 ++ src/lib/server/utils/auth/password.ts | 20 + src/lib/server/utils/auth/userAgent.ts | 151 ++ src/lib/server/utils/config/config.ts | 21 + src/routes/+layout.svelte | 42 +- .../upgrades/components/FilterGroup.svelte | 4 +- src/routes/auth/login/+page.server.ts | 99 + src/routes/auth/login/+page.svelte | 93 + src/routes/auth/logout/+server.ts | 26 + src/routes/auth/oidc/callback/+server.ts | 118 + src/routes/auth/oidc/login/+server.ts | 58 + src/routes/auth/setup/+page.server.ts | 105 + src/routes/auth/setup/+page.svelte | 125 + src/routes/settings/security/+page.server.ts | 145 ++ src/routes/settings/security/+page.svelte | 326 +++ svelte.config.js | 3 +- vite.config.ts | 1 + 41 files changed, 3984 insertions(+), 2058 deletions(-) create mode 100644 docs/todo/auth.md create mode 100644 src/lib/server/db/migrations/036_create_auth_tables.ts create mode 100644 src/lib/server/db/migrations/037_add_session_metadata.ts create mode 100644 src/lib/server/db/queries/authSettings.ts create mode 100644 src/lib/server/db/queries/sessions.ts create mode 100644 src/lib/server/db/queries/users.ts create mode 100644 src/lib/server/utils/auth/README.md create mode 100644 src/lib/server/utils/auth/apiKey.ts create mode 100644 src/lib/server/utils/auth/loginAnalysis.ts create mode 100644 src/lib/server/utils/auth/middleware.ts create mode 100644 src/lib/server/utils/auth/network.ts create mode 100644 src/lib/server/utils/auth/oidc.ts create mode 100644 src/lib/server/utils/auth/password.ts create mode 100644 src/lib/server/utils/auth/userAgent.ts create mode 100644 src/routes/auth/login/+page.server.ts create mode 100644 src/routes/auth/login/+page.svelte create mode 100644 src/routes/auth/logout/+server.ts create mode 100644 src/routes/auth/oidc/callback/+server.ts create mode 100644 src/routes/auth/oidc/login/+server.ts create mode 100644 src/routes/auth/setup/+page.server.ts create mode 100644 src/routes/auth/setup/+page.svelte create mode 100644 src/routes/settings/security/+page.server.ts create mode 100644 src/routes/settings/security/+page.svelte diff --git a/.gitignore b/.gitignore index fb0805b..bcb6f28 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,9 @@ obj/ # Bruno environments (contain API keys) bruno/environments/ + +# OIDC testing (local Keycloak config) +test/oidc/ + +# Research repos +research/ diff --git a/README.md b/README.md index 6e130a2..35d083a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ - **Testing** - Validate regex patterns, custom format conditions, and quality profile behavior before syncing +**Authentication** + +- `AUTH=on` (default) - Username/password login required +- `AUTH=local` - Skip auth for local network requests +- `AUTH=oidc` - SSO via OpenID Connect provider +- `AUTH=off` - No authentication (use with external auth like Authentik/Authelia) + +API access via `X-Api-Key` header or `?apikey=` query param. See +[auth docs](src/lib/server/utils/auth/README.md) for details. + ## Discord We're most active on [Discord](https://discord.gg/2A89tXZMgA), where we post diff --git a/deno.json b/deno.json index fffa233..98df548 100644 --- a/deno.json +++ b/deno.json @@ -19,16 +19,20 @@ "$notifications/": "./src/lib/server/notifications/", "$sync/": "./src/lib/server/sync/", "$cache/": "./src/lib/server/utils/cache/", + "$auth/": "./src/lib/server/utils/auth/", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@^1.0.0", "marked": "npm:marked@^15.0.6", "simple-icons": "npm:simple-icons@^15.17.0", "highlight.js": "npm:highlight.js@^11.11.1", - "croner": "npm:croner@^8.1.2", - "@std/yaml": "jsr:@std/yaml@^1.0.10" + "croner": "npm:croner@^9.1.0", + "@std/yaml": "jsr:@std/yaml@^1.0.10", + "@felix/bcrypt": "jsr:@felix/bcrypt@^1.0.8" }, "tasks": { "dev": "deno run -A scripts/dev.ts", + "dev:noauth": "AUTH=local deno run -A scripts/dev.ts", + "dev:oidc": "AUTH=oidc OIDC_DISCOVERY_URL=http://localhost:8080/realms/profilarr/.well-known/openid-configuration OIDC_CLIENT_ID=profilarr OIDC_CLIENT_SECRET=secret deno run -A scripts/dev.ts", "dev:server": "DENO_ENV=development PORT=6969 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 VITE_PLATFORM=linux-amd64 VITE_CHANNEL=dev deno run -A npm:vite dev", "dev:parser": "cd src/services/parser && dotnet watch run --urls http://localhost:5000", "build": "APP_BASE_PATH=./dist/build deno run -A npm:vite build && deno compile --no-check --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --allow-sys --target x86_64-unknown-linux-gnu --output dist/build/profilarr dist/build/mod.ts", @@ -37,7 +41,7 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint .", "check": "deno task check:server && deno task check:client", - "check:server": "deno check src/lib/server/**/*.ts", + "check:server": "deno check --quiet src/lib/server/**/*.ts", "check:client": "npx svelte-check --tsconfig ./tsconfig.json", "test": "deno run -A scripts/test.ts", "test:watch": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env --watch", diff --git a/deno.lock b/deno.lock index 957fac1..4a4cd3f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,63 +1,18 @@ { "version": "5", "specifiers": { - "jsr:@db/sqlite@0.12": "0.12.0", - "jsr:@denosaurs/plug@1": "1.1.0", - "jsr:@soapbox/kysely-deno-sqlite@*": "2.2.0", + "jsr:@denosaurs/plug@^1.1.0": "1.1.0", + "jsr:@felix/bcrypt@^1.0.8": "1.0.8", "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", - "jsr:@std/assert@*": "1.0.15", - "jsr:@std/assert@0.217": "0.217.0", - "jsr:@std/assert@1": "1.0.15", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fs@1": "1.0.19", "jsr:@std/internal@^1.0.10": "1.0.12", - "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/internal@^1.0.9": "1.0.12", - "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.2", - "jsr:@std/path@^1.1.1": "1.1.2", - "jsr:@std/yaml@^1.0.10": "1.0.10", - "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", - "npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.39.1", - "npm:@eslint/js@^9.36.0": "9.39.1", - "npm:@jsr/db__sqlite@0.12": "0.12.0", - "npm:@sveltejs/kit@^2.43.2": "2.48.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__@types+node@22.19.0_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.0", - "npm:@sveltejs/vite-plugin-svelte@^6.2.0": "6.2.1_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", - "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.16", - "npm:@tailwindcss/vite@^4.1.13": "4.1.16_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", - "npm:@types/node@22": "22.19.0", - "npm:croner@^8.1.2": "8.1.2", - "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.1", - "npm:eslint-plugin-svelte@^3.12.4": "3.13.0_eslint@9.39.1_svelte@5.43.3__acorn@8.15.0_postcss@8.5.6", - "npm:eslint@^9.36.0": "9.39.1", - "npm:globals@^16.4.0": "16.5.0", - "npm:highlight.js@^11.11.1": "11.11.1", - "npm:kysely@0.27.6": "0.27.6", - "npm:kysely@~0.27.2": "0.27.6", - "npm:lucide-svelte@0.546": "0.546.0_svelte@5.43.3__acorn@8.15.0", - "npm:marked@^15.0.6": "15.0.12", - "npm:openapi-typescript@7": "7.10.1_typescript@5.9.3", - "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.43.3__acorn@8.15.0", - "npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.43.3___acorn@8.15.0_svelte@5.43.3__acorn@8.15.0", - "npm:prettier@^3.6.2": "3.6.2", - "npm:simple-icons@^15.17.0": "15.17.0", - "npm:svelte-check@^4.3.2": "4.3.3_svelte@5.43.3__acorn@8.15.0_typescript@5.9.3", - "npm:svelte@^5.39.5": "5.43.3_acorn@8.15.0", - "npm:sveltekit-adapter-deno@~0.16.1": "0.16.1_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.3____acorn@8.15.0___vite@7.1.12____@types+node@22.19.0____picomatch@4.0.3___@types+node@22.19.0__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__acorn@8.15.0__@types+node@22.19.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__@types+node@22.19.0_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", - "npm:tailwindcss@^4.1.13": "4.1.16", - "npm:typescript-eslint@^8.44.1": "8.46.3_eslint@9.39.1_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.1__typescript@5.9.3", - "npm:typescript@^5.9.2": "5.9.3", - "npm:vite@^7.1.7": "7.1.12_@types+node@22.19.0_picomatch@4.0.3" + "jsr:@std/path@^1.1.1": "1.1.2" }, "jsr": { - "@db/sqlite@0.12.0": { - "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", - "dependencies": [ - "jsr:@denosaurs/plug", - "jsr:@std/path@0.217" - ] - }, "@denosaurs/plug@1.1.0": { "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", "dependencies": [ @@ -67,20 +22,14 @@ "jsr:@std/path@1" ] }, + "@felix/bcrypt@1.0.8": { + "integrity": "59c41160fc027882479c512db5d53792c4d91aadcd49467c85caa2f1679046f2", + "dependencies": [ + "jsr:@denosaurs/plug" + ] + }, "@soapbox/kysely-deno-sqlite@2.2.0": { - "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", - "dependencies": [ - "npm:kysely@~0.27.2" - ] - }, - "@std/assert@0.217.0": { - "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" - }, - "@std/assert@1.0.15": { - "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] + "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a" }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" @@ -98,1984 +47,20 @@ "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, - "@std/path@0.217.0": { - "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", - "dependencies": [ - "jsr:@std/assert@0.217" - ] - }, "@std/path@1.1.2": { "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", "dependencies": [ "jsr:@std/internal@^1.0.10" ] - }, - "@std/yaml@1.0.10": { - "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" - } - }, - "npm": { - "@babel/code-frame@7.28.6": { - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "dependencies": [ - "@babel/helper-validator-identifier", - "js-tokens", - "picocolors" - ] - }, - "@babel/helper-validator-identifier@7.28.5": { - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" - }, - "@deno/vite-plugin@1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==", - "dependencies": [ - "vite" - ] - }, - "@esbuild/aix-ppc64@0.24.2": { - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/aix-ppc64@0.25.12": { - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/android-arm64@0.24.2": { - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm64@0.25.12": { - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm@0.24.2": { - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-arm@0.25.12": { - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-x64@0.24.2": { - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/android-x64@0.25.12": { - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/darwin-arm64@0.24.2": { - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-arm64@0.25.12": { - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-x64@0.24.2": { - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/darwin-x64@0.25.12": { - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-arm64@0.24.2": { - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-arm64@0.25.12": { - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-x64@0.24.2": { - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-x64@0.25.12": { - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/linux-arm64@0.24.2": { - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm64@0.25.12": { - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm@0.24.2": { - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-arm@0.25.12": { - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-ia32@0.24.2": { - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-ia32@0.25.12": { - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-loong64@0.24.2": { - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-loong64@0.25.12": { - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-mips64el@0.24.2": { - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-mips64el@0.25.12": { - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-ppc64@0.24.2": { - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-ppc64@0.25.12": { - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-riscv64@0.24.2": { - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-riscv64@0.25.12": { - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-s390x@0.24.2": { - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-s390x@0.25.12": { - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-x64@0.24.2": { - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/linux-x64@0.25.12": { - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.24.2": { - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-arm64@0.25.12": { - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-x64@0.24.2": { - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-x64@0.25.12": { - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-arm64@0.24.2": { - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-arm64@0.25.12": { - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-x64@0.24.2": { - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-x64@0.25.12": { - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openharmony-arm64@0.25.12": { - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@esbuild/sunos-x64@0.24.2": { - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/sunos-x64@0.25.12": { - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/win32-arm64@0.24.2": { - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-arm64@0.25.12": { - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-ia32@0.24.2": { - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-ia32@0.25.12": { - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-x64@0.24.2": { - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@esbuild/win32-x64@0.25.12": { - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@eslint-community/eslint-utils@4.9.0_eslint@9.39.1": { - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dependencies": [ - "eslint", - "eslint-visitor-keys@3.4.3" - ] - }, - "@eslint-community/regexpp@4.12.2": { - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==" - }, - "@eslint/compat@1.4.1_eslint@9.39.1": { - "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", - "dependencies": [ - "@eslint/core", - "eslint" - ], - "optionalPeers": [ - "eslint" - ] - }, - "@eslint/config-array@0.21.1": { - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dependencies": [ - "@eslint/object-schema", - "debug", - "minimatch@3.1.2" - ] - }, - "@eslint/config-helpers@0.4.2": { - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dependencies": [ - "@eslint/core" - ] - }, - "@eslint/core@0.17.0": { - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dependencies": [ - "@types/json-schema" - ] - }, - "@eslint/eslintrc@3.3.1": { - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dependencies": [ - "ajv", - "debug", - "espree", - "globals@14.0.0", - "ignore@5.3.2", - "import-fresh", - "js-yaml", - "minimatch@3.1.2", - "strip-json-comments" - ] - }, - "@eslint/js@9.39.1": { - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==" - }, - "@eslint/object-schema@2.1.7": { - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==" - }, - "@eslint/plugin-kit@0.4.1": { - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dependencies": [ - "@eslint/core", - "levn" - ] - }, - "@humanfs/core@0.19.1": { - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" - }, - "@humanfs/node@0.16.7": { - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dependencies": [ - "@humanfs/core", - "@humanwhocodes/retry" - ] - }, - "@humanwhocodes/module-importer@1.0.1": { - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" - }, - "@humanwhocodes/retry@0.4.3": { - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" - }, - "@jridgewell/gen-mapping@0.3.13": { - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dependencies": [ - "@jridgewell/sourcemap-codec", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/remapping@2.3.5": { - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dependencies": [ - "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/resolve-uri@3.1.2": { - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" - }, - "@jridgewell/sourcemap-codec@1.5.5": { - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" - }, - "@jridgewell/trace-mapping@0.3.31": { - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dependencies": [ - "@jridgewell/resolve-uri", - "@jridgewell/sourcemap-codec" - ] - }, - "@jsr/db__sqlite@0.12.0": { - "integrity": "sha512-nTMYOzEl8oFhtPS90tAdMbpYTec7/brHtlVLt8afAsNLX+z1FkEIWljxqJS8UDzN23wCTsNXL96NArqBaJRy9A==", - "dependencies": [ - "@jsr/denosaurs__plug", - "@jsr/std__path@0.217.0" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/db__sqlite/0.12.0.tgz" - }, - "@jsr/denosaurs__plug@1.1.0": { - "integrity": "sha512-GNRMr8XcYWbv8C1B5OjDa5u8q3p2lz7YVWQLhH5HAy0pkpb0+Y3npSxzjM49v5ajTFIzUCwIKv1gQukPm9q7qw==", - "dependencies": [ - "@jsr/std__encoding", - "@jsr/std__fmt@1.0.8", - "@jsr/std__fs", - "@jsr/std__path@1.1.2" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/denosaurs__plug/1.1.0.tgz" - }, - "@jsr/std__assert@0.217.0": { - "integrity": "sha512-kCGfcXX8lMcZHWrCgFhbdpNloB50MkLwHdRZvZKjZK424F9g+M742jkTDLMOJmwkDoEqFKyNVrGhPtspS4+NvQ==", - "dependencies": [ - "@jsr/std__fmt@0.217.0" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/0.217.0.tgz" - }, - "@jsr/std__encoding@1.0.10": { - "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz" - }, - "@jsr/std__fmt@0.217.0": { - "integrity": "sha512-AM33Cr/V3St3Cj5O4QQe4aMKGyzL9eYz+mOC58BmqxgeZwFbvSC06DzM2DS3ixcsAnwH2kYMXHpCBax0sT9q8Q==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/0.217.0.tgz" - }, - "@jsr/std__fmt@1.0.8": { - "integrity": "sha512-miZHzj9OgjuajrcMKzpqNVwFb9O71UHZzV/FHVq0E0Uwmv/1JqXgmXAoBNPrn+MP0fHT3mMgaZ6XvQO7dam67Q==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.8.tgz" - }, - "@jsr/std__fs@1.0.19": { - "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==", - "dependencies": [ - "@jsr/std__internal", - "@jsr/std__path@1.1.2" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz" - }, - "@jsr/std__internal@1.0.12": { - "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz" - }, - "@jsr/std__path@0.217.0": { - "integrity": "sha512-KoqEpZX9CE8zyyr4+X6AROOGYv95AysnJni2E5g9pqG+IGUUuNjOC3yRTvHnsB5tJ6uQs6DwET5chIdUPcylIQ==", - "dependencies": [ - "@jsr/std__assert" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/0.217.0.tgz" - }, - "@jsr/std__path@1.1.2": { - "integrity": "sha512-5hkOR1s5M7am02Bn9KS+SNMNwUSivz7t7/w2HBhFIfO7Eh8+mWilaZ+1tdanV9aaSHr4c99Zo4Da+cCSuzUOdA==", - "dependencies": [ - "@jsr/std__internal" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.2.tgz" - }, - "@nodelib/fs.scandir@2.1.5": { - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": [ - "@nodelib/fs.stat", - "run-parallel" - ] - }, - "@nodelib/fs.stat@2.0.5": { - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk@1.2.8": { - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": [ - "@nodelib/fs.scandir", - "fastq" - ] - }, - "@polka/url@1.0.0-next.29": { - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" - }, - "@redocly/ajv@8.17.1": { - "integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==", - "dependencies": [ - "fast-deep-equal", - "fast-uri", - "json-schema-traverse@1.0.0", - "require-from-string" - ] - }, - "@redocly/config@0.22.2": { - "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==" - }, - "@redocly/openapi-core@1.34.6": { - "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", - "dependencies": [ - "@redocly/ajv", - "@redocly/config", - "colorette", - "https-proxy-agent", - "js-levenshtein", - "js-yaml", - "minimatch@5.1.6", - "pluralize", - "yaml-ast-parser" - ] - }, - "@rollup/rollup-android-arm-eabi@4.52.5": { - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", - "os": ["android"], - "cpu": ["arm"] - }, - "@rollup/rollup-android-arm64@4.52.5": { - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@rollup/rollup-darwin-arm64@4.52.5": { - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@rollup/rollup-darwin-x64@4.52.5": { - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@rollup/rollup-freebsd-arm64@4.52.5": { - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@rollup/rollup-freebsd-x64@4.52.5": { - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@rollup/rollup-linux-arm-gnueabihf@4.52.5": { - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@rollup/rollup-linux-arm-musleabihf@4.52.5": { - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@rollup/rollup-linux-arm64-gnu@4.52.5": { - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rollup/rollup-linux-arm64-musl@4.52.5": { - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rollup/rollup-linux-loong64-gnu@4.52.5": { - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@rollup/rollup-linux-ppc64-gnu@4.52.5": { - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@rollup/rollup-linux-riscv64-gnu@4.52.5": { - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@rollup/rollup-linux-riscv64-musl@4.52.5": { - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@rollup/rollup-linux-s390x-gnu@4.52.5": { - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@rollup/rollup-linux-x64-gnu@4.52.5": { - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rollup/rollup-linux-x64-musl@4.52.5": { - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rollup/rollup-openharmony-arm64@4.52.5": { - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@rollup/rollup-win32-arm64-msvc@4.52.5": { - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@rollup/rollup-win32-ia32-msvc@4.52.5": { - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@rollup/rollup-win32-x64-gnu@4.52.5": { - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@rollup/rollup-win32-x64-msvc@4.52.5": { - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@standard-schema/spec@1.0.0": { - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" - }, - "@sveltejs/acorn-typescript@1.0.6_acorn@8.15.0": { - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", - "dependencies": [ - "acorn" - ] - }, - "@sveltejs/kit@2.48.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__@types+node@22.19.0_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_acorn@8.15.0_@types+node@22.19.0": { - "integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==", - "dependencies": [ - "@standard-schema/spec", - "@sveltejs/acorn-typescript", - "@sveltejs/vite-plugin-svelte", - "@types/cookie", - "acorn", - "cookie", - "devalue", - "esm-env", - "kleur", - "magic-string", - "mrmime", - "sade", - "set-cookie-parser", - "sirv", - "svelte", - "vite" - ], - "bin": true - }, - "@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__@types+node@22.19.0_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", - "dependencies": [ - "@sveltejs/vite-plugin-svelte", - "debug", - "svelte", - "vite" - ] - }, - "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", - "dependencies": [ - "@sveltejs/vite-plugin-svelte-inspector", - "debug", - "deepmerge", - "magic-string", - "svelte", - "vite", - "vitefu" - ] - }, - "@tailwindcss/forms@0.5.10_tailwindcss@4.1.16": { - "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", - "dependencies": [ - "mini-svg-data-uri", - "tailwindcss" - ] - }, - "@tailwindcss/node@4.1.16": { - "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", - "dependencies": [ - "@jridgewell/remapping", - "enhanced-resolve", - "jiti", - "lightningcss", - "magic-string", - "source-map-js", - "tailwindcss" - ] - }, - "@tailwindcss/oxide-android-arm64@4.1.16": { - "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-darwin-arm64@4.1.16": { - "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-darwin-x64@4.1.16": { - "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-freebsd-x64@4.1.16": { - "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16": { - "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@tailwindcss/oxide-linux-arm64-gnu@4.1.16": { - "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-linux-arm64-musl@4.1.16": { - "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-linux-x64-gnu@4.1.16": { - "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-linux-x64-musl@4.1.16": { - "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-wasm32-wasi@4.1.16": { - "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", - "cpu": ["wasm32"] - }, - "@tailwindcss/oxide-win32-arm64-msvc@4.1.16": { - "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-win32-x64-msvc@4.1.16": { - "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide@4.1.16": { - "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", - "optionalDependencies": [ - "@tailwindcss/oxide-android-arm64", - "@tailwindcss/oxide-darwin-arm64", - "@tailwindcss/oxide-darwin-x64", - "@tailwindcss/oxide-freebsd-x64", - "@tailwindcss/oxide-linux-arm-gnueabihf", - "@tailwindcss/oxide-linux-arm64-gnu", - "@tailwindcss/oxide-linux-arm64-musl", - "@tailwindcss/oxide-linux-x64-gnu", - "@tailwindcss/oxide-linux-x64-musl", - "@tailwindcss/oxide-wasm32-wasi", - "@tailwindcss/oxide-win32-arm64-msvc", - "@tailwindcss/oxide-win32-x64-msvc" - ] - }, - "@tailwindcss/vite@4.1.16_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==", - "dependencies": [ - "@tailwindcss/node", - "@tailwindcss/oxide", - "tailwindcss", - "vite" - ] - }, - "@types/cookie@0.6.0": { - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" - }, - "@types/estree@1.0.8": { - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" - }, - "@types/json-schema@7.0.15": { - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" - }, - "@types/node@22.19.0": { - "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", - "dependencies": [ - "undici-types" - ] - }, - "@typescript-eslint/eslint-plugin@8.46.3_@typescript-eslint+parser@8.46.3__eslint@9.39.1__typescript@5.9.3_eslint@9.39.1_typescript@5.9.3": { - "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", - "dependencies": [ - "@eslint-community/regexpp", - "@typescript-eslint/parser", - "@typescript-eslint/scope-manager", - "@typescript-eslint/type-utils", - "@typescript-eslint/utils", - "@typescript-eslint/visitor-keys", - "eslint", - "graphemer", - "ignore@7.0.5", - "natural-compare", - "ts-api-utils", - "typescript" - ] - }, - "@typescript-eslint/parser@8.46.3_eslint@9.39.1_typescript@5.9.3": { - "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", - "dependencies": [ - "@typescript-eslint/scope-manager", - "@typescript-eslint/types", - "@typescript-eslint/typescript-estree", - "@typescript-eslint/visitor-keys", - "debug", - "eslint", - "typescript" - ] - }, - "@typescript-eslint/project-service@8.46.3_typescript@5.9.3": { - "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", - "dependencies": [ - "@typescript-eslint/tsconfig-utils", - "@typescript-eslint/types", - "debug", - "typescript" - ] - }, - "@typescript-eslint/scope-manager@8.46.3": { - "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", - "dependencies": [ - "@typescript-eslint/types", - "@typescript-eslint/visitor-keys" - ] - }, - "@typescript-eslint/tsconfig-utils@8.46.3_typescript@5.9.3": { - "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", - "dependencies": [ - "typescript" - ] - }, - "@typescript-eslint/type-utils@8.46.3_eslint@9.39.1_typescript@5.9.3": { - "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", - "dependencies": [ - "@typescript-eslint/types", - "@typescript-eslint/typescript-estree", - "@typescript-eslint/utils", - "debug", - "eslint", - "ts-api-utils", - "typescript" - ] - }, - "@typescript-eslint/types@8.46.3": { - "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==" - }, - "@typescript-eslint/typescript-estree@8.46.3_typescript@5.9.3": { - "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", - "dependencies": [ - "@typescript-eslint/project-service", - "@typescript-eslint/tsconfig-utils", - "@typescript-eslint/types", - "@typescript-eslint/visitor-keys", - "debug", - "fast-glob", - "is-glob", - "minimatch@9.0.5", - "semver", - "ts-api-utils", - "typescript" - ] - }, - "@typescript-eslint/utils@8.46.3_eslint@9.39.1_typescript@5.9.3": { - "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", - "dependencies": [ - "@eslint-community/eslint-utils", - "@typescript-eslint/scope-manager", - "@typescript-eslint/types", - "@typescript-eslint/typescript-estree", - "eslint", - "typescript" - ] - }, - "@typescript-eslint/visitor-keys@8.46.3": { - "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", - "dependencies": [ - "@typescript-eslint/types", - "eslint-visitor-keys@4.2.1" - ] - }, - "acorn-jsx@5.3.2_acorn@8.15.0": { - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dependencies": [ - "acorn" - ] - }, - "acorn@8.15.0": { - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "bin": true - }, - "agent-base@7.1.4": { - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" - }, - "ajv@6.12.6": { - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": [ - "fast-deep-equal", - "fast-json-stable-stringify", - "json-schema-traverse@0.4.1", - "uri-js" - ] - }, - "ansi-colors@4.1.3": { - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" - }, - "ansi-styles@4.3.0": { - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": [ - "color-convert" - ] - }, - "argparse@2.0.1": { - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "aria-query@5.3.2": { - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" - }, - "axobject-query@4.1.0": { - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" - }, - "balanced-match@1.0.2": { - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "brace-expansion@1.1.12": { - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dependencies": [ - "balanced-match", - "concat-map" - ] - }, - "brace-expansion@2.0.2": { - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dependencies": [ - "balanced-match" - ] - }, - "braces@3.0.3": { - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": [ - "fill-range" - ] - }, - "callsites@3.1.0": { - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "chalk@4.1.2": { - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": [ - "ansi-styles", - "supports-color@7.2.0" - ] - }, - "change-case@5.4.4": { - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==" - }, - "chokidar@4.0.3": { - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dependencies": [ - "readdirp" - ] - }, - "clsx@2.1.1": { - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" - }, - "color-convert@2.0.1": { - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": [ - "color-name" - ] - }, - "color-name@1.1.4": { - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "colorette@1.4.0": { - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==" - }, - "concat-map@0.0.1": { - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "cookie@0.6.0": { - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" - }, - "croner@8.1.2": { - "integrity": "sha512-ypfPFcAXHuAZRCzo3vJL6ltENzniTjwe/qsLleH1V2/7SRDjgvRQyrLmumFTLmjFax4IuSxfGXEn79fozXcJog==" - }, - "cross-spawn@7.0.6": { - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dependencies": [ - "path-key", - "shebang-command", - "which" - ] - }, - "cssesc@3.0.0": { - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": true - }, - "debug@4.4.3": { - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dependencies": [ - "ms" - ] - }, - "deep-is@0.1.4": { - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" - }, - "deepmerge@4.3.1": { - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" - }, - "detect-libc@2.1.2": { - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" - }, - "devalue@5.4.2": { - "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==" - }, - "enhanced-resolve@5.18.3": { - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dependencies": [ - "graceful-fs", - "tapable" - ] - }, - "esbuild@0.24.2": { - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "optionalDependencies": [ - "@esbuild/aix-ppc64@0.24.2", - "@esbuild/android-arm@0.24.2", - "@esbuild/android-arm64@0.24.2", - "@esbuild/android-x64@0.24.2", - "@esbuild/darwin-arm64@0.24.2", - "@esbuild/darwin-x64@0.24.2", - "@esbuild/freebsd-arm64@0.24.2", - "@esbuild/freebsd-x64@0.24.2", - "@esbuild/linux-arm@0.24.2", - "@esbuild/linux-arm64@0.24.2", - "@esbuild/linux-ia32@0.24.2", - "@esbuild/linux-loong64@0.24.2", - "@esbuild/linux-mips64el@0.24.2", - "@esbuild/linux-ppc64@0.24.2", - "@esbuild/linux-riscv64@0.24.2", - "@esbuild/linux-s390x@0.24.2", - "@esbuild/linux-x64@0.24.2", - "@esbuild/netbsd-arm64@0.24.2", - "@esbuild/netbsd-x64@0.24.2", - "@esbuild/openbsd-arm64@0.24.2", - "@esbuild/openbsd-x64@0.24.2", - "@esbuild/sunos-x64@0.24.2", - "@esbuild/win32-arm64@0.24.2", - "@esbuild/win32-ia32@0.24.2", - "@esbuild/win32-x64@0.24.2" - ], - "scripts": true, - "bin": true - }, - "esbuild@0.25.12": { - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "optionalDependencies": [ - "@esbuild/aix-ppc64@0.25.12", - "@esbuild/android-arm@0.25.12", - "@esbuild/android-arm64@0.25.12", - "@esbuild/android-x64@0.25.12", - "@esbuild/darwin-arm64@0.25.12", - "@esbuild/darwin-x64@0.25.12", - "@esbuild/freebsd-arm64@0.25.12", - "@esbuild/freebsd-x64@0.25.12", - "@esbuild/linux-arm@0.25.12", - "@esbuild/linux-arm64@0.25.12", - "@esbuild/linux-ia32@0.25.12", - "@esbuild/linux-loong64@0.25.12", - "@esbuild/linux-mips64el@0.25.12", - "@esbuild/linux-ppc64@0.25.12", - "@esbuild/linux-riscv64@0.25.12", - "@esbuild/linux-s390x@0.25.12", - "@esbuild/linux-x64@0.25.12", - "@esbuild/netbsd-arm64@0.25.12", - "@esbuild/netbsd-x64@0.25.12", - "@esbuild/openbsd-arm64@0.25.12", - "@esbuild/openbsd-x64@0.25.12", - "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64@0.25.12", - "@esbuild/win32-arm64@0.25.12", - "@esbuild/win32-ia32@0.25.12", - "@esbuild/win32-x64@0.25.12" - ], - "scripts": true, - "bin": true - }, - "escape-string-regexp@4.0.0": { - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" - }, - "eslint-config-prettier@10.1.8_eslint@9.39.1": { - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dependencies": [ - "eslint" - ], - "bin": true - }, - "eslint-plugin-svelte@3.13.0_eslint@9.39.1_svelte@5.43.3__acorn@8.15.0_postcss@8.5.6": { - "integrity": "sha512-2ohCCQJJTNbIpQCSDSTWj+FN0OVfPmSO03lmSNT7ytqMaWF6kpT86LdzDqtm4sh7TVPl/OEWJ/d7R87bXP2Vjg==", - "dependencies": [ - "@eslint-community/eslint-utils", - "@jridgewell/sourcemap-codec", - "eslint", - "esutils", - "globals@16.5.0", - "known-css-properties", - "postcss", - "postcss-load-config", - "postcss-safe-parser", - "semver", - "svelte", - "svelte-eslint-parser" - ], - "optionalPeers": [ - "svelte" - ] - }, - "eslint-scope@8.4.0": { - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dependencies": [ - "esrecurse", - "estraverse" - ] - }, - "eslint-visitor-keys@3.4.3": { - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" - }, - "eslint-visitor-keys@4.2.1": { - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==" - }, - "eslint@9.39.1": { - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dependencies": [ - "@eslint-community/eslint-utils", - "@eslint-community/regexpp", - "@eslint/config-array", - "@eslint/config-helpers", - "@eslint/core", - "@eslint/eslintrc", - "@eslint/js", - "@eslint/plugin-kit", - "@humanfs/node", - "@humanwhocodes/module-importer", - "@humanwhocodes/retry", - "@types/estree", - "ajv", - "chalk", - "cross-spawn", - "debug", - "escape-string-regexp", - "eslint-scope", - "eslint-visitor-keys@4.2.1", - "espree", - "esquery", - "esutils", - "fast-deep-equal", - "file-entry-cache", - "find-up", - "glob-parent@6.0.2", - "ignore@5.3.2", - "imurmurhash", - "is-glob", - "json-stable-stringify-without-jsonify", - "lodash.merge", - "minimatch@3.1.2", - "natural-compare", - "optionator" - ], - "bin": true - }, - "esm-env@1.2.2": { - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" - }, - "espree@10.4.0_acorn@8.15.0": { - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dependencies": [ - "acorn", - "acorn-jsx", - "eslint-visitor-keys@4.2.1" - ] - }, - "esquery@1.6.0": { - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dependencies": [ - "estraverse" - ] - }, - "esrap@2.1.2": { - "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "esrecurse@4.3.0": { - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dependencies": [ - "estraverse" - ] - }, - "estraverse@5.3.0": { - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" - }, - "esutils@2.0.3": { - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" - }, - "fast-deep-equal@3.1.3": { - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-glob@3.3.3": { - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dependencies": [ - "@nodelib/fs.stat", - "@nodelib/fs.walk", - "glob-parent@5.1.2", - "merge2", - "micromatch" - ] - }, - "fast-json-stable-stringify@2.1.0": { - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "fast-levenshtein@2.0.6": { - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" - }, - "fast-uri@3.1.0": { - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" - }, - "fastq@1.19.1": { - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dependencies": [ - "reusify" - ] - }, - "fdir@6.5.0_picomatch@4.0.3": { - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dependencies": [ - "picomatch@4.0.3" - ], - "optionalPeers": [ - "picomatch@4.0.3" - ] - }, - "file-entry-cache@8.0.0": { - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dependencies": [ - "flat-cache" - ] - }, - "fill-range@7.1.1": { - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": [ - "to-regex-range" - ] - }, - "find-up@5.0.0": { - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dependencies": [ - "locate-path", - "path-exists" - ] - }, - "flat-cache@4.0.1": { - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dependencies": [ - "flatted", - "keyv" - ] - }, - "flatted@3.3.3": { - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" - }, - "fsevents@2.3.3": { - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "os": ["darwin"], - "scripts": true - }, - "glob-parent@5.1.2": { - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": [ - "is-glob" - ] - }, - "glob-parent@6.0.2": { - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": [ - "is-glob" - ] - }, - "globals@14.0.0": { - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" - }, - "globals@16.5.0": { - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==" - }, - "graceful-fs@4.2.11": { - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "graphemer@1.4.0": { - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" - }, - "has-flag@4.0.0": { - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "highlight.js@11.11.1": { - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==" - }, - "https-proxy-agent@7.0.6": { - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dependencies": [ - "agent-base", - "debug" - ] - }, - "ignore@5.3.2": { - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" - }, - "ignore@7.0.5": { - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==" - }, - "import-fresh@3.3.1": { - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dependencies": [ - "parent-module", - "resolve-from" - ] - }, - "imurmurhash@0.1.4": { - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" - }, - "index-to-position@1.2.0": { - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==" - }, - "is-extglob@2.1.1": { - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" - }, - "is-glob@4.0.3": { - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": [ - "is-extglob" - ] - }, - "is-number@7.0.0": { - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-reference@3.0.3": { - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dependencies": [ - "@types/estree" - ] - }, - "isexe@2.0.0": { - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "jiti@2.6.1": { - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "bin": true - }, - "js-levenshtein@1.1.6": { - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==" - }, - "js-tokens@4.0.0": { - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "js-yaml@4.1.0": { - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": [ - "argparse" - ], - "bin": true - }, - "json-buffer@3.0.1": { - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" - }, - "json-schema-traverse@0.4.1": { - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-schema-traverse@1.0.0": { - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "json-stable-stringify-without-jsonify@1.0.1": { - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" - }, - "keyv@4.5.4": { - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dependencies": [ - "json-buffer" - ] - }, - "kleur@4.1.5": { - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" - }, - "known-css-properties@0.37.0": { - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==" - }, - "kysely@0.27.6": { - "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" - }, - "levn@0.4.1": { - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dependencies": [ - "prelude-ls", - "type-check" - ] - }, - "lightningcss-android-arm64@1.30.2": { - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "os": ["android"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-arm64@1.30.2": { - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-x64@1.30.2": { - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "lightningcss-freebsd-x64@1.30.2": { - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "lightningcss-linux-arm-gnueabihf@1.30.2": { - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "os": ["linux"], - "cpu": ["arm"] - }, - "lightningcss-linux-arm64-gnu@1.30.2": { - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-arm64-musl@1.30.2": { - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-x64-gnu@1.30.2": { - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-linux-x64-musl@1.30.2": { - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-win32-arm64-msvc@1.30.2": { - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "lightningcss-win32-x64-msvc@1.30.2": { - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "os": ["win32"], - "cpu": ["x64"] - }, - "lightningcss@1.30.2": { - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dependencies": [ - "detect-libc" - ], - "optionalDependencies": [ - "lightningcss-android-arm64", - "lightningcss-darwin-arm64", - "lightningcss-darwin-x64", - "lightningcss-freebsd-x64", - "lightningcss-linux-arm-gnueabihf", - "lightningcss-linux-arm64-gnu", - "lightningcss-linux-arm64-musl", - "lightningcss-linux-x64-gnu", - "lightningcss-linux-x64-musl", - "lightningcss-win32-arm64-msvc", - "lightningcss-win32-x64-msvc" - ] - }, - "lilconfig@2.1.0": { - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" - }, - "locate-character@3.0.0": { - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "locate-path@6.0.0": { - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dependencies": [ - "p-locate" - ] - }, - "lodash.merge@4.6.2": { - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "lucide-svelte@0.546.0_svelte@5.43.3__acorn@8.15.0": { - "integrity": "sha512-vCvBUlFapD59ivX1b/i7wdUadSgC/3gQGvrGEZjSecOlThT+UR+X5UxdVEakHuhniTrSX0nJ2WrY5r25SVDtyQ==", - "dependencies": [ - "svelte" - ] - }, - "magic-string@0.30.21": { - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "marked@15.0.12": { - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "bin": true - }, - "merge2@1.4.1": { - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch@4.0.8": { - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": [ - "braces", - "picomatch@2.3.1" - ] - }, - "mini-svg-data-uri@1.4.4": { - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "bin": true - }, - "minimatch@3.1.2": { - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": [ - "brace-expansion@1.1.12" - ] - }, - "minimatch@5.1.6": { - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": [ - "brace-expansion@2.0.2" - ] - }, - "minimatch@9.0.5": { - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": [ - "brace-expansion@2.0.2" - ] - }, - "mri@1.2.0": { - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" - }, - "mrmime@2.0.1": { - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "nanoid@3.3.11": { - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "bin": true - }, - "natural-compare@1.4.0": { - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" - }, - "openapi-typescript@7.10.1_typescript@5.9.3": { - "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", - "dependencies": [ - "@redocly/openapi-core", - "ansi-colors", - "change-case", - "parse-json", - "supports-color@10.2.2", - "typescript", - "yargs-parser" - ], - "bin": true - }, - "optionator@0.9.4": { - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dependencies": [ - "deep-is", - "fast-levenshtein", - "levn", - "prelude-ls", - "type-check", - "word-wrap" - ] - }, - "p-limit@3.1.0": { - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dependencies": [ - "yocto-queue" - ] - }, - "p-locate@5.0.0": { - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dependencies": [ - "p-limit" - ] - }, - "parent-module@1.0.1": { - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": [ - "callsites" - ] - }, - "parse-json@8.3.0": { - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dependencies": [ - "@babel/code-frame", - "index-to-position", - "type-fest" - ] - }, - "path-exists@4.0.0": { - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "path-key@3.1.1": { - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "picocolors@1.1.1": { - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "picomatch@2.3.1": { - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" - }, - "picomatch@4.0.3": { - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" - }, - "pluralize@8.0.0": { - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" - }, - "postcss-load-config@3.1.4_postcss@8.5.6": { - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dependencies": [ - "lilconfig", - "postcss", - "yaml" - ], - "optionalPeers": [ - "postcss" - ] - }, - "postcss-safe-parser@7.0.1_postcss@8.5.6": { - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dependencies": [ - "postcss" - ] - }, - "postcss-scss@4.0.9_postcss@8.5.6": { - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dependencies": [ - "postcss" - ] - }, - "postcss-selector-parser@7.1.0": { - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dependencies": [ - "cssesc", - "util-deprecate" - ] - }, - "postcss@8.5.6": { - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dependencies": [ - "nanoid", - "picocolors", - "source-map-js" - ] - }, - "prelude-ls@1.2.1": { - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" - }, - "prettier-plugin-svelte@3.4.0_prettier@3.6.2_svelte@5.43.3__acorn@8.15.0": { - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", - "dependencies": [ - "prettier", - "svelte" - ] - }, - "prettier-plugin-tailwindcss@0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.43.3___acorn@8.15.0_svelte@5.43.3__acorn@8.15.0": { - "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", - "dependencies": [ - "prettier", - "prettier-plugin-svelte" - ], - "optionalPeers": [ - "prettier-plugin-svelte" - ] - }, - "prettier@3.6.2": { - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "bin": true - }, - "punycode@2.3.1": { - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" - }, - "queue-microtask@1.2.3": { - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, - "readdirp@4.1.2": { - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" - }, - "require-from-string@2.0.2": { - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "resolve-from@4.0.0": { - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "reusify@1.1.0": { - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" - }, - "rollup@4.52.5": { - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", - "dependencies": [ - "@types/estree" - ], - "optionalDependencies": [ - "@rollup/rollup-android-arm-eabi", - "@rollup/rollup-android-arm64", - "@rollup/rollup-darwin-arm64", - "@rollup/rollup-darwin-x64", - "@rollup/rollup-freebsd-arm64", - "@rollup/rollup-freebsd-x64", - "@rollup/rollup-linux-arm-gnueabihf", - "@rollup/rollup-linux-arm-musleabihf", - "@rollup/rollup-linux-arm64-gnu", - "@rollup/rollup-linux-arm64-musl", - "@rollup/rollup-linux-loong64-gnu", - "@rollup/rollup-linux-ppc64-gnu", - "@rollup/rollup-linux-riscv64-gnu", - "@rollup/rollup-linux-riscv64-musl", - "@rollup/rollup-linux-s390x-gnu", - "@rollup/rollup-linux-x64-gnu", - "@rollup/rollup-linux-x64-musl", - "@rollup/rollup-openharmony-arm64", - "@rollup/rollup-win32-arm64-msvc", - "@rollup/rollup-win32-ia32-msvc", - "@rollup/rollup-win32-x64-gnu", - "@rollup/rollup-win32-x64-msvc", - "fsevents" - ], - "bin": true - }, - "run-parallel@1.2.0": { - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dependencies": [ - "queue-microtask" - ] - }, - "sade@1.8.1": { - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dependencies": [ - "mri" - ] - }, - "semver@7.7.3": { - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "bin": true - }, - "set-cookie-parser@2.7.2": { - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" - }, - "shebang-command@2.0.0": { - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": [ - "shebang-regex" - ] - }, - "shebang-regex@3.0.0": { - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" - }, - "simple-icons@15.17.0": { - "integrity": "sha512-viOcugYj+JFYVWJvDh4Ph1xHk9iTGhzt+NoPrfAQYSCADvmZFSQUWyKEbSMuqVRUsaRgvADn+Cczysemsf1N3Q==" - }, - "sirv@3.0.2": { - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dependencies": [ - "@polka/url", - "mrmime", - "totalist" - ] - }, - "source-map-js@1.2.1": { - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" - }, - "strip-json-comments@3.1.1": { - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" - }, - "supports-color@10.2.2": { - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" - }, - "supports-color@7.2.0": { - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": [ - "has-flag" - ] - }, - "svelte-check@4.3.3_svelte@5.43.3__acorn@8.15.0_typescript@5.9.3": { - "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", - "dependencies": [ - "@jridgewell/trace-mapping", - "chokidar", - "fdir", - "picocolors", - "sade", - "svelte", - "typescript" - ], - "bin": true - }, - "svelte-eslint-parser@1.4.0_svelte@5.43.3__acorn@8.15.0_postcss@8.5.6": { - "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", - "dependencies": [ - "eslint-scope", - "eslint-visitor-keys@4.2.1", - "espree", - "postcss", - "postcss-scss", - "postcss-selector-parser", - "svelte" - ], - "optionalPeers": [ - "svelte" - ] - }, - "svelte@5.43.3_acorn@8.15.0": { - "integrity": "sha512-kjkAjCk41mJfvJZG56XcJNOdJSke94JxtcX8zFzzz2vrt47E0LnoBzU6azIZ1aBxJgUep8qegAkguSf1GjxLXQ==", - "dependencies": [ - "@jridgewell/remapping", - "@jridgewell/sourcemap-codec", - "@sveltejs/acorn-typescript", - "@types/estree", - "acorn", - "aria-query", - "axobject-query", - "clsx", - "esm-env", - "esrap", - "is-reference", - "locate-character", - "magic-string", - "zimmerframe" - ] - }, - "sveltekit-adapter-deno@0.16.1_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.3____acorn@8.15.0___vite@7.1.12____@types+node@22.19.0____picomatch@4.0.3___@types+node@22.19.0__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__acorn@8.15.0__@types+node@22.19.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.3___acorn@8.15.0__vite@7.1.12___@types+node@22.19.0___picomatch@4.0.3__@types+node@22.19.0_svelte@5.43.3__acorn@8.15.0_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-AwJj5Y9yoJ5BQVhfFURrVex5GrQmU/v06pW8vs6tyWaJxEYrnF6o3gbELVtCOI/YRl4G02I7n8fBDMY+/cgBdg==", - "dependencies": [ - "@sveltejs/kit", - "esbuild@0.24.2" - ] - }, - "tailwindcss@4.1.16": { - "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==" - }, - "tapable@2.3.0": { - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==" - }, - "tinyglobby@0.2.15_picomatch@4.0.3": { - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dependencies": [ - "fdir", - "picomatch@4.0.3" - ] - }, - "to-regex-range@5.0.1": { - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": [ - "is-number" - ] - }, - "totalist@3.0.1": { - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" - }, - "ts-api-utils@2.1.0_typescript@5.9.3": { - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dependencies": [ - "typescript" - ] - }, - "type-check@0.4.0": { - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dependencies": [ - "prelude-ls" - ] - }, - "type-fest@4.41.0": { - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" - }, - "typescript-eslint@8.46.3_eslint@9.39.1_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.1__typescript@5.9.3": { - "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", - "dependencies": [ - "@typescript-eslint/eslint-plugin", - "@typescript-eslint/parser", - "@typescript-eslint/typescript-estree", - "@typescript-eslint/utils", - "eslint", - "typescript" - ] - }, - "typescript@5.9.3": { - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "bin": true - }, - "undici-types@6.21.0": { - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "uri-js@4.4.1": { - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": [ - "punycode" - ] - }, - "util-deprecate@1.0.2": { - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "vite@7.1.12_@types+node@22.19.0_picomatch@4.0.3": { - "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", - "dependencies": [ - "@types/node", - "esbuild@0.25.12", - "fdir", - "picomatch@4.0.3", - "postcss", - "rollup", - "tinyglobby" - ], - "optionalDependencies": [ - "fsevents" - ], - "optionalPeers": [ - "@types/node" - ], - "bin": true - }, - "vitefu@1.1.1_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0": { - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dependencies": [ - "vite" - ], - "optionalPeers": [ - "vite" - ] - }, - "which@2.0.2": { - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": [ - "isexe" - ], - "bin": true - }, - "word-wrap@1.2.5": { - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" - }, - "yaml-ast-parser@0.0.43": { - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==" - }, - "yaml@1.10.2": { - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - }, - "yargs-parser@21.1.1": { - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" - }, - "yocto-queue@0.1.0": { - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" - }, - "zimmerframe@1.1.4": { - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, "workspace": { "dependencies": [ + "jsr:@felix/bcrypt@^1.0.8", "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", "jsr:@std/yaml@^1.0.10", - "npm:croner@^8.1.2", + "npm:croner@^9.1.0", "npm:highlight.js@^11.11.1", "npm:marked@^15.0.6", "npm:simple-icons@^15.17.0" diff --git a/docs/todo/auth.md b/docs/todo/auth.md new file mode 100644 index 0000000..02727ba --- /dev/null +++ b/docs/todo/auth.md @@ -0,0 +1,1130 @@ +# Auth Implementation Plan + +## Overview + +Two auth methods (implementing both): + +1. **Basic Auth** - Username/password, session cookies, API key +2. **OIDC** - Login via external provider (Google, Authentik, Keycloak, etc.) + +Which method is used depends on the `AUTH` environment variable. + +--- + +## Session Configuration + +- **Duration:** 7 days (like Sonarr) +- **Sliding expiration:** Reset expiry to 7 days from now, but only when past halfway point + - Days 1-3: No DB update (session still has plenty of time) + - Days 4-7: Extend session to 7 days from now + - This avoids a DB write on every request while keeping active users logged in +- **Multiple sessions:** Users can be logged in from multiple browsers/devices + +--- + +## AUTH Environment Variable + +Single env var controls authentication behavior: + +```bash +AUTH=on # Default - username/password auth required everywhere +AUTH=local # Bypass auth for local IPs only +AUTH=off # Disable auth - trust external proxy (Authelia/Authentik) +AUTH=oidc # Use OIDC provider for login (requires OIDC_* env vars) +``` + +| Value | Behavior | +|-------|----------| +| `on` | Username/password authentication (default if not set) | +| `local` | Skip auth for local IPs, require for external | +| `off` | No auth checks - trust reverse proxy handles it | +| `oidc` | Login via OIDC provider (Google, Authentik, etc.) | + +This is intentionally an env var, not a UI setting (like Sonarr/Radarr's XML config). Users must deliberately configure it. + +**Add to `src/lib/server/utils/config/config.ts`:** + +```typescript +export type AuthMode = 'on' | 'local' | 'off' | 'oidc'; + +class Config { + // ... existing fields ... + public readonly authMode: AuthMode; + + constructor() { + // ... existing code ... + + // Auth mode: 'on' (default), 'local', 'off', 'oidc' + const auth = (Deno.env.get('AUTH') || 'on').toLowerCase(); + this.authMode = ['on', 'local', 'off', 'oidc'].includes(auth) + ? auth as AuthMode + : 'on'; + } +} +``` + +Then in middleware, use `config.authMode` instead of reading env var directly. + +**Local addresses** (for `AUTH=local`): +- `127.0.0.0/8` (loopback) +- `10.0.0.0/8` (Class A private) +- `172.16.0.0/12` (Class B private) +- `192.168.0.0/16` (Class C private) + +--- + +## Phase 1: Basic Auth + +### Database Migrations + +**Migration 036: Create auth tables** (all tables in one migration) + +```sql +-- Users table (single admin user) +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Sessions table (multiple sessions per user) +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- UUID + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- Auth settings table (singleton) +CREATE TABLE auth_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + session_duration_hours INTEGER NOT NULL DEFAULT 168, -- 7 days + api_key TEXT, -- For programmatic access + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default settings with generated API key +INSERT INTO auth_settings (id, api_key) VALUES (1, lower(hex(randomblob(16)))); +``` + +--- + +### Auth Utilities + +**`src/lib/server/utils/auth/password.ts`** + +```typescript +// Using @felix/bcrypt (Rust FFI) +export function hashPassword(password: string): Promise; +export function verifyPassword(password: string, hash: string): Promise; +``` + +**Session cookies** - Use SvelteKit's built-in `event.cookies` API: +```typescript +// Set session cookie +cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires +}); + +// Read session cookie +const sessionId = cookies.get('session'); + +// Delete session cookie (logout) +cookies.delete('session', { path: '/' }); +``` + +No custom session utility needed - browsers handle cookies automatically. + +**`src/lib/server/utils/auth/apiKey.ts`** + +```typescript +export function generateApiKey(): string; // Random secure token +``` + +**`src/lib/server/utils/auth/network.ts`** + +```typescript +// isLocalAddress: Based on Sonarr - checks RFC 1918 + link-local + IPv6 local +export function isLocalAddress(ip: string): boolean; + +// getClientIp: Based on Overseerr - checks common proxy headers in order: +// x-forwarded-for, x-real-ip, x-client-ip, cf-connecting-ip, etc. +// No configuration needed - works automatically behind most proxies +export function getClientIp(event: RequestEvent): string; +``` + +**`src/lib/server/utils/auth/middleware.ts`** + +Core auth logic - keeps hooks.server.ts thin: + +```typescript +import type { RequestEvent } from '@sveltejs/kit'; +import { config } from '$config'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { usersQueries } from '$db/queries/users.ts'; +import { isLocalAddress, getClientIp } from './network.ts'; + +export interface AuthState { + needsSetup: boolean; + user: User | null; + session: Session | null; + skipAuth: boolean; // true when AUTH=off or AUTH=local+local IP +} + +const PUBLIC_PATHS = ['/auth/login', '/auth/setup', '/api/v1/health']; + +export function isPublicPath(pathname: string): boolean { + return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/')); +} + +export function getAuthState(event: RequestEvent): AuthState { + const settings = authSettingsQueries.get(); + const hasUsers = usersQueries.exists(); + + // AUTH=off - skip all auth (trust external proxy) + if (config.authMode === 'off') { + return { + needsSetup: false, + user: null, + session: null, + skipAuth: true + }; + } + + // AUTH=local - skip auth for local IPs + if (config.authMode === 'local' && isLocalAddress(getClientIp(event))) { + return { + needsSetup: !hasUsers, + user: null, + session: null, + skipAuth: true + }; + } + + // Check API key + const apiKey = request.headers.get('X-Api-Key'); + if (apiKey && settings.api_key && apiKey === settings.api_key) { + return { + needsSetup: false, + user: { id: 0, username: 'api' } as User, + session: null, + skipAuth: false + }; + } + + // Check session cookie (using SvelteKit's cookies API) + const sessionId = event.cookies.get('session'); + const session = sessionId ? sessionsQueries.getValidById(sessionId) : null; + const user = session ? usersQueries.getById(session.user_id) : null; + + return { + needsSetup: !hasUsers, + user, + session, + skipAuth: false + }; +} + +// Sliding expiration: extend session if past halfway point +export function maybeExtendSession(session: Session, durationHours: number): void { + const expiresAt = new Date(session.expires_at).getTime(); + const now = Date.now(); + const halfDuration = (durationHours * 60 * 60 * 1000) / 2; + + // Only extend if less than half the duration remains + if (expiresAt - now < halfDuration) { + sessionsQueries.extendExpiration(session.id, durationHours); + } +} + +// Clean expired sessions - call on startup +export function cleanupExpiredSessions(): number { + return sessionsQueries.deleteExpired(); +} +``` + +--- + +### Auth Middleware (hooks.server.ts) + +Thin wrapper that calls auth utilities: + +```typescript +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { + getAuthState, + isPublicPath, + cleanupExpiredSessions, + maybeExtendSession +} from '$utils/auth/middleware.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; + +// Clean expired sessions on startup +cleanupExpiredSessions(); + +export const handle: Handle = async ({ event, resolve }) => { + const auth = getAuthState(event); + + // AUTH=off or AUTH=local with local IP - skip all auth + if (auth.skipAuth) { + return resolve(event); + } + + // First-run setup flow + if (auth.needsSetup) { + if (event.url.pathname === '/auth/setup') { + return resolve(event); + } + throw redirect(303, '/auth/setup'); + } + + // Block setup after user exists + if (event.url.pathname === '/auth/setup') { + throw redirect(303, '/'); + } + + // Public paths don't need auth + if (isPublicPath(event.url.pathname)) { + return resolve(event); + } + + // Not authenticated + if (!auth.user) { + if (event.url.pathname.startsWith('/api')) { + return new Response('Unauthorized', { status: 401 }); + } + throw redirect(303, '/auth/login'); + } + + // Sliding expiration: extend session if past halfway point + if (auth.session) { + const settings = authSettingsQueries.get(); + maybeExtendSession(auth.session, settings.session_duration_hours); + } + + // Authenticated - attach to locals + event.locals.user = auth.user; + event.locals.session = auth.session; + + return resolve(event); +}; +``` + +--- + +### Query Modules + +**`src/lib/server/db/queries/users.ts`** + +```typescript +export interface User { + id: number; + username: string; + password_hash: string; + created_at: string; + updated_at: string; +} + +export const usersQueries = { + exists(): boolean { + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM users' + ); + return (result?.count ?? 0) > 0; + }, + + getById(id: number): User | undefined { + return db.queryFirst('SELECT * FROM users WHERE id = ?', id); + }, + + getByUsername(username: string): User | undefined { + return db.queryFirst('SELECT * FROM users WHERE username = ?', username); + }, + + getAllUsernames(): string[] { + // For login analysis - typo detection + const results = db.query<{ username: string }>( + "SELECT username FROM users WHERE username NOT LIKE 'oidc:%'" + ); + return results.map((r) => r.username); + }, + + create(username: string, passwordHash: string): number { + db.execute( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + username, passwordHash + ); + const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id'); + return result?.id ?? 0; + }, + + updatePassword(id: number, passwordHash: string): boolean { + const affected = db.execute( + 'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + passwordHash, id + ); + return affected > 0; + } +}; +``` + +**`src/lib/server/db/queries/sessions.ts`** + +```typescript +export interface Session { + id: string; + user_id: number; + expires_at: string; + created_at: string; +} + +export const sessionsQueries = { + create(userId: number, durationHours: number): string { + const id = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000); + + db.execute( + 'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', + id, userId, expiresAt.toISOString() + ); + + return id; + }, + + getById(id: string): Session | undefined { + // For logout logging - get session regardless of expiration + return db.queryFirst('SELECT * FROM sessions WHERE id = ?', id); + }, + + getValidById(id: string): Session | undefined { + return db.queryFirst( + `SELECT * FROM sessions + WHERE id = ? AND datetime(expires_at) > datetime('now')`, + id + ); + }, + + deleteById(id: string): boolean { + const affected = db.execute('DELETE FROM sessions WHERE id = ?', id); + return affected > 0; + }, + + deleteByUserId(userId: number): number { + return db.execute('DELETE FROM sessions WHERE user_id = ?', userId); + }, + + deleteExpired(): number { + return db.execute( + `DELETE FROM sessions WHERE datetime(expires_at) <= datetime('now')` + ); + }, + + getByUserId(userId: number): Session[] { + return db.query( + 'SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC', + userId + ); + }, + + // Sliding expiration: extend session by duration from now + extendExpiration(id: string, durationHours: number): boolean { + const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000); + const affected = db.execute( + 'UPDATE sessions SET expires_at = ? WHERE id = ?', + expiresAt.toISOString(), id + ); + return affected > 0; + } +}; +``` + +**`src/lib/server/db/queries/authSettings.ts`** + +```typescript +export interface AuthSettings { + id: number; + session_duration_hours: number; + api_key: string | null; + created_at: string; + updated_at: string; +} + +export const authSettingsQueries = { + get(): AuthSettings { + return db.queryFirst( + 'SELECT * FROM auth_settings WHERE id = 1' + )!; + }, + + update(input: { + sessionDurationHours?: number; + apiKey?: string | null; + }): boolean { + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (input.sessionDurationHours !== undefined) { + updates.push('session_duration_hours = ?'); + params.push(input.sessionDurationHours); + } + if (input.apiKey !== undefined) { + updates.push('api_key = ?'); + params.push(input.apiKey); + } + + if (updates.length === 0) return false; + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(1); + + const affected = db.execute( + `UPDATE auth_settings SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + regenerateApiKey(): string { + const apiKey = crypto.randomUUID(); + this.update({ apiKey }); + return apiKey; + }, + + clearApiKey(): void { + this.update({ apiKey: null }); + } +}; +``` + +--- + +### Routes + +**`/auth/setup` - First-run setup (create admin account)** + +Only accessible when no users exist. + +``` +src/routes/auth/setup/ +├── +page.svelte -- Form: username, password, confirm password +└── +page.server.ts -- Load: redirect if users exist. Action: create user, login, redirect +``` + +**`/auth/login` - Login page** + +``` +src/routes/auth/login/ +├── +page.svelte -- Form: username, password +└── +page.server.ts -- Action: verify password, create session, set cookie, redirect +``` + +Example `+page.server.ts`: + +```typescript +import { fail, redirect } from '@sveltejs/kit'; +import { usersQueries } from '$db/queries/users.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { verifyPassword } from '$utils/auth/password.ts'; + +export const actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const username = data.get('username') as string; + const password = data.get('password') as string; + + if (!username || !password) { + return fail(400, { error: 'Username and password required' }); + } + + const user = usersQueries.getByUsername(username); + if (!user) { + return fail(400, { error: 'Invalid credentials' }); + } + + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + return fail(400, { error: 'Invalid credentials' }); + } + + const settings = authSettingsQueries.get(); + const sessionId = sessionsQueries.create(user.id, settings.session_duration_hours); + + const expires = new Date(Date.now() + settings.session_duration_hours * 60 * 60 * 1000); + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires + }); + + throw redirect(303, '/'); + } +}; +``` + +**`/settings/security` - Auth settings UI** + +``` +src/routes/settings/security/ +├── +page.svelte -- Change password, view sessions, API key management +└── +page.server.ts -- Load settings, actions for password/API key/logout +``` + +Features: +- Change password form +- View active sessions (with logout button for each) +- "Logout all other sessions" button +- API Key: show (masked), copy, regenerate, delete + +--- + +### File Structure + +``` +src/lib/server/ +├── db/ +│ ├── migrations/ +│ │ ├── 036_create_auth_tables.ts # Users, sessions, auth_settings +│ │ └── 037_add_session_metadata.ts # Session metadata columns +│ └── queries/ +│ ├── users.ts +│ ├── sessions.ts +│ └── authSettings.ts +└── utils/ + └── auth/ + ├── password.ts # bcrypt hash/verify + ├── apiKey.ts # API key generation + ├── network.ts # Local IP detection + ├── middleware.ts # Core auth logic + ├── userAgent.ts # User agent parsing + ├── loginAnalysis.ts # Login failure analysis + └── oidc.ts # OIDC utilities + +src/routes/ +├── auth/ +│ ├── setup/ +│ │ ├── +page.svelte +│ │ └── +page.server.ts +│ ├── login/ +│ │ ├── +page.svelte +│ │ └── +page.server.ts +│ └── logout/ +│ └── +server.ts # Logout endpoint +└── settings/ + └── security/ + ├── +page.svelte + └── +page.server.ts + +src/hooks.server.ts -- Auth middleware (thin, calls utils) +``` + +--- + +### Implementation Order + +1. ✅ Migrations (users, sessions, auth_settings) - `036_create_auth_tables.ts` +2. ✅ Password utility (hash/verify) - `$auth/password.ts` +3. ✅ API key utility (generate) - `$auth/apiKey.ts` +4. ✅ Network utility (isLocalAddress, getClientIp) - `$auth/network.ts` +5. ✅ Query modules (users, sessions, authSettings) - `$db/queries/` +6. ✅ Auth middleware utility + update hooks.server.ts +7. ✅ `/auth/setup` page (first-run) +8. ✅ `/auth/login` page +9. ✅ `/auth/logout` endpoint - `src/routes/auth/logout/+server.ts` +10. ✅ `/settings/security` page +11. ✅ Session metadata migration - `037_add_session_metadata.ts` + +--- + +## Phase 1.5: Session Metadata + +Add rich session information for better session management UI. + +### Migration 037: Add session metadata + +```sql +ALTER TABLE sessions ADD COLUMN ip_address TEXT; +ALTER TABLE sessions ADD COLUMN user_agent TEXT; +ALTER TABLE sessions ADD COLUMN browser TEXT; +ALTER TABLE sessions ADD COLUMN os TEXT; +ALTER TABLE sessions ADD COLUMN device_type TEXT; +ALTER TABLE sessions ADD COLUMN last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP; +``` + +### User Agent Parser + +**`src/lib/server/utils/auth/userAgent.ts`** + +Simple regex-based parser (no heavy libraries): + +```typescript +interface ParsedUserAgent { + browser: string; // "Chrome 120", "Firefox 121", "Safari 17" + os: string; // "Windows 11", "macOS 14", "Ubuntu", "iOS 17" + deviceType: string; // "Desktop", "Mobile", "Tablet" +} + +export function parseUserAgent(ua: string): ParsedUserAgent; +``` + +### Updated Session Creation + +When creating sessions (login, setup), capture: +- IP address from request headers +- User agent string +- Parsed browser/OS/device + +### Updated Session Display + +Security page shows: +| Created | Last Active | Browser | OS | Device | IP | Status | +|---------|-------------|---------|-----|--------|-----|--------| +| Jan 25, 10:30 | 2 min ago | Chrome 120 | Windows 11 | Desktop | 192.168.1.5 | Current | + +### Update `last_active_at` + +In middleware, when extending session (sliding expiration), also update `last_active_at`. + +--- + +## Phase 2: OIDC + +### Overview + +OIDC (OpenID Connect) lets users login via external providers (Google, Authentik, Keycloak, etc.) instead of username/password. We implement **generic OIDC** - works with any compliant provider, no provider-specific code needed. + +### Environment Variables + +```bash +AUTH=oidc +OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration +OIDC_CLIENT_ID=profilarr +OIDC_CLIENT_SECRET=secret +``` + +Env vars are standard for OIDC config because the client secret is sensitive (shouldn't be in database/config files). + +**Add to `src/lib/server/utils/config/config.ts`:** + +```typescript +class Config { + // ... existing fields ... + public readonly oidc: { + discoveryUrl: string | null; + clientId: string | null; + clientSecret: string | null; + }; + + constructor() { + // ... existing code ... + + // OIDC configuration (only used when AUTH=oidc) + this.oidc = { + discoveryUrl: Deno.env.get('OIDC_DISCOVERY_URL') || null, + clientId: Deno.env.get('OIDC_CLIENT_ID') || null, + clientSecret: Deno.env.get('OIDC_CLIENT_SECRET') || null + }; + } +} +``` + +### The Flow + +``` +1. User clicks "Login with OIDC" on /login + ↓ +2. GET /auth/oidc/login + - Fetch discovery document (cached) + - Generate state token (CSRF protection) + - Redirect to provider's authorization endpoint + ↓ +3. User logs in at provider (if not already) + ↓ +4. Provider redirects to /auth/oidc/callback?code=xxx&state=xxx + ↓ +5. POST to provider's token endpoint (server-to-server) + - Exchange code for tokens using client secret + - Receive: access_token, id_token (JWT with user info) + ↓ +6. Decode & verify id_token + - Extract: sub (user ID), email, name + ↓ +7. Create session, set cookie, redirect to / +``` + +### Discovery Document + +The discovery URL returns JSON telling us everything: +```json +{ + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "userinfo_endpoint": "https://auth.example.com/userinfo", + "jwks_uri": "https://auth.example.com/.well-known/jwks.json" +} +``` + +This is why it works with **any** OIDC provider - we read their config dynamically. + +### Routes + +**`/auth/oidc/login` - Initiate OIDC flow** + +```typescript +// src/routes/auth/oidc/login/+server.ts +import { redirect } from '@sveltejs/kit'; +import { config } from '$config'; +import { getDiscoveryDocument, generateState } from '$utils/auth/oidc.ts'; + +export async function GET({ cookies }) { + const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl!); + const state = generateState(); + + cookies.set('oidc_state', state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 10 // 10 minutes + }); + + const params = new URLSearchParams({ + client_id: config.oidc.clientId!, + redirect_uri: `${config.serverUrl}/auth/oidc/callback`, + response_type: 'code', + scope: 'openid email profile', + state + }); + + throw redirect(302, `${discovery.authorization_endpoint}?${params}`); +} +``` + +**`/auth/oidc/callback` - Handle provider response** + +```typescript +// src/routes/auth/oidc/callback/+server.ts +import { redirect, error } from '@sveltejs/kit'; +import { config } from '$config'; +import { + getDiscoveryDocument, + exchangeCode, + verifyIdToken +} from '$utils/auth/oidc.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; + +export async function GET({ url, cookies }) { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const savedState = cookies.get('oidc_state'); + + // Verify state (CSRF protection) + if (!state || state !== savedState) { + throw error(400, 'Invalid state'); + } + cookies.delete('oidc_state', { path: '/' }); + + if (!code) { + throw error(400, 'No code provided'); + } + + // Exchange code for tokens + const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl!); + const tokens = await exchangeCode(discovery.token_endpoint, code, { + clientId: config.oidc.clientId!, + clientSecret: config.oidc.clientSecret!, + redirectUri: `${config.serverUrl}/auth/oidc/callback` + }); + + // Verify and decode ID token + const claims = await verifyIdToken(tokens.id_token, discovery.jwks_uri); + + // Create session (user ID 0 for OIDC users - single user app) + const settings = authSettingsQueries.get(); + const sessionId = sessionsQueries.create(0, settings.session_duration_hours); + + const expires = new Date(Date.now() + settings.session_duration_hours * 60 * 60 * 1000); + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires + }); + + throw redirect(303, '/'); +} +``` + +### OIDC Utility + +**`src/lib/server/utils/auth/oidc.ts`** + +```typescript +interface DiscoveryDocument { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + jwks_uri: string; +} + +// Cache discovery document (doesn't change often) +let cachedDiscovery: { url: string; doc: DiscoveryDocument; expires: number } | null = null; + +export async function getDiscoveryDocument(url: string): Promise { + if (cachedDiscovery && cachedDiscovery.url === url && Date.now() < cachedDiscovery.expires) { + return cachedDiscovery.doc; + } + + const response = await fetch(url); + const doc = await response.json() as DiscoveryDocument; + + cachedDiscovery = { + url, + doc, + expires: Date.now() + 60 * 60 * 1000 // Cache for 1 hour + }; + + return doc; +} + +export function generateState(): string { + return crypto.randomUUID(); +} + +export async function exchangeCode( + tokenEndpoint: string, + code: string, + opts: { clientId: string; clientSecret: string; redirectUri: string } +): Promise<{ access_token: string; id_token: string }> { + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: opts.clientId, + client_secret: opts.clientSecret, + redirect_uri: opts.redirectUri + }) + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.status}`); + } + + return response.json(); +} + +export async function verifyIdToken( + idToken: string, + jwksUri: string +): Promise<{ sub: string; email?: string; name?: string }> { + // Decode JWT (header.payload.signature) + const [, payloadB64] = idToken.split('.'); + const payload = JSON.parse(atob(payloadB64)); + + // TODO: Verify signature using JWKS (for production) + // For now, trust the token since it came from server-to-server exchange + + return { + sub: payload.sub, + email: payload.email, + name: payload.name + }; +} +``` + +### Update Middleware + +```typescript +// In getAuthState() - add OIDC handling +if (config.authMode === 'oidc') { + // OIDC still uses sessions - check session cookie + const sessionId = event.cookies.get('session'); + const session = sessionId ? sessionsQueries.getValidById(sessionId) : null; + + return { + needsSetup: false, // No setup needed for OIDC + user: session ? { id: 0, username: 'oidc' } as User : null, + session, + skipAuth: false + }; +} +``` + +### Update Login Page + +When `AUTH=oidc`, the login page shows "Login with OIDC" button instead of username/password form: + +```svelte + + + +{#if data.authMode === 'oidc'} + + Login with OIDC + +{:else} + +{/if} +``` + +### File Structure (Updated) + +``` +src/lib/server/utils/auth/ +├── password.ts # bcrypt hash/verify +├── apiKey.ts # API key generation +├── network.ts # Local IP detection +├── middleware.ts # Core auth logic +├── userAgent.ts # User agent parsing +├── loginAnalysis.ts # Login failure analysis (typo/attack detection) +└── oidc.ts # OIDC discovery, token exchange + +src/routes/auth/ +└── oidc/ + ├── login/ + │ └── +server.ts + └── callback/ + └── +server.ts +``` + +### Implementation Order (Full) + +**Phase 1: Basic Auth** ✅ +1. ✅ Migrations (users, sessions, auth_settings) +2. ✅ Password utility (hash/verify) +3. ✅ API key utility (generate) +4. ✅ Network utility (isLocalAddress, getClientIp) +5. ✅ Query modules (users, sessions, authSettings) +6. ✅ Auth middleware utility + update hooks.server.ts +7. ✅ `/auth/setup` page (first-run) +8. ✅ `/auth/login` page +9. ✅ `/auth/logout` endpoint +10. ✅ `/settings/security` page + +**Phase 1.5: Session Metadata** ✅ +11. ✅ Migration 037 (session metadata columns) +12. ✅ User agent parser utility +13. ✅ Update session creation to capture metadata +14. ✅ Update security page to display metadata + +**Phase 2: OIDC** ✅ +15. ✅ OIDC utility (discovery, token exchange, verify) +16. ✅ `/auth/oidc/login` route +17. ✅ `/auth/oidc/callback` route +18. ✅ Update login page with OIDC button + +**Phase 3: Auth Logging** ✅ +19. ✅ Login analysis utility (typo detection, attack username detection) +20. ✅ Login success/failure logging +21. ✅ Logout logging +22. ✅ Account creation logging +23. ✅ OIDC flow logging +24. ✅ API key and session logging +25. ✅ Unauthorized access and cleanup logging + +--- + +## Phase 3: Auth Logging ✅ + +### Source Tags + +Use `Auth` as the source for all auth-related logging, with sub-categories: +- `Auth` - general auth events +- `Auth:Login` - login attempts (password-based) +- `Auth:Session` - session management +- `Auth:OIDC` - OIDC flow events +- `Auth:APIKey` - API key usage + +### Login Analysis + +Failed login attempts are analyzed to distinguish between typos and potential attacks: + +**`src/lib/server/utils/auth/loginAnalysis.ts`** + +- `isCommonAttackUsername(username)` - checks against common attack usernames (admin, root, etc.) +- `findSimilarUsername(attempted, existing)` - Levenshtein distance for typo detection (≤2 edits) +- `analyzeLoginFailure()` / `formatLoginFailure()` - combines analysis for logging + +Example log messages: +``` +WARN Login failed for 'admi': unknown user (similar to 'admin') +WARN Login failed for 'root': unknown user (common attack username) +WARN Login failed for 'admin': invalid password +``` + +### INFO Level (User-relevant events) + +| Event | Source | What to Log | Location | +|-------|--------|-------------|----------| +| Login success | `Auth:Login` | username, IP, browser/device | `login/+page.server.ts` | +| Logout | `Auth:Session` | username, user ID | `logout/+server.ts` | +| Account created | `Auth` | username, IP | `setup/+page.server.ts` | +| Password changed | `Auth` | username, user ID | `settings/security/+page.server.ts` | +| API key authenticated | `Auth:APIKey` | IP, endpoint | `middleware.ts` | +| API key regenerated | `Auth:APIKey` | - | `settings/security/+page.server.ts` | +| Session revoked | `Auth:Session` | session ID (partial) | `settings/security/+page.server.ts` | +| Other sessions revoked | `Auth:Session` | user ID, count | `settings/security/+page.server.ts` | +| Session cleanup | `Auth:Session` | count deleted | `hooks.server.ts` | +| OIDC login success | `Auth:OIDC` | sub, IP, browser/device | `oidc/callback/+server.ts` | + +### WARN Level (Security-relevant) + +| Event | Source | What to Log | Location | +|-------|--------|-------------|----------| +| Login failed | `Auth:Login` | username, IP, reason (with analysis) | `login/+page.server.ts` | +| Invalid API key | `Auth:APIKey` | IP, endpoint, key (last 4 chars) | `middleware.ts` | +| OIDC state mismatch | `Auth:OIDC` | IP (possible CSRF) | `oidc/callback/+server.ts` | +| OIDC token exchange failed | `Auth:OIDC` | IP, error | `oidc/callback/+server.ts` | +| Unauthorized API access | `Auth` | IP, endpoint, method | `hooks.server.ts` | + +### ERROR Level (Failures) + +| Event | Source | What to Log | Location | +|-------|--------|-------------|----------| +| OIDC config missing | `Auth:OIDC` | which vars missing | `oidc/login/+server.ts` | +| ID token verification failed | `Auth:OIDC` | IP, error | `oidc/callback/+server.ts` | + +### DEBUG Level (Dev only) + +| Event | Source | What to Log | Location | +|-------|--------|-------------|----------| +| Session extended | `Auth:Session` | user ID | `middleware.ts` | +| Local IP bypass | `Auth` | IP | `middleware.ts` | +| OIDC flow started | `Auth:OIDC` | IP | `oidc/login/+server.ts` | + +### Security: What NOT to Log + +- Passwords (obviously) +- Full API keys (mask to last 4 chars: `****abcd`) +- Full session IDs (use partial: `abc12345...`) +- Full OIDC tokens + +### Implementation Files + +1. `src/lib/server/utils/auth/loginAnalysis.ts` - Login failure analysis (typo/attack detection) +2. `src/routes/auth/login/+page.server.ts` - Login success/failure with analysis +3. `src/routes/auth/logout/+server.ts` - Logout +4. `src/routes/auth/setup/+page.server.ts` - Account creation +5. `src/routes/auth/oidc/login/+server.ts` - OIDC flow start, config errors +6. `src/routes/auth/oidc/callback/+server.ts` - OIDC success/failures +7. `src/lib/server/utils/auth/middleware.ts` - API key, session validation, local bypass +8. `src/hooks.server.ts` - Unauthorized access, session cleanup count +9. `src/routes/settings/security/+page.server.ts` - Password change, API key regen, session revocation diff --git a/package-lock.json b/package-lock.json index f6ef1b6..f556cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "profilarr", "version": "2.0.0", + "license": "AGPL-3.0-only", "dependencies": { "@deno/vite-plugin": "^1.0.5", "@jsr/db__sqlite": "^0.12.0", diff --git a/src/app.d.ts b/src/app.d.ts index d6e8c08..bba26af 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,9 +1,15 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { User } from '$db/queries/users.ts'; +import type { Session } from '$db/queries/sessions.ts'; + declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: User | null; + session: Session | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/deno.d.ts b/src/deno.d.ts index 1240bb0..a5fffec 100644 --- a/src/deno.d.ts +++ b/src/deno.d.ts @@ -7,3 +7,9 @@ declare const __APP_VERSION__: string; // Note: Deno namespace types are provided by Deno's built-in lib.deno.ns.d.ts // Do not redeclare them here to avoid conflicts + +// JSR package declarations for svelte-check compatibility +declare module '@felix/bcrypt' { + export function hash(password: string, rounds?: number): Promise; + export function verify(password: string, hash: string): Promise; +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b371919..2e32c56 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,5 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import { config } from '$config'; import { printBanner, getServerInfo, logContainerConfig } from '$logger/startup.ts'; import { logSettings } from '$logger/settings.ts'; @@ -7,6 +9,13 @@ import { runMigrations } from '$db/migrations.ts'; import { initializeJobs } from '$jobs/init.ts'; import { jobScheduler } from '$jobs/scheduler.ts'; import { pcdManager } from '$pcd/pcd.ts'; +import { + getAuthState, + isPublicPath, + maybeExtendSession, + cleanupExpiredSessions +} from '$auth/middleware.ts'; +import { getClientIp } from '$auth/network.ts'; // Initialize configuration on server startup await config.init(); @@ -30,6 +39,15 @@ await pcdManager.initialize(); await initializeJobs(); await jobScheduler.start(); +// Clean expired sessions on startup +const expiredCount = cleanupExpiredSessions(); +if (expiredCount > 0) { + await logger.info(`Cleaned up ${expiredCount} expired session${expiredCount === 1 ? '' : 's'}`, { + source: 'Auth:Session', + meta: { count: expiredCount } + }); +} + // Log server ready await logger.info('Server ready', { source: 'Startup', @@ -38,3 +56,61 @@ await logger.info('Server ready', { // Print startup banner with URL printBanner(); + +/** + * Auth middleware + * Handles authentication, authorization, and session management + */ +export const handle: Handle = async ({ event, resolve }) => { + const auth = getAuthState(event); + + // First-run setup flow (applies to all auth modes except AUTH=off) + if (auth.needsSetup) { + if (event.url.pathname === '/auth/setup') { + return resolve(event); + } + throw redirect(303, '/auth/setup'); + } + + // AUTH=off or AUTH=local with local IP - skip auth after setup + if (auth.skipAuth) { + return resolve(event); + } + + // Block setup page after user exists + if (event.url.pathname === '/auth/setup') { + throw redirect(303, '/'); + } + + // Public paths don't need auth + if (isPublicPath(event.url.pathname)) { + return resolve(event); + } + + // Not authenticated - redirect or return 401 + if (!auth.user) { + if (event.url.pathname.startsWith('/api')) { + const ip = getClientIp(event); + void logger.warn('Unauthorized API access', { + source: 'Auth', + meta: { ip, endpoint: event.url.pathname, method: event.request.method } + }); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + throw redirect(303, '/auth/login'); + } + + // Sliding expiration: extend session if past halfway point + if (auth.session) { + maybeExtendSession(auth.session); + } + + // Authenticated - attach user to locals for use in routes + event.locals.user = auth.user; + event.locals.session = auth.session; + + return resolve(event); +}; diff --git a/src/lib/client/ui/form/FormInput.svelte b/src/lib/client/ui/form/FormInput.svelte index 02336d7..8b99727 100644 --- a/src/lib/client/ui/form/FormInput.svelte +++ b/src/lib/client/ui/form/FormInput.svelte @@ -1,4 +1,6 @@
-
+
+ {#if description}

@@ -21,18 +31,49 @@ {#if textarea} + {:else if private_} +

+ + +
{:else} {/if}
diff --git a/src/lib/client/ui/navigation/pageNav/pageNav.svelte b/src/lib/client/ui/navigation/pageNav/pageNav.svelte index cf38b16..8135b5b 100644 --- a/src/lib/client/ui/navigation/pageNav/pageNav.svelte +++ b/src/lib/client/ui/navigation/pageNav/pageNav.svelte @@ -76,7 +76,9 @@ + + diff --git a/src/lib/client/ui/table/Table.svelte b/src/lib/client/ui/table/Table.svelte index 44a1c9a..f2fee30 100644 --- a/src/lib/client/ui/table/Table.svelte +++ b/src/lib/client/ui/table/Table.svelte @@ -12,6 +12,7 @@ export let onRowClick: ((row: T) => void) | undefined = undefined; export let initialSort: SortState | null = null; export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined; + export let actionsHeader: string = 'Actions'; let sortKey: string | null = initialSort?.key ?? null; let sortDirection: SortDirection = initialSort?.direction ?? 'asc'; @@ -169,7 +170,7 @@ - Actions + {actionsHeader} {/if} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 1b79be2..dfeff3d 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -37,6 +37,8 @@ import { migration as migration032 } from './migrations/032_add_filter_id_to_upg import { migration as migration033 } from './migrations/033_create_github_cache.ts'; import { migration as migration034 } from './migrations/034_add_sync_status.ts'; import { migration as migration035 } from './migrations/035_add_job_skipped_status.ts'; +import { migration as migration036 } from './migrations/036_create_auth_tables.ts'; +import { migration as migration037 } from './migrations/037_add_session_metadata.ts'; export interface Migration { version: number; @@ -292,7 +294,9 @@ export function loadMigrations(): Migration[] { migration032, migration033, migration034, - migration035 + migration035, + migration036, + migration037 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/036_create_auth_tables.ts b/src/lib/server/db/migrations/036_create_auth_tables.ts new file mode 100644 index 0000000..af4c0fd --- /dev/null +++ b/src/lib/server/db/migrations/036_create_auth_tables.ts @@ -0,0 +1,58 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 036: Create authentication tables + * + * Creates all auth-related tables: + * - users: Single admin user (this is a single-user app) + * - sessions: Multiple sessions per user (allows login from multiple devices) + * - auth_settings: Singleton for session duration and API key + */ + +export const migration: Migration = { + version: 36, + name: 'Create auth tables', + + up: ` + -- Users table (single admin user) + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Sessions table (multiple sessions per user) + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_sessions_user_id ON sessions(user_id); + CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + + -- Auth settings table (singleton) + CREATE TABLE auth_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + session_duration_hours INTEGER NOT NULL DEFAULT 168, + api_key TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Insert default auth settings with generated API key + INSERT INTO auth_settings (id, api_key) VALUES (1, lower(hex(randomblob(16)))); + `, + + down: ` + DROP TABLE IF EXISTS auth_settings; + DROP INDEX IF EXISTS idx_sessions_expires_at; + DROP INDEX IF EXISTS idx_sessions_user_id; + DROP TABLE IF EXISTS sessions; + DROP TABLE IF EXISTS users; + ` +}; diff --git a/src/lib/server/db/migrations/037_add_session_metadata.ts b/src/lib/server/db/migrations/037_add_session_metadata.ts new file mode 100644 index 0000000..f1aa0a5 --- /dev/null +++ b/src/lib/server/db/migrations/037_add_session_metadata.ts @@ -0,0 +1,47 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 037: Add session metadata columns + * + * Adds rich session information for better session management UI: + * - ip_address: Client IP address when session was created + * - user_agent: Full user agent string + * - browser: Parsed browser name and version (e.g., "Chrome 120") + * - os: Parsed operating system (e.g., "Windows 11") + * - device_type: Device category (Desktop, Mobile, Tablet) + * - last_active_at: Updated on sliding expiration for activity tracking + */ + +export const migration: Migration = { + version: 37, + name: 'Add session metadata', + + up: ` + ALTER TABLE sessions ADD COLUMN ip_address TEXT; + ALTER TABLE sessions ADD COLUMN user_agent TEXT; + ALTER TABLE sessions ADD COLUMN browser TEXT; + ALTER TABLE sessions ADD COLUMN os TEXT; + ALTER TABLE sessions ADD COLUMN device_type TEXT; + ALTER TABLE sessions ADD COLUMN last_active_at DATETIME; + `, + + down: ` + -- SQLite doesn't support DROP COLUMN directly, so we recreate the table + CREATE TABLE sessions_new ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO sessions_new (id, user_id, expires_at, created_at) + SELECT id, user_id, expires_at, created_at FROM sessions; + + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + + CREATE INDEX idx_sessions_user_id ON sessions(user_id); + CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + ` +}; diff --git a/src/lib/server/db/queries/authSettings.ts b/src/lib/server/db/queries/authSettings.ts new file mode 100644 index 0000000..e90f0a6 --- /dev/null +++ b/src/lib/server/db/queries/authSettings.ts @@ -0,0 +1,104 @@ +import { db } from '../db.ts'; +import { generateApiKey } from '$auth/apiKey.ts'; + +/** + * Types for auth_settings table + */ +export interface AuthSettings { + id: number; + session_duration_hours: number; + api_key: string | null; + created_at: string; + updated_at: string; +} + +export interface UpdateAuthSettingsInput { + sessionDurationHours?: number; + apiKey?: string | null; +} + +/** + * All queries for auth_settings table + * Singleton pattern - only one settings record exists + */ +export const authSettingsQueries = { + /** + * Get auth settings (singleton) + */ + get(): AuthSettings { + const settings = db.queryFirst('SELECT * FROM auth_settings WHERE id = 1'); + if (!settings) { + throw new Error('Auth settings not found - database may not be initialized'); + } + return settings; + }, + + /** + * Get session duration in hours + */ + getSessionDurationHours(): number { + return this.get().session_duration_hours; + }, + + /** + * Get API key (may be null) + */ + getApiKey(): string | null { + return this.get().api_key; + }, + + /** + * Update auth settings + */ + update(input: UpdateAuthSettingsInput): boolean { + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (input.sessionDurationHours !== undefined) { + updates.push('session_duration_hours = ?'); + params.push(input.sessionDurationHours); + } + if (input.apiKey !== undefined) { + updates.push('api_key = ?'); + params.push(input.apiKey); + } + + if (updates.length === 0) { + return false; + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(1); // id is always 1 + + const affected = db.execute( + `UPDATE auth_settings SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Regenerate API key and return the new key + */ + regenerateApiKey(): string { + const newKey = generateApiKey(); + this.update({ apiKey: newKey }); + return newKey; + }, + + /** + * Clear API key (disable API access) + */ + clearApiKey(): boolean { + return this.update({ apiKey: null }); + }, + + /** + * Validate an API key + */ + validateApiKey(key: string): boolean { + const settings = this.get(); + return settings.api_key !== null && settings.api_key === key; + } +}; diff --git a/src/lib/server/db/queries/sessions.ts b/src/lib/server/db/queries/sessions.ts new file mode 100644 index 0000000..a4d7cfb --- /dev/null +++ b/src/lib/server/db/queries/sessions.ts @@ -0,0 +1,145 @@ +import { db } from '../db.ts'; + +/** + * Types for sessions table + */ +export interface Session { + id: string; + user_id: number; + expires_at: string; + created_at: string; + // Metadata fields (Migration 037) + ip_address: string | null; + user_agent: string | null; + browser: string | null; + os: string | null; + device_type: string | null; + last_active_at: string | null; +} + +/** + * Metadata to capture when creating a session + */ +export interface SessionMetadata { + ipAddress?: string; + userAgent?: string; + browser?: string; + os?: string; + deviceType?: string; +} + +/** + * All queries for sessions table + * Multiple sessions per user (different browsers/devices) + */ +export const sessionsQueries = { + /** + * Create a new session with optional metadata + */ + create(userId: number, durationHours: number, metadata?: SessionMetadata): string { + const id = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000); + + db.execute( + `INSERT INTO sessions (id, user_id, expires_at, ip_address, user_agent, browser, os, device_type, last_active_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + id, + userId, + expiresAt.toISOString(), + metadata?.ipAddress ?? null, + metadata?.userAgent ?? null, + metadata?.browser ?? null, + metadata?.os ?? null, + metadata?.deviceType ?? null + ); + + return id; + }, + + /** + * Get a session by ID (regardless of expiration) + */ + getById(id: string): Session | undefined { + return db.queryFirst('SELECT * FROM sessions WHERE id = ?', id); + }, + + /** + * Get a valid (non-expired) session by ID + */ + getValidById(id: string): Session | undefined { + return db.queryFirst( + `SELECT * FROM sessions + WHERE id = ? AND datetime(expires_at) > datetime('now')`, + id + ); + }, + + /** + * Get all sessions for a user + */ + getByUserId(userId: number): Session[] { + return db.query( + 'SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC', + userId + ); + }, + + /** + * Delete a specific session (logout) + */ + deleteById(id: string): boolean { + const affected = db.execute('DELETE FROM sessions WHERE id = ?', id); + return affected > 0; + }, + + /** + * Delete all sessions for a user (logout everywhere) + */ + deleteByUserId(userId: number): number { + return db.execute('DELETE FROM sessions WHERE user_id = ?', userId); + }, + + /** + * Delete all sessions except one (logout other devices) + */ + deleteOthersByUserId(userId: number, keepSessionId: string): number { + return db.execute( + 'DELETE FROM sessions WHERE user_id = ? AND id != ?', + userId, + keepSessionId + ); + }, + + /** + * Delete all expired sessions (cleanup) + */ + deleteExpired(): number { + return db.execute(`DELETE FROM sessions WHERE datetime(expires_at) <= datetime('now')`); + }, + + /** + * Extend session expiration (sliding expiration) + * Also updates last_active_at for activity tracking + */ + extendExpiration(id: string, durationHours: number): boolean { + const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000); + const affected = db.execute( + 'UPDATE sessions SET expires_at = ?, last_active_at = CURRENT_TIMESTAMP WHERE id = ?', + expiresAt.toISOString(), + id + ); + return affected > 0; + }, + + /** + * Count active sessions for a user + */ + countByUserId(userId: number): number { + const result = db.queryFirst<{ count: number }>( + `SELECT COUNT(*) as count FROM sessions + WHERE user_id = ? AND datetime(expires_at) > datetime('now')`, + userId + ); + return result?.count ?? 0; + } +}; diff --git a/src/lib/server/db/queries/users.ts b/src/lib/server/db/queries/users.ts new file mode 100644 index 0000000..6942e5d --- /dev/null +++ b/src/lib/server/db/queries/users.ts @@ -0,0 +1,122 @@ +import { db } from '../db.ts'; + +/** + * Types for users table + */ +export interface User { + id: number; + username: string; + password_hash: string; + created_at: string; + updated_at: string; +} + +/** + * All queries for users table + * Single admin user - no multi-user support + */ +export const usersQueries = { + /** + * Check if any users exist (for first-run setup detection) + */ + exists(): boolean { + const result = db.queryFirst<{ count: number }>('SELECT COUNT(*) as count FROM users'); + return (result?.count ?? 0) > 0; + }, + + /** + * Check if any local (non-OIDC) users exist + * OIDC users have username starting with 'oidc:' + */ + existsLocal(): boolean { + const result = db.queryFirst<{ count: number }>( + "SELECT COUNT(*) as count FROM users WHERE username NOT LIKE 'oidc:%'" + ); + return (result?.count ?? 0) > 0; + }, + + /** + * Get user by ID + */ + getById(id: number): User | undefined { + return db.queryFirst('SELECT * FROM users WHERE id = ?', id); + }, + + /** + * Get user by username + */ + getByUsername(username: string): User | undefined { + return db.queryFirst('SELECT * FROM users WHERE username = ?', username); + }, + + /** + * Get all usernames (for login analysis - typo detection) + * Excludes OIDC users since they can't login with password + */ + getAllUsernames(): string[] { + const results = db.query<{ username: string }>( + "SELECT username FROM users WHERE username NOT LIKE 'oidc:%'" + ); + return results.map((r) => r.username); + }, + + /** + * Create a new user (should only be called once during setup) + */ + create(username: string, passwordHash: string): number { + db.execute( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + username, + passwordHash + ); + + const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id'); + return result?.id ?? 0; + }, + + /** + * Update user's password + */ + updatePassword(id: number, passwordHash: string): boolean { + const affected = db.execute( + 'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + passwordHash, + id + ); + return affected > 0; + }, + + /** + * Update username + */ + updateUsername(id: number, username: string): boolean { + const affected = db.execute( + 'UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + username, + id + ); + return affected > 0; + }, + + /** + * Get or create OIDC user + * OIDC users have no password (placeholder hash) + */ + getOrCreateOidcUser(identifier: string): number { + const username = `oidc:${identifier}`; + const existing = this.getByUsername(username); + if (existing) { + return existing.id; + } + + // Create with placeholder - OIDC users can't login with password + db.execute( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + username, + 'OIDC_NO_PASSWORD' + ); + + const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id'); + return result?.id ?? 0; + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 77f8880..47dc8c3 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -1,7 +1,7 @@ -- Profilarr Database Schema -- This file documents the current database schema after all migrations -- DO NOT execute this file directly - use migrations instead --- Last updated: 2026-01-22 +-- Last updated: 2026-01-24 -- ============================================================================== -- TABLE: migrations @@ -648,3 +648,57 @@ CREATE TABLE github_cache ( -- GitHub cache indexes (Migration: 033_create_github_cache.ts) CREATE INDEX idx_github_cache_type ON github_cache(cache_type); CREATE INDEX idx_github_cache_expires ON github_cache(expires_at); + +-- ============================================================================== +-- TABLE: users +-- Purpose: Store admin user credentials (single-user app) +-- Migration: 036_create_auth_tables.ts +-- ============================================================================== + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: sessions +-- Purpose: Store user sessions (allows login from multiple devices) +-- Migration: 036_create_auth_tables.ts, 037_add_session_metadata.ts +-- ============================================================================== + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- UUID + user_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- Session metadata (Migration 037) + ip_address TEXT, -- Client IP when session created + user_agent TEXT, -- Full user agent string + browser TEXT, -- Parsed browser name/version (e.g., "Chrome 120") + os TEXT, -- Parsed OS (e.g., "Windows 11") + device_type TEXT, -- Device category (Desktop, Mobile, Tablet) + last_active_at DATETIME, -- Updated on sliding expiration + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- ============================================================================== +-- TABLE: auth_settings +-- Purpose: Store auth configuration (singleton pattern with id=1) +-- Migration: 036_create_auth_tables.ts +-- ============================================================================== + +CREATE TABLE auth_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + session_duration_hours INTEGER NOT NULL DEFAULT 168, -- 7 days + api_key TEXT, -- For programmatic access + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/lib/server/utils/auth/README.md b/src/lib/server/utils/auth/README.md new file mode 100644 index 0000000..b91afa3 --- /dev/null +++ b/src/lib/server/utils/auth/README.md @@ -0,0 +1,207 @@ +# Auth Module + +Authentication and session management for Profilarr. + +## Auth Modes + +Controlled by the `AUTH` environment variable: + +| Mode | Description | +|------|-------------| +| `on` | Default. Username/password required for all requests | +| `local` | Skip auth for local IPs (192.168.x.x, 10.x.x.x, etc.) | +| `off` | Disable auth entirely (trust reverse proxy like Authelia) | +| `oidc` | Login via external OIDC provider (Google, Authentik, Keycloak) | + +## Sequence Diagrams + +### AUTH=on (Default) + +#### First Run (No User) +```mermaid +sequenceDiagram + participant U as User + participant P as Profilarr + + U->>P: GET / + P->>P: Check: user exists? No + P->>U: Redirect /auth/setup + U->>P: GET /auth/setup + P->>U: Show setup form + U->>P: POST /auth/setup (username, password) + P->>P: Create user, create session + P->>U: Set cookie, redirect / +``` + +#### Login Flow +```mermaid +sequenceDiagram + participant U as User + participant P as Profilarr + + U->>P: GET / + P->>P: Check: has session? No + P->>U: Redirect /auth/login + U->>P: GET /auth/login + P->>U: Show login form + U->>P: POST /auth/login (username, password) + P->>P: Verify password, create session + P->>U: Set cookie, redirect / +``` + +#### Authenticated Request +```mermaid +sequenceDiagram + participant U as User + participant P as Profilarr + + U->>P: GET / (with session cookie) + P->>P: Validate session + P->>P: Past halfway? Extend session + P->>U: 200 OK (page content) +``` + +### AUTH=local + +#### Local IP +```mermaid +sequenceDiagram + participant U as User (192.168.x.x) + participant P as Profilarr + + U->>P: GET / + P->>P: Check: AUTH=local + local IP? Yes + P->>U: 200 OK (skip auth) +``` + +#### External IP +```mermaid +sequenceDiagram + participant U as User (external) + participant P as Profilarr + + U->>P: GET / + P->>P: Check: AUTH=local + local IP? No + P->>P: Check: has session? No + P->>U: Redirect /auth/login + Note over U,P: Same as AUTH=on login flow +``` + +### AUTH=oidc + +```mermaid +sequenceDiagram + participant U as User + participant P as Profilarr + participant IDP as OIDC Provider + + U->>P: GET / + P->>P: Check: has session? No + P->>U: Redirect /auth/login + U->>P: GET /auth/login + P->>U: Show "Sign in with SSO" button + U->>P: GET /auth/oidc/login + P->>P: Generate state, store in cookie + P->>U: Redirect to IDP + U->>IDP: Login at provider + IDP->>U: Redirect /auth/oidc/callback?code=xxx + U->>P: GET /auth/oidc/callback + P->>P: Verify state + P->>IDP: Exchange code for tokens + IDP->>P: access_token + id_token + P->>P: Verify id_token, create session + P->>U: Set cookie, redirect / +``` + +### AUTH=off + +```mermaid +sequenceDiagram + participant U as User + participant P as Profilarr + + U->>P: GET / + P->>P: Check: AUTH=off? Yes + P->>U: 200 OK (no auth checks) + Note over U,P: Trust reverse proxy (Authelia, etc.) +``` + +## Scenarios + +### AUTH=on (Default) + +| Scenario | What Happens | +|----------|--------------| +| First run, no users | Redirect to `/auth/setup` | +| User exists, not logged in | Redirect to `/auth/login` | +| User exists, logged in | Allow access | +| Session expired | Redirect to `/auth/login` | +| API request, no auth | 401 JSON response | +| API request, valid X-Api-Key | Allow access | +| Visit `/auth/setup` after user exists | Redirect to `/` | + +### AUTH=local + +| Scenario | What Happens | +|----------|--------------| +| Local IP (192.168.x.x) | Allow access (skip auth) | +| Local IP, first run | Redirect to `/auth/setup` (still need to create user) | +| External IP, not logged in | Redirect to `/auth/login` | +| External IP, logged in | Allow access | + +### AUTH=oidc + +| Scenario | What Happens | +|----------|--------------| +| Not logged in | Redirect to `/auth/login` (shows SSO button) | +| Click "Sign in with SSO" | Redirect to OIDC provider | +| Return from provider | Create session, redirect to `/` | +| Session expired | Redirect to `/auth/login` | +| Visit `/auth/setup` | Redirect to `/` (no setup needed) | + +### AUTH=off + +| Scenario | What Happens | +|----------|--------------| +| Any request | Allow access (no auth checks) | + +## Session Management + +- **Duration**: 7 days (configurable in Settings > Security) +- **Sliding expiration**: Session extended when past halfway point +- **Multiple sessions**: Users can be logged in from multiple devices +- **Metadata tracked**: IP, user agent, browser, OS, device type, last active + +## Files + +| File | Purpose | +|------|---------| +| `middleware.ts` | Core auth logic (getAuthState, isPublicPath) | +| `password.ts` | Bcrypt hash/verify | +| `network.ts` | IP detection (getClientIp, isLocalAddress) | +| `userAgent.ts` | Parse browser/OS/device from user agent | +| `apiKey.ts` | API key generation | +| `oidc.ts` | OIDC discovery, token exchange, JWT parsing | + +## Environment Variables + +```bash +# Auth mode +AUTH=on # on, local, off, oidc + +# OIDC (only when AUTH=oidc) +OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration +OIDC_CLIENT_ID=profilarr +OIDC_CLIENT_SECRET=your-secret +``` + +## Routes + +| Route | Purpose | +|-------|---------| +| `/auth/setup` | First-run setup (create admin account) | +| `/auth/login` | Login page (form or OIDC button) | +| `/auth/logout` | Logout (clear session) | +| `/auth/oidc/login` | Initiate OIDC flow | +| `/auth/oidc/callback` | Handle OIDC provider response | +| `/settings/security` | Manage sessions, API key, password | diff --git a/src/lib/server/utils/auth/apiKey.ts b/src/lib/server/utils/auth/apiKey.ts new file mode 100644 index 0000000..4e506dd --- /dev/null +++ b/src/lib/server/utils/auth/apiKey.ts @@ -0,0 +1,12 @@ +/** + * API key generation utility + * Generates 32 hex character keys (UUID without hyphens, like Sonarr) + */ + +/** + * Generate a new API key + * Returns 32 lowercase hex characters (128 bits) + */ +export function generateApiKey(): string { + return crypto.randomUUID().replace(/-/g, ''); +} diff --git a/src/lib/server/utils/auth/loginAnalysis.ts b/src/lib/server/utils/auth/loginAnalysis.ts new file mode 100644 index 0000000..e68a977 --- /dev/null +++ b/src/lib/server/utils/auth/loginAnalysis.ts @@ -0,0 +1,126 @@ +/** + * Login attempt analysis utilities + * Helps distinguish between typos and potential attack attempts + */ + +// Common usernames attackers try +const COMMON_ATTACK_USERNAMES = [ + 'admin', + 'administrator', + 'root', + 'user', + 'test', + 'guest', + 'demo', + 'system', + 'operator', + 'superuser', + 'master', + 'default' +]; + +/** + * Check if a username is commonly used in brute force attacks + */ +export function isCommonAttackUsername(username: string): boolean { + return COMMON_ATTACK_USERNAMES.includes(username.toLowerCase()); +} + +/** + * Calculate Levenshtein distance between two strings + */ +function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, // substitution + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j] + 1 // deletion + ); + } + } + } + + return matrix[b.length][a.length]; +} + +/** + * Find a similar username from the list of existing usernames + * Returns the similar username if found (within 2 edits), null otherwise + */ +export function findSimilarUsername( + attempted: string, + existingUsernames: string[] +): string | null { + const attemptedLower = attempted.toLowerCase(); + + for (const existing of existingUsernames) { + const distance = levenshteinDistance(attemptedLower, existing.toLowerCase()); + // Allow up to 2 character differences for typo detection + if (distance > 0 && distance <= 2) { + return existing; + } + } + + return null; +} + +export interface LoginFailureAnalysis { + reason: 'user_not_found' | 'invalid_password'; + similarUser: string | null; + isCommonAttack: boolean; +} + +/** + * Analyze a failed login attempt for logging purposes + */ +export function analyzeLoginFailure( + username: string, + existingUsernames: string[], + userExists: boolean +): LoginFailureAnalysis { + if (userExists) { + return { + reason: 'invalid_password', + similarUser: null, + isCommonAttack: false + }; + } + + return { + reason: 'user_not_found', + similarUser: findSimilarUsername(username, existingUsernames), + isCommonAttack: isCommonAttackUsername(username) + }; +} + +/** + * Format a login failure for logging + */ +export function formatLoginFailure(analysis: LoginFailureAnalysis): string { + if (analysis.reason === 'invalid_password') { + return 'invalid password'; + } + + if (analysis.similarUser) { + return `unknown user (similar to '${analysis.similarUser}')`; + } + + if (analysis.isCommonAttack) { + return 'unknown user (common attack username)'; + } + + return 'unknown user'; +} diff --git a/src/lib/server/utils/auth/middleware.ts b/src/lib/server/utils/auth/middleware.ts new file mode 100644 index 0000000..b8b9553 --- /dev/null +++ b/src/lib/server/utils/auth/middleware.ts @@ -0,0 +1,152 @@ +/** + * Auth middleware utilities + * Core auth logic - keeps hooks.server.ts thin + */ + +import type { RequestEvent } from '@sveltejs/kit'; +import { config } from '$config'; +import { usersQueries, type User } from '$db/queries/users.ts'; +import { sessionsQueries, type Session } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { isLocalAddress, getClientIp } from './network.ts'; +import { logger } from '$logger/logger.ts'; + +/** + * Auth state returned by getAuthState + */ +export interface AuthState { + needsSetup: boolean; + user: User | null; + session: Session | null; + skipAuth: boolean; // true when AUTH=off or AUTH=local+local IP +} + +/** + * Paths that don't require authentication + */ +const PUBLIC_PATHS = ['/auth/login', '/auth/setup', '/auth/oidc', '/api/v1/health']; + +/** + * Check if a path is public (doesn't require auth) + */ +export function isPublicPath(pathname: string): boolean { + return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/')); +} + +/** + * Get auth state from request + * Checks auth mode, API key, and session cookie + */ +export function getAuthState(event: RequestEvent): AuthState { + const hasLocalUsers = usersQueries.existsLocal(); + + // AUTH=off - skip all auth (trust external proxy like Authelia/Authentik) + if (config.authMode === 'off') { + return { + needsSetup: false, + user: null, + session: null, + skipAuth: true + }; + } + + // AUTH=local - skip auth for local IPs + if (config.authMode === 'local') { + const clientIp = getClientIp(event); + if (isLocalAddress(clientIp)) { + void logger.debug('Local IP bypass', { + source: 'Auth', + meta: { ip: clientIp } + }); + return { + needsSetup: !hasLocalUsers, + user: null, + session: null, + skipAuth: true + }; + } + } + + // AUTH=oidc - uses sessions but no local user/password + if (config.authMode === 'oidc') { + const sessionId = event.cookies.get('session'); + const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null; + const user = session ? usersQueries.getById(session.user_id) ?? null : null; + + return { + needsSetup: false, // No setup needed for OIDC + user, + session, + skipAuth: false + }; + } + + // AUTH=on (default) - full username/password auth + + // Check API key (header or query param) + const apiKey = + event.request.headers.get('X-Api-Key') || event.url.searchParams.get('apikey'); + if (apiKey) { + const ip = getClientIp(event); + const endpoint = event.url.pathname; + + if (authSettingsQueries.validateApiKey(apiKey)) { + void logger.info('API key authenticated', { + source: 'Auth:APIKey', + meta: { ip, endpoint } + }); + return { + needsSetup: false, + user: { id: 0, username: 'api' } as User, + session: null, + skipAuth: false + }; + } else { + // Mask API key - only show last 4 chars + const maskedKey = apiKey.length > 4 ? `****${apiKey.slice(-4)}` : '****'; + void logger.warn('Invalid API key', { + source: 'Auth:APIKey', + meta: { ip, endpoint, key: maskedKey } + }); + } + } + + // Check session cookie + const sessionId = event.cookies.get('session'); + const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null; + const user = session ? usersQueries.getById(session.user_id) ?? null : null; + + return { + needsSetup: !hasLocalUsers, + user, + session, + skipAuth: false + }; +} + +/** + * Sliding expiration: extend session if past halfway point + * Avoids DB write on every request while keeping active users logged in + */ +export function maybeExtendSession(session: Session): void { + const durationHours = authSettingsQueries.getSessionDurationHours(); + const expiresAt = new Date(session.expires_at).getTime(); + const now = Date.now(); + const halfDuration = (durationHours * 60 * 60 * 1000) / 2; + + // Only extend if less than half the duration remains + if (expiresAt - now < halfDuration) { + sessionsQueries.extendExpiration(session.id, durationHours); + void logger.debug('Session extended', { + source: 'Auth:Session', + meta: { userId: session.user_id } + }); + } +} + +/** + * Clean expired sessions - call on startup + */ +export function cleanupExpiredSessions(): number { + return sessionsQueries.deleteExpired(); +} diff --git a/src/lib/server/utils/auth/network.ts b/src/lib/server/utils/auth/network.ts new file mode 100644 index 0000000..bbcc55c --- /dev/null +++ b/src/lib/server/utils/auth/network.ts @@ -0,0 +1,136 @@ +/** + * Network utilities for detecting local/private IP addresses + * Used for AUTH=local mode to bypass auth for local network requests + * + * Based on Sonarr's implementation: + * https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs + */ + +/** + * Check if an IP address is a local/private network address + * + * IPv4 ranges: + * - 127.0.0.0/8 (loopback) + * - 10.0.0.0/8 (Class A private) + * - 172.16.0.0/12 (Class B private) + * - 192.168.0.0/16 (Class C private) + * - 169.254.0.0/16 (link-local, no DHCP) + * + * IPv6 ranges: + * - ::1 (loopback) + * - fe80::/10 (link-local) + * - fc00::/7 (unique local) + * - fec0::/10 (site-local, deprecated but still checked) + */ +export function isLocalAddress(ip: string): boolean { + // Handle IPv6-mapped IPv4 (::ffff:192.168.1.1) + if (ip.startsWith('::ffff:')) { + ip = ip.slice(7); + } + + // Check if it's an IPv4 address + if (ip.includes('.')) { + return isLocalIPv4(ip); + } + + // IPv6 checks + return isLocalIPv6(ip); +} + +/** + * Check if an IPv4 address is local/private + */ +function isLocalIPv4(ip: string): boolean { + const parts = ip.split('.'); + if (parts.length !== 4) return false; + + const bytes = parts.map((p) => parseInt(p, 10)); + if (bytes.some((b) => isNaN(b) || b < 0 || b > 255)) return false; + + const [a, b] = bytes; + + // Loopback: 127.0.0.0/8 + if (a === 127) return true; + + // Class A private: 10.0.0.0/8 + if (a === 10) return true; + + // Class B private: 172.16.0.0/12 (172.16.x.x - 172.31.x.x) + if (a === 172 && b >= 16 && b <= 31) return true; + + // Class C private: 192.168.0.0/16 + if (a === 192 && b === 168) return true; + + // Link-local: 169.254.0.0/16 (no DHCP assigned) + if (a === 169 && b === 254) return true; + + return false; +} + +/** + * Check if an IPv6 address is local + */ +function isLocalIPv6(ip: string): boolean { + const lower = ip.toLowerCase(); + + // Loopback + if (lower === '::1') return true; + + // Link-local: fe80::/10 + if (lower.startsWith('fe80:')) return true; + + // Unique local: fc00::/7 (fc00:: or fd00::) + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; + + // Site-local (deprecated): fec0::/10 + if (lower.startsWith('fec')) return true; + + return false; +} + +/** + * Headers to check for client IP, in order of precedence + * Based on @supercharge/request-ip (used by Overseerr) + */ +const IP_HEADERS = [ + 'x-forwarded-for', // Standard proxy header (may contain multiple IPs) + 'x-real-ip', // Nginx + 'x-client-ip', // Apache + 'cf-connecting-ip', // Cloudflare + 'fastly-client-ip', // Fastly + 'true-client-ip', // Akamai/Cloudflare + 'x-cluster-client-ip' // Rackspace +]; + +/** + * Extract client IP from request + * + * Checks common proxy headers in order (like Overseerr's approach), + * then falls back to SvelteKit's getClientAddress() + */ +export function getClientIp(event: { getClientAddress: () => string; request: Request }): string { + const headers = event.request.headers; + + // Check proxy headers in order + for (const header of IP_HEADERS) { + const value = headers.get(header); + if (value) { + // x-forwarded-for may contain multiple IPs: "client, proxy1, proxy2" + const ip = value.split(',')[0].trim(); + if (ip) return ip; + } + } + + // Fall back to SvelteKit's built-in + try { + const address = event.getClientAddress(); + if (address && address !== 'unknown') { + return address; + } + } catch { + // Can throw during prerendering + } + + // Default to loopback + return '127.0.0.1'; +} diff --git a/src/lib/server/utils/auth/oidc.ts b/src/lib/server/utils/auth/oidc.ts new file mode 100644 index 0000000..9f83338 --- /dev/null +++ b/src/lib/server/utils/auth/oidc.ts @@ -0,0 +1,194 @@ +/** + * OIDC (OpenID Connect) utilities + * Handles discovery, token exchange, and ID token parsing + * + * No external dependencies - just native fetch and crypto + */ + +export interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; +} + +export interface TokenResponse { + access_token: string; + id_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; +} + +export interface IdTokenClaims { + sub: string; + email?: string; + name?: string; + preferred_username?: string; + iss: string; + aud: string | string[]; + exp: number; + iat: number; +} + +// Cache discovery document (doesn't change often) +let cachedDiscovery: { + url: string; + doc: DiscoveryDocument; + expires: number; +} | null = null; + +/** + * Fetch and cache OIDC discovery document + */ +export async function getDiscoveryDocument(url: string): Promise { + if (cachedDiscovery && cachedDiscovery.url === url && Date.now() < cachedDiscovery.expires) { + return cachedDiscovery.doc; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery: ${response.status}`); + } + + const doc = (await response.json()) as DiscoveryDocument; + + if (!doc.authorization_endpoint || !doc.token_endpoint) { + throw new Error('Invalid OIDC discovery document'); + } + + // Cache for 1 hour + cachedDiscovery = { + url, + doc, + expires: Date.now() + 60 * 60 * 1000 + }; + + return doc; +} + +/** + * Generate a random state token for CSRF protection + */ +export function generateState(): string { + return crypto.randomUUID(); +} + +/** + * Build the authorization URL + */ +export function buildAuthorizationUrl( + authorizationEndpoint: string, + opts: { + clientId: string; + redirectUri: string; + state: string; + scope?: string; + } +): string { + const params = new URLSearchParams({ + client_id: opts.clientId, + redirect_uri: opts.redirectUri, + response_type: 'code', + scope: opts.scope || 'openid email profile', + state: opts.state + }); + + return `${authorizationEndpoint}?${params.toString()}`; +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCode( + tokenEndpoint: string, + code: string, + opts: { + clientId: string; + clientSecret: string; + redirectUri: string; + } +): Promise { + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: opts.clientId, + client_secret: opts.clientSecret, + redirect_uri: opts.redirectUri + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(`Token exchange error: ${data.error}`); + } + + return data as TokenResponse; +} + +/** + * Decode a JWT and extract claims (no signature verification) + * + * Note: We trust the token because it came from a server-to-server + * exchange using our client secret. The provider validated everything. + */ +export function decodeIdToken(idToken: string): IdTokenClaims { + const parts = idToken.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + + // Base64URL decode the payload + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const decoded = atob(payload); + const claims = JSON.parse(decoded) as IdTokenClaims; + + return claims; +} + +/** + * Verify basic claims on the ID token + */ +export function verifyIdToken( + claims: IdTokenClaims, + opts: { + clientId: string; + issuer: string; + } +): void { + // Verify issuer + if (claims.iss !== opts.issuer) { + throw new Error(`Invalid issuer: expected ${opts.issuer}, got ${claims.iss}`); + } + + // Verify audience + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!audiences.includes(opts.clientId)) { + throw new Error(`Invalid audience: token not issued for ${opts.clientId}`); + } + + // Verify expiration + const now = Math.floor(Date.now() / 1000); + if (claims.exp && claims.exp < now) { + throw new Error('ID token has expired'); + } +} + +/** + * Clear the cached discovery document + */ +export function clearDiscoveryCache(): void { + cachedDiscovery = null; +} diff --git a/src/lib/server/utils/auth/password.ts b/src/lib/server/utils/auth/password.ts new file mode 100644 index 0000000..b41e3c4 --- /dev/null +++ b/src/lib/server/utils/auth/password.ts @@ -0,0 +1,20 @@ +/** + * Password hashing utilities using bcrypt via @felix/bcrypt + * Uses Rust bcrypt via Deno FFI + */ + +import { hash, verify } from '@felix/bcrypt'; + +/** + * Hash a password using bcrypt + */ +export function hashPassword(password: string): Promise { + return hash(password); +} + +/** + * Verify a password against a stored hash + */ +export function verifyPassword(password: string, storedHash: string): Promise { + return verify(password, storedHash); +} diff --git a/src/lib/server/utils/auth/userAgent.ts b/src/lib/server/utils/auth/userAgent.ts new file mode 100644 index 0000000..6e723fe --- /dev/null +++ b/src/lib/server/utils/auth/userAgent.ts @@ -0,0 +1,151 @@ +/** + * User Agent Parser + * + * Simple regex-based parser to extract browser, OS, and device type + * from user agent strings. No heavy libraries - just pattern matching. + */ + +export interface ParsedUserAgent { + browser: string; // "Chrome 120", "Firefox 121", "Safari 17" + os: string; // "Windows 11", "macOS 14", "Ubuntu", "iOS 17" + deviceType: string; // "Desktop", "Mobile", "Tablet" +} + +/** + * Parse a user agent string into structured data + */ +export function parseUserAgent(ua: string): ParsedUserAgent { + if (!ua) { + return { browser: 'Unknown', os: 'Unknown', deviceType: 'Unknown' }; + } + + return { + browser: parseBrowser(ua), + os: parseOS(ua), + deviceType: parseDeviceType(ua) + }; +} + +/** + * Extract browser name and version + */ +function parseBrowser(ua: string): string { + // Order matters - check more specific patterns first + + // Edge (Chromium-based) + const edge = ua.match(/Edg(?:e|A|iOS)?\/(\d+)/); + if (edge) return `Edge ${edge[1]}`; + + // Opera (also Chromium-based, check before Chrome) + const opera = ua.match(/(?:OPR|Opera)\/(\d+)/); + if (opera) return `Opera ${opera[1]}`; + + // Firefox + const firefox = ua.match(/Firefox\/(\d+)/); + if (firefox) return `Firefox ${firefox[1]}`; + + // Safari (check before Chrome since Chrome includes Safari in UA) + // Safari doesn't include "Chrome" in its UA + if (ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium')) { + const safari = ua.match(/Version\/(\d+)/); + if (safari) return `Safari ${safari[1]}`; + return 'Safari'; + } + + // Chrome (and Chromium-based browsers not caught above) + const chrome = ua.match(/(?:Chrome|Chromium)\/(\d+)/); + if (chrome) return `Chrome ${chrome[1]}`; + + // Internet Explorer + const ie = ua.match(/(?:MSIE |rv:)(\d+)/); + if (ie) return `IE ${ie[1]}`; + + // Fallback: try to find any browser-like pattern + const generic = ua.match(/(\w+)\/(\d+)/); + if (generic) return `${generic[1]} ${generic[2]}`; + + return 'Unknown'; +} + +/** + * Extract operating system name and version + */ +function parseOS(ua: string): string { + // iOS (check before Mac since iOS includes "like Mac OS X") + const ios = ua.match(/(?:iPhone|iPad|iPod).*?OS (\d+)/); + if (ios) return `iOS ${ios[1]}`; + + // Android + const android = ua.match(/Android (\d+(?:\.\d+)?)/); + if (android) return `Android ${android[1]}`; + + // Windows + // Note: Windows 11 still reports "Windows NT 10.0" for backwards compatibility + // There's no reliable way to distinguish Win10 from Win11 via user agent alone + if (ua.includes('Windows')) { + if (ua.includes('Windows NT 10.0')) return 'Windows'; + if (ua.includes('Windows NT 6.3')) return 'Windows 8.1'; + if (ua.includes('Windows NT 6.2')) return 'Windows 8'; + if (ua.includes('Windows NT 6.1')) return 'Windows 7'; + if (ua.includes('Windows NT 6.0')) return 'Windows Vista'; + if (ua.includes('Windows NT 5.1')) return 'Windows XP'; + return 'Windows'; + } + + // macOS (after iOS check) + const mac = ua.match(/Mac OS X (\d+)[_.](\d+)/); + if (mac) { + const major = parseInt(mac[1]); + const minor = parseInt(mac[2]); + // macOS 11+ uses major version only in marketing + if (major >= 11) return `macOS ${major}`; + // macOS 10.x uses 10.minor naming + return `macOS ${major}.${minor}`; + } + if (ua.includes('Macintosh')) return 'macOS'; + + // Linux distributions + if (ua.includes('Ubuntu')) return 'Ubuntu'; + if (ua.includes('Fedora')) return 'Fedora'; + if (ua.includes('Debian')) return 'Debian'; + if (ua.includes('Arch')) return 'Arch Linux'; + if (ua.includes('CrOS')) return 'Chrome OS'; + if (ua.includes('Linux')) return 'Linux'; + + // BSD variants + if (ua.includes('FreeBSD')) return 'FreeBSD'; + if (ua.includes('OpenBSD')) return 'OpenBSD'; + + return 'Unknown'; +} + +/** + * Determine device type from user agent + */ +function parseDeviceType(ua: string): string { + // Tablets (check before mobile since some tablets include "Mobile") + if ( + ua.includes('iPad') || + ua.includes('Tablet') || + (ua.includes('Android') && !ua.includes('Mobile')) + ) { + return 'Tablet'; + } + + // Mobile devices + if ( + ua.includes('Mobile') || + ua.includes('iPhone') || + ua.includes('iPod') || + ua.includes('Android') || + ua.includes('webOS') || + ua.includes('BlackBerry') || + ua.includes('Opera Mini') || + ua.includes('IEMobile') + ) { + return 'Mobile'; + } + + // Default to Desktop + return 'Desktop'; +} diff --git a/src/lib/server/utils/config/config.ts b/src/lib/server/utils/config/config.ts index 23274ab..68f2648 100644 --- a/src/lib/server/utils/config/config.ts +++ b/src/lib/server/utils/config/config.ts @@ -2,12 +2,20 @@ * Application configuration singleton */ +export type AuthMode = 'on' | 'local' | 'off' | 'oidc'; + class Config { private basePath: string; public readonly timezone: string; public readonly parserUrl: string; public readonly port: number; public readonly host: string; + public readonly authMode: AuthMode; + public readonly oidc: { + discoveryUrl: string | null; + clientId: string | null; + clientSecret: string | null; + }; constructor() { // Default base path logic: @@ -36,6 +44,19 @@ class Config { // Server bind configuration this.port = parseInt(Deno.env.get('PORT') || '6868', 10); this.host = Deno.env.get('HOST') || '0.0.0.0'; + + // Auth mode: 'on' (default), 'local', 'off', 'oidc' + const auth = (Deno.env.get('AUTH') || 'on').toLowerCase(); + this.authMode = ['on', 'local', 'off', 'oidc'].includes(auth) + ? (auth as AuthMode) + : 'on'; + + // OIDC configuration (only used when AUTH=oidc) + this.oidc = { + discoveryUrl: Deno.env.get('OIDC_DISCOVERY_URL') || null, + clientId: Deno.env.get('OIDC_CLIENT_ID') || null, + clientSecret: Deno.env.get('OIDC_CLIENT_SECRET') || null + }; } /** diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 632a872..e6a56c8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,8 +5,12 @@ import PageNav from '$ui/navigation/pageNav/pageNav.svelte'; import AlertContainer from '$alerts/AlertContainer.svelte'; import { sidebarCollapsed } from '$lib/client/stores/sidebar'; + import { page } from '$app/stores'; export let data; + + // Hide navigation on auth pages (login, setup, etc.) + $: isAuthPage = $page.url.pathname.startsWith('/auth/'); @@ -14,25 +18,29 @@ Profilarr - - +{#if !isAuthPage} + + +{/if} - - +{#if !isAuthPage} + + +{/if} -
+
diff --git a/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte index 30920d4..f85c587 100644 --- a/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte +++ b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte @@ -166,7 +166,7 @@ name="value-{childIndex}" value={child.value as number} on:change={(e) => { - child.value = e.detail; + if (e.detail !== undefined) child.value = e.detail; notifyChange(); }} font="mono" @@ -181,7 +181,7 @@ name="value-{childIndex}" value={child.value as number} on:change={(e) => { - child.value = e.detail; + if (e.detail !== undefined) child.value = e.detail; notifyChange(); }} min={1} diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts new file mode 100644 index 0000000..77d278f --- /dev/null +++ b/src/routes/auth/login/+page.server.ts @@ -0,0 +1,99 @@ +import type { Actions, ServerLoad } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; +import { config } from '$config'; +import { usersQueries } from '$db/queries/users.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { verifyPassword } from '$auth/password.ts'; +import { getClientIp } from '$auth/network.ts'; +import { parseUserAgent } from '$auth/userAgent.ts'; +import { analyzeLoginFailure, formatLoginFailure } from '$auth/loginAnalysis.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = () => { + // OIDC mode - just show the OIDC button, no setup needed + if (config.authMode === 'oidc') { + return { authMode: 'oidc' }; + } + + // If no local users exist, redirect to setup + // (OIDC users don't count - they can't login with password) + if (!usersQueries.existsLocal()) { + throw redirect(303, '/auth/setup'); + } + + return { authMode: config.authMode }; +}; + +export const actions: Actions = { + default: async (event) => { + const { request, cookies } = event; + const formData = await request.formData(); + const username = (formData.get('username') as string)?.trim(); + const password = formData.get('password') as string; + + // Validation + if (!username || !password) { + return fail(400, { error: 'Username and password are required', username }); + } + + // Find user + const user = usersQueries.getByUsername(username); + if (!user) { + const ip = getClientIp(event); + const allUsernames = usersQueries.getAllUsernames(); + const analysis = analyzeLoginFailure(username, allUsernames, false); + + await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, { + source: 'Auth:Login', + meta: { username, ip, ...analysis } + }); + return fail(400, { error: 'Invalid username or password', username }); + } + + // Verify password + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + const ip = getClientIp(event); + const analysis = analyzeLoginFailure(username, [], true); + + await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, { + source: 'Auth:Login', + meta: { username, ip, ...analysis } + }); + return fail(400, { error: 'Invalid username or password', username }); + } + + // Capture session metadata + const ipAddress = getClientIp(event); + const userAgent = request.headers.get('user-agent') ?? ''; + const parsed = parseUserAgent(userAgent); + + // Create session with metadata + const durationHours = authSettingsQueries.getSessionDurationHours(); + const sessionId = sessionsQueries.create(user.id, durationHours, { + ipAddress, + userAgent, + browser: parsed.browser, + os: parsed.os, + deviceType: parsed.deviceType + }); + + await logger.info(`Login successful for '${username}'`, { + source: 'Auth:Login', + meta: { username, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType } + }); + + // Set session cookie + const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000); + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires + }); + + // Redirect to home + throw redirect(303, '/'); + } +}; diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte new file mode 100644 index 0000000..c4ce3c8 --- /dev/null +++ b/src/routes/auth/login/+page.svelte @@ -0,0 +1,93 @@ + + + + Login - Profilarr + + +
+
+
+ Profilarr logo +
+

Welcome back

+

+ Sign in to continue. +

+
+
+ + {#if data.authMode === 'oidc'} + +
+
diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..a3b3158 --- /dev/null +++ b/src/routes/auth/logout/+server.ts @@ -0,0 +1,26 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { usersQueries } from '$db/queries/users.ts'; +import { logger } from '$logger/logger.ts'; + +export const GET: RequestHandler = async ({ cookies }) => { + const sessionId = cookies.get('session'); + + if (sessionId) { + // Get session info before deleting for logging + const session = sessionsQueries.getById(sessionId); + if (session) { + const user = usersQueries.getById(session.user_id); + await logger.info(`User '${user?.username ?? 'unknown'}' logged out`, { + source: 'Auth:Session', + meta: { userId: session.user_id, username: user?.username } + }); + } + sessionsQueries.deleteById(sessionId); + } + + cookies.delete('session', { path: '/' }); + + throw redirect(303, '/auth/login'); +}; diff --git a/src/routes/auth/oidc/callback/+server.ts b/src/routes/auth/oidc/callback/+server.ts new file mode 100644 index 0000000..edfb446 --- /dev/null +++ b/src/routes/auth/oidc/callback/+server.ts @@ -0,0 +1,118 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { config } from '$config'; +import { getDiscoveryDocument, exchangeCode, decodeIdToken, verifyIdToken } from '$auth/oidc.ts'; +import { usersQueries } from '$db/queries/users.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { getClientIp } from '$auth/network.ts'; +import { parseUserAgent } from '$auth/userAgent.ts'; +import { logger } from '$logger/logger.ts'; + +export const GET: RequestHandler = async (event) => { + const { url, cookies, request } = event; + + // Get code and state from query params + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Handle provider errors + if (errorParam) { + throw error(400, `OIDC error: ${errorParam} - ${errorDescription || 'Unknown error'}`); + } + + // Verify state (CSRF protection) + const savedState = cookies.get('oidc_state'); + if (!state || state !== savedState) { + const ip = getClientIp(event); + await logger.warn('OIDC state mismatch (possible CSRF attempt)', { + source: 'Auth:OIDC', + meta: { ip } + }); + throw error(400, 'Invalid state parameter'); + } + cookies.delete('oidc_state', { path: '/' }); + + // Verify we have a code + if (!code) { + throw error(400, 'No authorization code provided'); + } + + // Validate OIDC configuration + if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) { + throw error(500, 'OIDC is not configured'); + } + + // Fetch discovery document + const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl); + + // Exchange code for tokens + const ip = getClientIp(event); + let tokens; + try { + tokens = await exchangeCode(discovery.token_endpoint, code, { + clientId: config.oidc.clientId, + clientSecret: config.oidc.clientSecret, + redirectUri: `${config.serverUrl}/auth/oidc/callback` + }); + } catch (err) { + await logger.warn('OIDC token exchange failed', { + source: 'Auth:OIDC', + meta: { ip, error: err instanceof Error ? err.message : String(err) } + }); + throw error(500, 'Failed to exchange authorization code'); + } + + // Decode and verify ID token + let claims; + try { + claims = decodeIdToken(tokens.id_token); + verifyIdToken(claims, { + clientId: config.oidc.clientId, + issuer: discovery.issuer + }); + } catch (err) { + await logger.error('OIDC ID token verification failed', { + source: 'Auth:OIDC', + meta: { ip, error: err instanceof Error ? err.message : String(err) } + }); + throw error(500, 'Failed to verify ID token'); + } + + // Get or create OIDC user (using 'sub' as unique identifier) + const userId = usersQueries.getOrCreateOidcUser(claims.sub); + + // Capture session metadata + const ipAddress = getClientIp(event); + const userAgent = request.headers.get('user-agent') ?? ''; + const parsed = parseUserAgent(userAgent); + + // Create session + const durationHours = authSettingsQueries.getSessionDurationHours(); + const sessionId = sessionsQueries.create(userId, durationHours, { + ipAddress, + userAgent, + browser: parsed.browser, + os: parsed.os, + deviceType: parsed.deviceType + }); + + await logger.info(`OIDC login successful for '${claims.sub}'`, { + source: 'Auth:OIDC', + meta: { sub: claims.sub, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType } + }); + + // Set session cookie + const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000); + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires + }); + + // Redirect to home + throw redirect(303, '/'); +}; diff --git a/src/routes/auth/oidc/login/+server.ts b/src/routes/auth/oidc/login/+server.ts new file mode 100644 index 0000000..d532f66 --- /dev/null +++ b/src/routes/auth/oidc/login/+server.ts @@ -0,0 +1,58 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { config } from '$config'; +import { getDiscoveryDocument, generateState, buildAuthorizationUrl } from '$auth/oidc.ts'; +import { getClientIp } from '$auth/network.ts'; +import { logger } from '$logger/logger.ts'; + +export const GET: RequestHandler = async (event) => { + const { cookies } = event; + const ip = getClientIp(event); + + // Validate OIDC configuration + if (config.authMode !== 'oidc') { + throw error(400, 'OIDC authentication is not enabled'); + } + + if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) { + const missing = [ + !config.oidc.discoveryUrl && 'OIDC_DISCOVERY_URL', + !config.oidc.clientId && 'OIDC_CLIENT_ID', + !config.oidc.clientSecret && 'OIDC_CLIENT_SECRET' + ].filter(Boolean); + + await logger.error(`OIDC config missing: ${missing.join(', ')}`, { + source: 'Auth:OIDC', + meta: { missing } + }); + throw error(500, 'OIDC is not configured. Set OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET'); + } + + await logger.debug('OIDC flow started', { + source: 'Auth:OIDC', + meta: { ip } + }); + + // Fetch discovery document + const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl); + + // Generate state token for CSRF protection + const state = generateState(); + + // Store state in cookie (10 minute expiry) + cookies.set('oidc_state', state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 10 + }); + + // Build authorization URL and redirect + const authUrl = buildAuthorizationUrl(discovery.authorization_endpoint, { + clientId: config.oidc.clientId, + redirectUri: `${config.serverUrl}/auth/oidc/callback`, + state + }); + + throw redirect(302, authUrl); +}; diff --git a/src/routes/auth/setup/+page.server.ts b/src/routes/auth/setup/+page.server.ts new file mode 100644 index 0000000..32224f3 --- /dev/null +++ b/src/routes/auth/setup/+page.server.ts @@ -0,0 +1,105 @@ +import type { Actions, ServerLoad } from '@sveltejs/kit'; +import { fail, redirect } from '@sveltejs/kit'; +import { usersQueries } from '$db/queries/users.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { hashPassword } from '$auth/password.ts'; +import { getClientIp } from '$auth/network.ts'; +import { parseUserAgent } from '$auth/userAgent.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = () => { + // If local users already exist, redirect to home + // (OIDC users don't count - they need to create a local account to use password auth) + if (usersQueries.existsLocal()) { + throw redirect(303, '/'); + } + + return {}; +}; + +export const actions: Actions = { + default: async (event) => { + const { request, cookies } = event; + + // Double-check no local users exist (race condition protection) + if (usersQueries.existsLocal()) { + throw redirect(303, '/'); + } + + const formData = await request.formData(); + const username = (formData.get('username') as string)?.trim(); + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + // Validation + if (!username) { + return fail(400, { error: 'Username is required', username }); + } + + if (username.length < 3) { + return fail(400, { error: 'Username must be at least 3 characters', username }); + } + + if (!password) { + return fail(400, { error: 'Password is required', username }); + } + + if (password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', username }); + } + + if (password !== confirmPassword) { + return fail(400, { error: 'Passwords do not match', username }); + } + + try { + // Hash password and create user + const passwordHash = await hashPassword(password); + const userId = usersQueries.create(username, passwordHash); + + if (!userId) { + return fail(500, { error: 'Failed to create account', username }); + } + + // Capture session metadata + const ipAddress = getClientIp(event); + const userAgent = request.headers.get('user-agent') ?? ''; + const parsed = parseUserAgent(userAgent); + + // Create session with metadata + const durationHours = authSettingsQueries.getSessionDurationHours(); + const sessionId = sessionsQueries.create(userId, durationHours, { + ipAddress, + userAgent, + browser: parsed.browser, + os: parsed.os, + deviceType: parsed.deviceType + }); + + await logger.info(`Account created: '${username}'`, { + source: 'Auth', + meta: { username, ip: ipAddress } + }); + + // Set session cookie + const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000); + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + expires + }); + + // Redirect to home + throw redirect(303, '/'); + } catch (err) { + // Re-throw redirects + if (err instanceof Response || (err && typeof err === 'object' && 'status' in err)) { + throw err; + } + + return fail(500, { error: 'Failed to create account', username }); + } + } +}; diff --git a/src/routes/auth/setup/+page.svelte b/src/routes/auth/setup/+page.svelte new file mode 100644 index 0000000..ea2112f --- /dev/null +++ b/src/routes/auth/setup/+page.svelte @@ -0,0 +1,125 @@ + + + + Setup - Profilarr + + +
+
+ +
+
+ Profilarr logo +
+

Welcome to Profilarr

+

+ Create your admin account to get started. +

+
+
+ +
+

+ Configure authentication via the AUTH environment variable: +

+
    +
  • + + on + — Full authentication + (default) +
  • +
  • + + local + — Skip auth for local network +
  • +
  • + + oidc + — Use external provider +
  • +
  • + + off + — For reverse proxy setups +
  • +
+
+
+ + +
+
{ + submitting = true; + return async ({ update }) => { + await update({ reset: false }); + submitting = false; + }; + }} + > + + + + + + +
+
+
diff --git a/src/routes/settings/security/+page.server.ts b/src/routes/settings/security/+page.server.ts new file mode 100644 index 0000000..c62a29a --- /dev/null +++ b/src/routes/settings/security/+page.server.ts @@ -0,0 +1,145 @@ +import type { Actions, ServerLoad } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; +import { usersQueries } from '$db/queries/users.ts'; +import { sessionsQueries } from '$db/queries/sessions.ts'; +import { authSettingsQueries } from '$db/queries/authSettings.ts'; +import { hashPassword, verifyPassword } from '$auth/password.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = async ({ cookies }) => { + const currentSessionId = cookies.get('session'); + const user = usersQueries.getByUsername('admin') ?? usersQueries.getById(1); + + if (!user) { + return { sessions: [], apiKey: null, currentSessionId: null }; + } + + const sessions = sessionsQueries.getByUserId(user.id); + const apiKey = authSettingsQueries.getApiKey(); + + return { + sessions: sessions.map(s => ({ + id: s.id, + created_at: s.created_at, + expires_at: s.expires_at, + last_active_at: s.last_active_at, + ip_address: s.ip_address, + browser: s.browser, + os: s.os, + device_type: s.device_type, + isCurrent: s.id === currentSessionId + })), + apiKey, + currentSessionId + }; +}; + +export const actions: Actions = { + changePassword: async ({ request, cookies }) => { + const formData = await request.formData(); + const currentPassword = formData.get('currentPassword') as string; + const newPassword = formData.get('newPassword') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (!currentPassword || !newPassword || !confirmPassword) { + return fail(400, { passwordError: 'All fields are required' }); + } + + if (newPassword.length < 8) { + return fail(400, { passwordError: 'New password must be at least 8 characters' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { passwordError: 'Passwords do not match' }); + } + + // Get current user from session + const sessionId = cookies.get('session'); + if (!sessionId) { + return fail(401, { passwordError: 'Not authenticated' }); + } + + const session = sessionsQueries.getValidById(sessionId); + if (!session) { + return fail(401, { passwordError: 'Invalid session' }); + } + + const user = usersQueries.getById(session.user_id); + if (!user) { + return fail(401, { passwordError: 'User not found' }); + } + + // Verify current password + const valid = await verifyPassword(currentPassword, user.password_hash); + if (!valid) { + return fail(400, { passwordError: 'Current password is incorrect' }); + } + + // Update password + const newHash = await hashPassword(newPassword); + usersQueries.updatePassword(user.id, newHash); + + await logger.info(`Password changed for '${user.username}'`, { + source: 'Auth', + meta: { userId: user.id, username: user.username } + }); + + return { passwordSuccess: true }; + }, + + regenerateApiKey: async () => { + const newKey = authSettingsQueries.regenerateApiKey(); + + await logger.info('API key regenerated', { + source: 'Auth:APIKey' + }); + + return { apiKey: newKey, apiKeyRegenerated: true }; + }, + + revokeSession: async ({ request, cookies }) => { + const formData = await request.formData(); + const sessionId = formData.get('sessionId') as string; + const currentSessionId = cookies.get('session'); + + if (!sessionId) { + return fail(400, { sessionError: 'Session ID required' }); + } + + if (sessionId === currentSessionId) { + return fail(400, { sessionError: 'Cannot revoke current session' }); + } + + sessionsQueries.deleteById(sessionId); + + await logger.info('Session revoked', { + source: 'Auth:Session', + meta: { revokedSessionId: sessionId.slice(0, 8) + '...' } + }); + + return { sessionRevoked: true }; + }, + + revokeOtherSessions: async ({ cookies }) => { + const currentSessionId = cookies.get('session'); + if (!currentSessionId) { + return fail(401, { sessionError: 'Not authenticated' }); + } + + const session = sessionsQueries.getValidById(currentSessionId); + if (!session) { + return fail(401, { sessionError: 'Invalid session' }); + } + + const count = sessionsQueries.deleteOthersByUserId(session.user_id, currentSessionId); + + if (count > 0) { + await logger.info(`Revoked ${count} other session${count === 1 ? '' : 's'}`, { + source: 'Auth:Session', + meta: { userId: session.user_id, count } + }); + } + + return { sessionsRevoked: count }; + } +}; diff --git a/src/routes/settings/security/+page.svelte b/src/routes/settings/security/+page.svelte new file mode 100644 index 0000000..01e3ca9 --- /dev/null +++ b/src/routes/settings/security/+page.svelte @@ -0,0 +1,326 @@ + + +
+
+

Security

+

+ Manage your password, API key, and active sessions +

+
+ +
+ +
+
+

Change Password

+

+ Update your account password +

+
+
+
{ + changingPassword = true; + return async ({ update }) => { + await update({ reset: false }); + changingPassword = false; + }; + }} + > + + +
+
+ +
+
+ +
+
+ + +
+
+

API Key

+

+ Authenticate API requests via X-Api-Key header +

+
+
+ {#if apiKey} +
+
+ +
+ +
{ + regeneratingKey = true; + return async ({ update }) => { + await update(); + regeneratingKey = false; + }; + }}> + +
+
+ {:else} +
+

No API key configured

+
+
+ {/if} +
+
+ + +
+
+
+

Active Sessions

+

+ Manage your logged-in sessions across devices +

+
+ {#if data.sessions.length > 1} +
{ + return async ({ update }) => { + await update(); + await invalidateAll(); + }; + }}> +
+
+ {#if data.sessions.length > 0} + + + {#if row.isCurrent} + Current + {:else} +
{ + return async ({ update }) => { + await update(); + await invalidateAll(); + }; + }}> + + + + {/if} +
+
+ {:else} +

No active sessions

+ {/if} +
+
+
+
diff --git a/svelte.config.js b/svelte.config.js index b18b48d..24c8e24 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -29,7 +29,8 @@ const config = { $utils: './src/lib/server/utils', $notifications: './src/lib/server/notifications', $cache: './src/lib/server/utils/cache', - $sync: './src/lib/server/sync' + $sync: './src/lib/server/sync', + $auth: './src/lib/server/utils/auth' } } }; diff --git a/vite.config.ts b/vite.config.ts index 111070d..06f1d4c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ plugins: [deno(), tailwindcss(), sveltekit()], server: { port: 6969, + host: true, watch: { // Ignore temporary files created by editors ignored: ['**/*.tmp.*', '**/*~', '**/.#*']