From 53974e85e036a9ad786489ac3016d21f7dbc2ace Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 19:02:45 +0100 Subject: [PATCH 01/12] fix: accessibility audit - focus management, keyboard nav, ARIA, and CI checks - Add useFocusTrap hook for focus trapping and restoration across all dialogs - Add jsx-a11y ESLint plugin and axe-core Playwright for CI checks - Fix primary flows keyboard completeness (arrow nav, escape handlers) - Fix focus restoration after overlays close (ShortcutOverlay, CommandPalette, ProofInspectorModal, SenderConversionDialog, SnoozeDialog, SettingsModal, Compose) - Add live region announcements (aria-live) for proof inspector and app root - Fix ARIA roles: dialog attributes on SettingsModal, Compose, provenance modal - Fix EmailList invalid listbox role (changed to list) - Add aria-labels to icon-only buttons in EmailView, Sidebar collapsed state - Add aria-current to sidebar navigation, aria-label to nav landmarks - Add Escape key handlers to Topbar dropdown menus - Add data-shortcuts-disabled attribute support for WCAG 2.1.4 compliance - Add screen-reader live regions to root layout for status/alert announcements - Add accessibility E2E spec with axe-core violation checks --- eslint.config.js | 4 + package-lock.json | 1824 +++++++++++++++++ package.json | 2 + src/components/mail/Compose.tsx | 16 +- src/components/mail/EmailList.tsx | 3 +- src/components/mail/EmailView.tsx | 11 +- src/components/mail/SettingsModal.tsx | 14 +- src/components/mail/Sidebar.tsx | 5 +- src/components/mail/Topbar.tsx | 24 +- .../command-palette/CommandPalette.tsx | 3 + .../command-palette/ShortcutOverlay.tsx | 34 +- src/features/command-palette/shortcuts.ts | 1 + .../proof-inspector/ProofInspectorModal.tsx | 10 +- .../SenderConversionDialog.tsx | 14 +- src/features/snooze/SnoozeDialog.tsx | 13 +- src/hooks/use-focus-trap.ts | 69 + src/routes/__root.tsx | 2 + tests/e2e/accessibility.spec.ts | 73 + 18 files changed, 2060 insertions(+), 62 deletions(-) create mode 100644 src/hooks/use-focus-trap.ts create mode 100644 tests/e2e/accessibility.spec.ts diff --git a/eslint.config.js b/eslint.config.js index 047f824e..65fabd13 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ import js from "@eslint/js"; import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; import globals from "globals"; +import jsxA11y from "eslint-plugin-jsx-a11y"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; @@ -17,13 +18,16 @@ export default tseslint.config( plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, + "jsx-a11y": jsxA11y, }, rules: { ...reactHooks.configs.recommended.rules, + ...jsxA11y.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/ban-ts-comment": "warn", + "jsx-a11y/no-autofocus": "off", }, }, eslintPluginPrettier, diff --git a/package-lock.json b/package-lock.json index c2f0b402..22300d0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/js": "^9.32.0", "@playwright/test": "^1.49.1", "@types/node": "^22.16.5", @@ -71,6 +72,7 @@ "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -82,6 +84,19 @@ "vitest": "^4.1.8" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4875,6 +4890,116 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4885,6 +5010,59 @@ "node": ">=12" } }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -5038,6 +5216,56 @@ "ieee754": "^1.2.1" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5420,6 +5648,67 @@ "node": ">=12" } }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5466,6 +5755,42 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5555,6 +5880,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.336", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", @@ -5589,6 +5929,13 @@ "embla-carousel": "8.6.0" } }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -5636,6 +5983,114 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -5643,6 +6098,68 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz", + "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5741,6 +6258,36 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.5.5", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", @@ -6051,6 +6598,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -6092,6 +6655,60 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6101,6 +6718,31 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6110,6 +6752,38 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -6147,12 +6821,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "license": "MIT" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6184,6 +6888,19 @@ } } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6194,6 +6911,77 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -6303,6 +7091,21 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -6312,6 +7115,60 @@ "node": ">=12" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6324,6 +7181,87 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6333,6 +7271,42 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6345,6 +7319,32 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6354,6 +7354,175 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isbot": { "version": "5.1.38", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.38.tgz", @@ -6442,6 +7611,22 @@ "node": ">=6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6461,6 +7646,26 @@ "node": ">=6" } }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6792,6 +7997,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/miniflare": { "version": "4.20260410.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260410.0.tgz", @@ -6916,6 +8131,88 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -6948,6 +8245,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7139,6 +8454,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -7473,6 +8798,50 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7542,6 +8911,61 @@ "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", "license": "MIT" }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7584,6 +9008,55 @@ "seroval": "^1.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -7663,6 +9136,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7724,6 +9273,95 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8393,6 +10031,84 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -8437,6 +10153,25 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -9302,6 +11037,95 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index b2ee75e1..32502226 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/js": "^9.32.0", "@playwright/test": "^1.49.1", "@types/node": "^22.16.5", @@ -85,6 +86,7 @@ "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/src/components/mail/Compose.tsx b/src/components/mail/Compose.tsx index 05aab183..811f0457 100644 --- a/src/components/mail/Compose.tsx +++ b/src/components/mail/Compose.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { EmojiPicker } from "./EmojiPicker"; import { TrustBadge, type TrustState } from "@/features/design-system"; import { cn } from "@/lib/utils"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; import { resolveRecipients } from "@/features/compose/recipientResolver"; import { @@ -66,6 +67,7 @@ export function Compose({ const [postage, setPostage] = useState(initialPostage); const [resolvedRecipients, setResolvedRecipients] = useState([]); + const containerRef = useFocusTrap(open, onClose); const textareaRef = useRef(null); const fileInputRef = useRef(null); const imageInputRef = useRef(null); @@ -251,6 +253,10 @@ export function Compose({ className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" /> - - {label} - + + {label} + onChange(e.target.value)} placeholder={placeholder} diff --git a/src/components/mail/EmailList.tsx b/src/components/mail/EmailList.tsx index e6792618..084a4013 100644 --- a/src/components/mail/EmailList.tsx +++ b/src/components/mail/EmailList.tsx @@ -221,8 +221,7 @@ export function EmailList({
    actions.onSnooze?.(email)} - title="Snooze" + aria-label="Snooze" className="inline-flex items-center gap-1.5 rounded-md p-2 text-muted-foreground transition hover:bg-white/[0.06] hover:text-foreground" > @@ -226,7 +226,7 @@ export function EmailView({ actions.onArchive?.(email)} - title="Archive" + aria-label="Archive" className="inline-flex items-center gap-1.5 rounded-md p-2 text-muted-foreground transition hover:bg-white/[0.06] hover:text-foreground" > @@ -235,7 +235,7 @@ export function EmailView({ actions.onTrash?.(email)} - title="Move to trash" + aria-label="Move to trash" className="shrink-0 rounded-md p-2 text-muted-foreground transition hover:bg-white/[0.06] hover:text-foreground" > @@ -243,7 +243,7 @@ export function EmailView({ actions.onToggleStar?.(email)} - title={email.starred ? "Unstar" : "Star"} + aria-label={email.starred ? "Unstar" : "Star"} className={cn( "shrink-0 rounded-md p-2 transition hover:bg-white/[0.06]", email.starred @@ -630,6 +630,9 @@ function ProtocolStatus({ className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" /> void; }) { const [activeTab, setActiveTab] = useState("account"); + const containerRef = useFocusTrap(open, onCancel ?? onClose); return ( @@ -84,6 +86,10 @@ export function SettingsModal({ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" /> +

    {confirmDialog.title}

    {confirmDialog.description}

    + )}
    -
    - {!session.isCurrent && ( - - )} -
    - ))} + ); + }) + )} - {/* Trusted Devices */} + {/* Devices */}
    -

    Trusted devices

    +

    Devices

    - Devices that can access your account without extra verification + Trusted devices with access to your account

    - {devices.map((device) => ( -
    -
    - - {editingDevice === device.id ? ( -
    - setDeviceName(e.target.value)} - className="rounded border border-white/10 bg-white/[0.04] px-2 py-1 text-sm text-foreground outline-none focus:border-white/20" - /> - -
    - ) : ( -
    -

    {device.name}

    -

    - {device.type} • {device.lastActive} -

    -
    - )} -
    - {!editingDevice && ( - - )} + {devices.length === 0 ? ( +
    +

    No devices registered

    - ))} + ) : ( + devices.map((device) => { + const isCurrent = device.isCurrent; + const isDisabled = + device.keyStatus === "revoked" || device.keyStatus === "compromised"; + return ( +
    +
    +
    + +
    + {editingDevice === device.id ? ( +
    + setDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveDeviceName(device.id); + if (e.key === "Escape") setEditingDevice(null); + }} + className="rounded border border-white/10 bg-white/[0.04] px-2 py-1 text-sm text-foreground outline-none focus:border-white/20 w-40" + autoFocus + /> + +
    + ) : ( +
    +

    {device.name}

    + {isCurrent && ( + + Current + + )} + +
    + )} +

    + {device.lastLocation} •{" "} + {formatRelativeTime(device.lastActive)} + {device.trusted && !isDisabled && ( + <> + {" "} + •{" "} + Trusted + + )} +

    +
    +
    +
    + {!isDisabled && !editingDevice && ( + <> + + + + + )} +
    +
    + + {/* Device info sub-row */} + {!isDisabled && !editingDevice && ( +
    + {device.publicKey.slice(0, 16)}... + + Registered{" "} + {formatRelativeTime(device.createdAt)} + + {device.sessions.length > 0 && ( + {device.sessions.filter((s) => !s.revokedAt).length} session(s) + )} +
    + )} +
    + ); + }) + )}
    - {/* Recovery */} + {/* Revocation consequences info */} +
    + + {showRevocationInfo && ( +
    +

    What happens when you revoke a device?

    +
      +
    • +
      + All active sessions on that device are immediately terminated +
    • +
    • +
      + The device's encryption key is invalidated — it can no longer decrypt new messages +
    • +
    • +
      + Existing encrypted messages already on the device remain accessible locally +
    • +
    • +
      + The device must re-authenticate and re-register to regain access +
    • +
    • +
      + If the device is compromised, flagging it also triggers an alert and recommends key rotation +
    • +
    +
    + )} +
    + + {/* Account Recovery */}
    @@ -1181,14 +1455,45 @@ function SecuritySettings() {
    -
    -

    Recovery enabled

    +
    +

    + {recoveryStatus.enabled ? "Recovery enabled" : "Recovery not configured"} +

    - Last updated 3 days ago + {recoveryStatus.lastUpdated && ( + + Last updated {formatRelativeTime(recoveryStatus.lastUpdated)} + + )} +
    +
    + {recoveryStatus.devicesCount} device(s) registered + {recoveryStatus.trustedCount} trusted +
    +
    + +
    -
    @@ -1196,41 +1501,45 @@ function SecuritySettings() {
    -

    Signing keys

    -

    Your public key for verifying messages

    +

    Encryption keys

    +

    + Your public key for encrypted message delivery +

    - - GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ - +
    + + {currentDevice?.publicKey ?? + "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"} + +
    +
    + +
    + Key active + +
    - {/* High-risk actions (roadmap) */} + {/* High-risk actions */}
    @@ -1240,10 +1549,19 @@ function SecuritySettings() {

    -
    -
    - - Coming soon +
    +
    +
    + + Device revocation and key rotation require confirmation +
    +
    +
    + + + All sensitive actions (revocation, compromise flags, key rotation) are logged in your + audit history. Message body content is never recorded. +
    @@ -1269,7 +1587,14 @@ function SecuritySettings() { diff --git a/src/features/audit-log/data.ts b/src/features/audit-log/data.ts index 38f15842..fbc63948 100644 --- a/src/features/audit-log/data.ts +++ b/src/features/audit-log/data.ts @@ -129,4 +129,60 @@ export const MOCK_AUDIT_EVENTS: AuditEvent[] = [ actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, summary: "Session ended", }, + { + id: "evt_015", + kind: "device.registered", + category: "security", + ts: "2026-06-15T09:15:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Device registered: MacBook Air", + }, + { + id: "evt_016", + kind: "device.registered", + category: "security", + ts: "2026-06-14T18:30:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Device registered: iPhone 15 Pro", + }, + { + id: "evt_017", + kind: "device.renamed", + category: "security", + ts: "2026-06-15T10:00:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Device renamed: Work Laptop", + }, + { + id: "evt_018", + kind: "device.revoked", + category: "security", + ts: "2026-06-14T12:00:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Device revoked: Old Android Phone", + }, + { + id: "evt_019", + kind: "device.suspicious_login", + category: "security", + ts: "2026-06-13T03:45:00.000Z", + actor: { type: "system" }, + summary: "Suspicious login attempt from unrecognized device (Moscow, RU)", + }, + { + id: "evt_020", + kind: "device.recovery_activated", + category: "security", + ts: "2026-06-12T20:00:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Account recovery method configured", + }, + { + id: "evt_021", + kind: "device.key_rotated", + category: "security", + ts: "2026-06-10T15:22:00.000Z", + actor: { type: "user", address: "GDQ4...X4KJ", displayName: "Uthaimin" }, + summary: "Encryption key rotated for MacBook Air", + }, ]; diff --git a/src/features/audit-log/types.ts b/src/features/audit-log/types.ts index 79c31e83..835b1fa8 100644 --- a/src/features/audit-log/types.ts +++ b/src/features/audit-log/types.ts @@ -20,6 +20,13 @@ export type AuditEventKind = | "session.ended" | "identity.resolved" | "identity.verification_failed" + | "device.registered" + | "device.renamed" + | "device.revoked" + | "device.trust_toggled" + | "device.key_rotated" + | "device.recovery_activated" + | "device.suspicious_login" // Billing | "postage.attached" | "postage.settled" @@ -37,6 +44,13 @@ export const CATEGORY_FOR_KIND: Record = { "session.ended": "security", "identity.resolved": "security", "identity.verification_failed": "security", + "device.registered": "security", + "device.renamed": "security", + "device.revoked": "security", + "device.trust_toggled": "security", + "device.key_rotated": "security", + "device.recovery_activated": "security", + "device.suspicious_login": "security", "postage.attached": "billing", "postage.settled": "billing", "postage.refunded": "billing", diff --git a/src/features/device-management/types.ts b/src/features/device-management/types.ts new file mode 100644 index 00000000..a40472f2 --- /dev/null +++ b/src/features/device-management/types.ts @@ -0,0 +1,38 @@ +export type DeviceType = "desktop" | "mobile" | "tablet" | "unknown"; +export type KeyStatus = "active" | "compromised" | "revoked" | "rotated"; + +export interface Device { + id: string; + address: string; + name: string; + type: DeviceType; + fingerprint: string; + publicKey: string; + keyStatus: KeyStatus; + trusted: boolean; + lastActive: string; + lastIp: string; + lastLocation: string; + createdAt: string; + isCurrent: boolean; + sessions: Session[]; +} + +export interface Session { + id: string; + deviceId: string; + address: string; + startedAt: string; + lastActiveAt: string; + ip: string; + location: string; + isCurrent: boolean; + revokedAt: string | null; +} + +export interface RecoveryStatus { + enabled: boolean; + lastUpdated: string | null; + devicesCount: number; + trustedCount: number; +} diff --git a/src/features/device-management/useDevices.ts b/src/features/device-management/useDevices.ts new file mode 100644 index 00000000..e5edb78f --- /dev/null +++ b/src/features/device-management/useDevices.ts @@ -0,0 +1,165 @@ +import { useState, useEffect, useCallback } from "react"; +import type { Device, RecoveryStatus } from "./types"; + +const MOCK_DEVICES: Device[] = [ + { + id: "dev_001", + address: "GDQ4...X4KJ", + name: "MacBook Air", + type: "desktop", + fingerprint: "fp_current", + publicKey: "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ", + keyStatus: "active", + trusted: true, + lastActive: new Date().toISOString(), + lastIp: "192.168.1.10", + lastLocation: "San Francisco, CA", + createdAt: new Date(Date.now() - 86400000 * 30).toISOString(), + isCurrent: true, + sessions: [ + { + id: "sess_001", + deviceId: "dev_001", + address: "GDQ4...X4KJ", + startedAt: new Date(Date.now() - 3600000).toISOString(), + lastActiveAt: new Date().toISOString(), + ip: "192.168.1.10", + location: "San Francisco, CA", + isCurrent: true, + revokedAt: null, + }, + ], + }, + { + id: "dev_002", + address: "GDQ4...X4KJ", + name: "iPhone 15 Pro", + type: "mobile", + fingerprint: "fp_iphone", + publicKey: "GBRJ63...M2KN", + keyStatus: "active", + trusted: true, + lastActive: new Date(Date.now() - 7200000).toISOString(), + lastIp: "192.168.1.20", + lastLocation: "San Francisco, CA", + createdAt: new Date(Date.now() - 86400000 * 14).toISOString(), + isCurrent: false, + sessions: [ + { + id: "sess_002", + deviceId: "dev_002", + address: "GDQ4...X4KJ", + startedAt: new Date(Date.now() - 86400000).toISOString(), + lastActiveAt: new Date(Date.now() - 7200000).toISOString(), + ip: "192.168.1.20", + location: "San Francisco, CA", + isCurrent: false, + revokedAt: null, + }, + ], + }, + { + id: "dev_003", + address: "GDQ4...X4KJ", + name: "Old Android Phone", + type: "mobile", + fingerprint: "fp_android", + publicKey: "GCXK42...9PQR", + keyStatus: "revoked", + trusted: false, + lastActive: new Date(Date.now() - 86400000 * 7).toISOString(), + lastIp: "203.0.113.42", + lastLocation: "Unknown location", + createdAt: new Date(Date.now() - 86400000 * 60).toISOString(), + isCurrent: false, + sessions: [], + }, +]; + +export function useDevices() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [recoveryStatus, setRecoveryStatus] = useState({ + enabled: true, + lastUpdated: new Date(Date.now() - 86400000 * 3).toISOString(), + devicesCount: 3, + trustedCount: 2, + }); + + useEffect(() => { + const load = async () => { + setLoading(true); + await new Promise((r) => setTimeout(r, 150)); + setDevices(MOCK_DEVICES); + setRecoveryStatus({ + enabled: true, + lastUpdated: new Date(Date.now() - 86400000 * 3).toISOString(), + devicesCount: MOCK_DEVICES.length, + trustedCount: MOCK_DEVICES.filter((d) => d.trusted).length, + }); + setLoading(false); + }; + load(); + }, []); + + const renameDevice = useCallback( + async (deviceId: string, name: string) => { + await new Promise((r) => setTimeout(r, 100)); + setDevices((prev) => + prev.map((d) => (d.id === deviceId ? { ...d, name } : d)), + ); + }, + [], + ); + + const revokeDevice = useCallback(async (deviceId: string) => { + await new Promise((r) => setTimeout(r, 100)); + setDevices((prev) => + prev.map((d) => { + if (d.id === deviceId) { + return { + ...d, + keyStatus: "revoked" as const, + trusted: false, + sessions: d.sessions.map((s) => ({ ...s, revokedAt: new Date().toISOString() })), + }; + } + return d; + }), + ); + setRecoveryStatus((prev) => ({ + ...prev, + trustedCount: prev.trustedCount - (devices.find((d) => d.id === deviceId)?.trusted ? 1 : 0), + })); + }, [devices]); + + const flagCompromised = useCallback(async (deviceId: string) => { + await new Promise((r) => setTimeout(r, 100)); + setDevices((prev) => + prev.map((d) => { + if (d.id === deviceId) { + return { + ...d, + keyStatus: "compromised" as const, + trusted: false, + sessions: d.sessions.map((s) => ({ ...s, revokedAt: new Date().toISOString() })), + }; + } + return d; + }), + ); + setRecoveryStatus((prev) => ({ + ...prev, + trustedCount: prev.trustedCount - (devices.find((d) => d.id === deviceId)?.trusted ? 1 : 0), + })); + }, [devices]); + + return { + devices, + loading, + recoveryStatus, + renameDevice, + revokeDevice, + flagCompromised, + }; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4dfbdaaa..3183a56f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,14 +16,22 @@ import { Route as ApiV1OpenapiDotjsonRouteImport } from './routes/api/v1/openapi import { Route as ApiV1HealthRouteImport } from './routes/api/v1/health' import { Route as ApiV1ReceiptsIndexRouteImport } from './routes/api/v1/receipts/index' import { Route as ApiV1PostageIndexRouteImport } from './routes/api/v1/postage/index' +import { Route as ApiV1DevicesIndexRouteImport } from './routes/api/v1/devices/index' +import { Route as ApiV1SessionsSessionIdRouteImport } from './routes/api/v1/sessions/$sessionId' import { Route as ApiV1ReceiptsMessageIdRouteImport } from './routes/api/v1/receipts/$messageId' import { Route as ApiV1PostageQuoteRouteImport } from './routes/api/v1/postage/quote' import { Route as ApiV1PostageMessageIdRouteImport } from './routes/api/v1/postage/$messageId' import { Route as ApiV1PoliciesEvaluateRouteImport } from './routes/api/v1/policies/evaluate' import { Route as ApiV1PoliciesOwnerRouteImport } from './routes/api/v1/policies/$owner' +import { Route as ApiV1DevicesRecoveryRouteImport } from './routes/api/v1/devices/recovery' +import { Route as ApiV1DevicesDeviceIdRouteImport } from './routes/api/v1/devices/$deviceId' +import { Route as ApiV1SessionsSessionIdRevokeRouteImport } from './routes/api/v1/sessions/$sessionId/revoke' import { Route as ApiV1ReceiptsMessageIdReadRouteImport } from './routes/api/v1/receipts/$messageId/read' import { Route as ApiV1PostageMessageIdSettleRouteImport } from './routes/api/v1/postage/$messageId/settle' import { Route as ApiV1PostageMessageIdRefundRouteImport } from './routes/api/v1/postage/$messageId/refund' +import { Route as ApiV1DevicesDeviceIdRevokeRouteImport } from './routes/api/v1/devices/$deviceId/revoke' +import { Route as ApiV1DevicesDeviceIdNameRouteImport } from './routes/api/v1/devices/$deviceId/name' +import { Route as ApiV1DevicesDeviceIdCompromisedRouteImport } from './routes/api/v1/devices/$deviceId/compromised' import { Route as ApiV1PoliciesOwnerSendersSenderRouteImport } from './routes/api/v1/policies/$owner/senders/$sender' const MotionGalleryRoute = MotionGalleryRouteImport.update({ @@ -61,6 +69,16 @@ const ApiV1PostageIndexRoute = ApiV1PostageIndexRouteImport.update({ path: '/api/v1/postage/', getParentRoute: () => rootRouteImport, } as any) +const ApiV1DevicesIndexRoute = ApiV1DevicesIndexRouteImport.update({ + id: '/api/v1/devices/', + path: '/api/v1/devices/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1SessionsSessionIdRoute = ApiV1SessionsSessionIdRouteImport.update({ + id: '/api/v1/sessions/$sessionId', + path: '/api/v1/sessions/$sessionId', + getParentRoute: () => rootRouteImport, +} as any) const ApiV1ReceiptsMessageIdRoute = ApiV1ReceiptsMessageIdRouteImport.update({ id: '/api/v1/receipts/$messageId', path: '/api/v1/receipts/$messageId', @@ -86,6 +104,22 @@ const ApiV1PoliciesOwnerRoute = ApiV1PoliciesOwnerRouteImport.update({ path: '/api/v1/policies/$owner', getParentRoute: () => rootRouteImport, } as any) +const ApiV1DevicesRecoveryRoute = ApiV1DevicesRecoveryRouteImport.update({ + id: '/api/v1/devices/recovery', + path: '/api/v1/devices/recovery', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1DevicesDeviceIdRoute = ApiV1DevicesDeviceIdRouteImport.update({ + id: '/api/v1/devices/$deviceId', + path: '/api/v1/devices/$deviceId', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1SessionsSessionIdRevokeRoute = + ApiV1SessionsSessionIdRevokeRouteImport.update({ + id: '/revoke', + path: '/revoke', + getParentRoute: () => ApiV1SessionsSessionIdRoute, + } as any) const ApiV1ReceiptsMessageIdReadRoute = ApiV1ReceiptsMessageIdReadRouteImport.update({ id: '/read', @@ -104,6 +138,24 @@ const ApiV1PostageMessageIdRefundRoute = path: '/refund', getParentRoute: () => ApiV1PostageMessageIdRoute, } as any) +const ApiV1DevicesDeviceIdRevokeRoute = + ApiV1DevicesDeviceIdRevokeRouteImport.update({ + id: '/revoke', + path: '/revoke', + getParentRoute: () => ApiV1DevicesDeviceIdRoute, + } as any) +const ApiV1DevicesDeviceIdNameRoute = + ApiV1DevicesDeviceIdNameRouteImport.update({ + id: '/name', + path: '/name', + getParentRoute: () => ApiV1DevicesDeviceIdRoute, + } as any) +const ApiV1DevicesDeviceIdCompromisedRoute = + ApiV1DevicesDeviceIdCompromisedRouteImport.update({ + id: '/compromised', + path: '/compromised', + getParentRoute: () => ApiV1DevicesDeviceIdRoute, + } as any) const ApiV1PoliciesOwnerSendersSenderRoute = ApiV1PoliciesOwnerSendersSenderRouteImport.update({ id: '/senders/$sender', @@ -117,16 +169,24 @@ export interface FileRoutesByFullPath { '/api/v1/health': typeof ApiV1HealthRoute '/api/v1/openapi.json': typeof ApiV1OpenapiDotjsonRoute '/api/v1/protocol': typeof ApiV1ProtocolRoute + '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren + '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren '/api/v1/postage/quote': typeof ApiV1PostageQuoteRoute '/api/v1/receipts/$messageId': typeof ApiV1ReceiptsMessageIdRouteWithChildren + '/api/v1/sessions/$sessionId': typeof ApiV1SessionsSessionIdRouteWithChildren + '/api/v1/devices/': typeof ApiV1DevicesIndexRoute '/api/v1/postage/': typeof ApiV1PostageIndexRoute '/api/v1/receipts/': typeof ApiV1ReceiptsIndexRoute + '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute + '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute + '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute + '/api/v1/sessions/$sessionId/revoke': typeof ApiV1SessionsSessionIdRevokeRoute '/api/v1/policies/$owner/senders/$sender': typeof ApiV1PoliciesOwnerSendersSenderRoute } export interface FileRoutesByTo { @@ -135,16 +195,24 @@ export interface FileRoutesByTo { '/api/v1/health': typeof ApiV1HealthRoute '/api/v1/openapi.json': typeof ApiV1OpenapiDotjsonRoute '/api/v1/protocol': typeof ApiV1ProtocolRoute + '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren + '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren '/api/v1/postage/quote': typeof ApiV1PostageQuoteRoute '/api/v1/receipts/$messageId': typeof ApiV1ReceiptsMessageIdRouteWithChildren + '/api/v1/sessions/$sessionId': typeof ApiV1SessionsSessionIdRouteWithChildren + '/api/v1/devices': typeof ApiV1DevicesIndexRoute '/api/v1/postage': typeof ApiV1PostageIndexRoute '/api/v1/receipts': typeof ApiV1ReceiptsIndexRoute + '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute + '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute + '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute + '/api/v1/sessions/$sessionId/revoke': typeof ApiV1SessionsSessionIdRevokeRoute '/api/v1/policies/$owner/senders/$sender': typeof ApiV1PoliciesOwnerSendersSenderRoute } export interface FileRoutesById { @@ -154,16 +222,24 @@ export interface FileRoutesById { '/api/v1/health': typeof ApiV1HealthRoute '/api/v1/openapi.json': typeof ApiV1OpenapiDotjsonRoute '/api/v1/protocol': typeof ApiV1ProtocolRoute + '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren + '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren '/api/v1/postage/quote': typeof ApiV1PostageQuoteRoute '/api/v1/receipts/$messageId': typeof ApiV1ReceiptsMessageIdRouteWithChildren + '/api/v1/sessions/$sessionId': typeof ApiV1SessionsSessionIdRouteWithChildren + '/api/v1/devices/': typeof ApiV1DevicesIndexRoute '/api/v1/postage/': typeof ApiV1PostageIndexRoute '/api/v1/receipts/': typeof ApiV1ReceiptsIndexRoute + '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute + '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute + '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute + '/api/v1/sessions/$sessionId/revoke': typeof ApiV1SessionsSessionIdRevokeRoute '/api/v1/policies/$owner/senders/$sender': typeof ApiV1PoliciesOwnerSendersSenderRoute } export interface FileRouteTypes { @@ -174,16 +250,24 @@ export interface FileRouteTypes { | '/api/v1/health' | '/api/v1/openapi.json' | '/api/v1/protocol' + | '/api/v1/devices/$deviceId' + | '/api/v1/devices/recovery' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' | '/api/v1/postage/quote' | '/api/v1/receipts/$messageId' + | '/api/v1/sessions/$sessionId' + | '/api/v1/devices/' | '/api/v1/postage/' | '/api/v1/receipts/' + | '/api/v1/devices/$deviceId/compromised' + | '/api/v1/devices/$deviceId/name' + | '/api/v1/devices/$deviceId/revoke' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' + | '/api/v1/sessions/$sessionId/revoke' | '/api/v1/policies/$owner/senders/$sender' fileRoutesByTo: FileRoutesByTo to: @@ -192,16 +276,24 @@ export interface FileRouteTypes { | '/api/v1/health' | '/api/v1/openapi.json' | '/api/v1/protocol' + | '/api/v1/devices/$deviceId' + | '/api/v1/devices/recovery' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' | '/api/v1/postage/quote' | '/api/v1/receipts/$messageId' + | '/api/v1/sessions/$sessionId' + | '/api/v1/devices' | '/api/v1/postage' | '/api/v1/receipts' + | '/api/v1/devices/$deviceId/compromised' + | '/api/v1/devices/$deviceId/name' + | '/api/v1/devices/$deviceId/revoke' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' + | '/api/v1/sessions/$sessionId/revoke' | '/api/v1/policies/$owner/senders/$sender' id: | '__root__' @@ -210,16 +302,24 @@ export interface FileRouteTypes { | '/api/v1/health' | '/api/v1/openapi.json' | '/api/v1/protocol' + | '/api/v1/devices/$deviceId' + | '/api/v1/devices/recovery' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' | '/api/v1/postage/quote' | '/api/v1/receipts/$messageId' + | '/api/v1/sessions/$sessionId' + | '/api/v1/devices/' | '/api/v1/postage/' | '/api/v1/receipts/' + | '/api/v1/devices/$deviceId/compromised' + | '/api/v1/devices/$deviceId/name' + | '/api/v1/devices/$deviceId/revoke' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' + | '/api/v1/sessions/$sessionId/revoke' | '/api/v1/policies/$owner/senders/$sender' fileRoutesById: FileRoutesById } @@ -229,11 +329,15 @@ export interface RootRouteChildren { ApiV1HealthRoute: typeof ApiV1HealthRoute ApiV1OpenapiDotjsonRoute: typeof ApiV1OpenapiDotjsonRoute ApiV1ProtocolRoute: typeof ApiV1ProtocolRoute + ApiV1DevicesDeviceIdRoute: typeof ApiV1DevicesDeviceIdRouteWithChildren + ApiV1DevicesRecoveryRoute: typeof ApiV1DevicesRecoveryRoute ApiV1PoliciesOwnerRoute: typeof ApiV1PoliciesOwnerRouteWithChildren ApiV1PoliciesEvaluateRoute: typeof ApiV1PoliciesEvaluateRoute ApiV1PostageMessageIdRoute: typeof ApiV1PostageMessageIdRouteWithChildren ApiV1PostageQuoteRoute: typeof ApiV1PostageQuoteRoute ApiV1ReceiptsMessageIdRoute: typeof ApiV1ReceiptsMessageIdRouteWithChildren + ApiV1SessionsSessionIdRoute: typeof ApiV1SessionsSessionIdRouteWithChildren + ApiV1DevicesIndexRoute: typeof ApiV1DevicesIndexRoute ApiV1PostageIndexRoute: typeof ApiV1PostageIndexRoute ApiV1ReceiptsIndexRoute: typeof ApiV1ReceiptsIndexRoute } @@ -289,6 +393,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1PostageIndexRouteImport parentRoute: typeof rootRouteImport } + '/api/v1/devices/': { + id: '/api/v1/devices/' + path: '/api/v1/devices' + fullPath: '/api/v1/devices/' + preLoaderRoute: typeof ApiV1DevicesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/sessions/$sessionId': { + id: '/api/v1/sessions/$sessionId' + path: '/api/v1/sessions/$sessionId' + fullPath: '/api/v1/sessions/$sessionId' + preLoaderRoute: typeof ApiV1SessionsSessionIdRouteImport + parentRoute: typeof rootRouteImport + } '/api/v1/receipts/$messageId': { id: '/api/v1/receipts/$messageId' path: '/api/v1/receipts/$messageId' @@ -324,6 +442,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1PoliciesOwnerRouteImport parentRoute: typeof rootRouteImport } + '/api/v1/devices/recovery': { + id: '/api/v1/devices/recovery' + path: '/api/v1/devices/recovery' + fullPath: '/api/v1/devices/recovery' + preLoaderRoute: typeof ApiV1DevicesRecoveryRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/devices/$deviceId': { + id: '/api/v1/devices/$deviceId' + path: '/api/v1/devices/$deviceId' + fullPath: '/api/v1/devices/$deviceId' + preLoaderRoute: typeof ApiV1DevicesDeviceIdRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/sessions/$sessionId/revoke': { + id: '/api/v1/sessions/$sessionId/revoke' + path: '/revoke' + fullPath: '/api/v1/sessions/$sessionId/revoke' + preLoaderRoute: typeof ApiV1SessionsSessionIdRevokeRouteImport + parentRoute: typeof ApiV1SessionsSessionIdRoute + } '/api/v1/receipts/$messageId/read': { id: '/api/v1/receipts/$messageId/read' path: '/read' @@ -345,6 +484,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1PostageMessageIdRefundRouteImport parentRoute: typeof ApiV1PostageMessageIdRoute } + '/api/v1/devices/$deviceId/revoke': { + id: '/api/v1/devices/$deviceId/revoke' + path: '/revoke' + fullPath: '/api/v1/devices/$deviceId/revoke' + preLoaderRoute: typeof ApiV1DevicesDeviceIdRevokeRouteImport + parentRoute: typeof ApiV1DevicesDeviceIdRoute + } + '/api/v1/devices/$deviceId/name': { + id: '/api/v1/devices/$deviceId/name' + path: '/name' + fullPath: '/api/v1/devices/$deviceId/name' + preLoaderRoute: typeof ApiV1DevicesDeviceIdNameRouteImport + parentRoute: typeof ApiV1DevicesDeviceIdRoute + } + '/api/v1/devices/$deviceId/compromised': { + id: '/api/v1/devices/$deviceId/compromised' + path: '/compromised' + fullPath: '/api/v1/devices/$deviceId/compromised' + preLoaderRoute: typeof ApiV1DevicesDeviceIdCompromisedRouteImport + parentRoute: typeof ApiV1DevicesDeviceIdRoute + } '/api/v1/policies/$owner/senders/$sender': { id: '/api/v1/policies/$owner/senders/$sender' path: '/senders/$sender' @@ -355,6 +515,21 @@ declare module '@tanstack/react-router' { } } +interface ApiV1DevicesDeviceIdRouteChildren { + ApiV1DevicesDeviceIdCompromisedRoute: typeof ApiV1DevicesDeviceIdCompromisedRoute + ApiV1DevicesDeviceIdNameRoute: typeof ApiV1DevicesDeviceIdNameRoute + ApiV1DevicesDeviceIdRevokeRoute: typeof ApiV1DevicesDeviceIdRevokeRoute +} + +const ApiV1DevicesDeviceIdRouteChildren: ApiV1DevicesDeviceIdRouteChildren = { + ApiV1DevicesDeviceIdCompromisedRoute: ApiV1DevicesDeviceIdCompromisedRoute, + ApiV1DevicesDeviceIdNameRoute: ApiV1DevicesDeviceIdNameRoute, + ApiV1DevicesDeviceIdRevokeRoute: ApiV1DevicesDeviceIdRevokeRoute, +} + +const ApiV1DevicesDeviceIdRouteWithChildren = + ApiV1DevicesDeviceIdRoute._addFileChildren(ApiV1DevicesDeviceIdRouteChildren) + interface ApiV1PoliciesOwnerRouteChildren { ApiV1PoliciesOwnerSendersSenderRoute: typeof ApiV1PoliciesOwnerSendersSenderRoute } @@ -395,17 +570,35 @@ const ApiV1ReceiptsMessageIdRouteWithChildren = ApiV1ReceiptsMessageIdRouteChildren, ) +interface ApiV1SessionsSessionIdRouteChildren { + ApiV1SessionsSessionIdRevokeRoute: typeof ApiV1SessionsSessionIdRevokeRoute +} + +const ApiV1SessionsSessionIdRouteChildren: ApiV1SessionsSessionIdRouteChildren = + { + ApiV1SessionsSessionIdRevokeRoute: ApiV1SessionsSessionIdRevokeRoute, + } + +const ApiV1SessionsSessionIdRouteWithChildren = + ApiV1SessionsSessionIdRoute._addFileChildren( + ApiV1SessionsSessionIdRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, MotionGalleryRoute: MotionGalleryRoute, ApiV1HealthRoute: ApiV1HealthRoute, ApiV1OpenapiDotjsonRoute: ApiV1OpenapiDotjsonRoute, ApiV1ProtocolRoute: ApiV1ProtocolRoute, + ApiV1DevicesDeviceIdRoute: ApiV1DevicesDeviceIdRouteWithChildren, + ApiV1DevicesRecoveryRoute: ApiV1DevicesRecoveryRoute, ApiV1PoliciesOwnerRoute: ApiV1PoliciesOwnerRouteWithChildren, ApiV1PoliciesEvaluateRoute: ApiV1PoliciesEvaluateRoute, ApiV1PostageMessageIdRoute: ApiV1PostageMessageIdRouteWithChildren, ApiV1PostageQuoteRoute: ApiV1PostageQuoteRoute, ApiV1ReceiptsMessageIdRoute: ApiV1ReceiptsMessageIdRouteWithChildren, + ApiV1SessionsSessionIdRoute: ApiV1SessionsSessionIdRouteWithChildren, + ApiV1DevicesIndexRoute: ApiV1DevicesIndexRoute, ApiV1PostageIndexRoute: ApiV1PostageIndexRoute, ApiV1ReceiptsIndexRoute: ApiV1ReceiptsIndexRoute, } diff --git a/src/routes/api/v1/devices/$deviceId.ts b/src/routes/api/v1/devices/$deviceId.ts new file mode 100644 index 00000000..cc103445 --- /dev/null +++ b/src/routes/api/v1/devices/$deviceId.ts @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; +import { ApiError } from "@/server/api/errors"; + +export const Route = createFileRoute("/api/v1/devices/$deviceId")({ + server: { + handlers: { + GET: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const device = await getApiContext().repository.getDevice(params.deviceId); + if (!device || device.address !== address) { + throw new ApiError(404, "not_found", "Device not found"); + } + return apiSuccess(request, { device }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/$deviceId/compromised.ts b/src/routes/api/v1/devices/$deviceId/compromised.ts new file mode 100644 index 00000000..4f0f3462 --- /dev/null +++ b/src/routes/api/v1/devices/$deviceId/compromised.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { flagDeviceCompromised } from "@/server/api/device-service"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/$deviceId/compromised")({ + server: { + handlers: { + POST: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + await flagDeviceCompromised(getApiContext().repository, params.deviceId, address); + return apiSuccess(request, { success: true }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/$deviceId/name.ts b/src/routes/api/v1/devices/$deviceId/name.ts new file mode 100644 index 00000000..53633f0d --- /dev/null +++ b/src/routes/api/v1/devices/$deviceId/name.ts @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { renameDevice } from "@/server/api/device-service"; +import { parseJsonBody } from "@/server/api/request"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +const nameSchema = z.object({ + name: z.string().min(1).max(64), +}); + +export const Route = createFileRoute("/api/v1/devices/$deviceId/name")({ + server: { + handlers: { + PUT: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const { name } = await parseJsonBody(request, nameSchema); + const device = await renameDevice( + getApiContext().repository, + params.deviceId, + address, + name, + ); + return apiSuccess(request, { device }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/$deviceId/revoke.ts b/src/routes/api/v1/devices/$deviceId/revoke.ts new file mode 100644 index 00000000..018106e9 --- /dev/null +++ b/src/routes/api/v1/devices/$deviceId/revoke.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { revokeDevice } from "@/server/api/device-service"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/$deviceId/revoke")({ + server: { + handlers: { + POST: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + await revokeDevice(getApiContext().repository, params.deviceId, address); + return apiSuccess(request, { success: true }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/index.ts b/src/routes/api/v1/devices/index.ts new file mode 100644 index 00000000..bbff4ccd --- /dev/null +++ b/src/routes/api/v1/devices/index.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { getDevicesWithSessions } from "@/server/api/device-service"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/")({ + server: { + handlers: { + GET: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const fingerprint = request.headers.get("x-device-fingerprint") ?? undefined; + const devices = await getDevicesWithSessions( + getApiContext().repository, + address, + fingerprint, + ); + return apiSuccess(request, { devices }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/recovery.ts b/src/routes/api/v1/devices/recovery.ts new file mode 100644 index 00000000..72305083 --- /dev/null +++ b/src/routes/api/v1/devices/recovery.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { getRecoveryStatus } from "@/server/api/device-service"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/recovery")({ + server: { + handlers: { + GET: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const status = await getRecoveryStatus(getApiContext().repository, address); + return apiSuccess(request, status); + }), + }, + }, +}); diff --git a/src/routes/api/v1/sessions/$sessionId.ts b/src/routes/api/v1/sessions/$sessionId.ts new file mode 100644 index 00000000..dfde2305 --- /dev/null +++ b/src/routes/api/v1/sessions/$sessionId.ts @@ -0,0 +1,3 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/v1/sessions/$sessionId")({}); diff --git a/src/routes/api/v1/sessions/$sessionId/revoke.ts b/src/routes/api/v1/sessions/$sessionId/revoke.ts new file mode 100644 index 00000000..7eede2f0 --- /dev/null +++ b/src/routes/api/v1/sessions/$sessionId/revoke.ts @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; +import { ApiError } from "@/server/api/errors"; + +export const Route = createFileRoute("/api/v1/sessions/$sessionId/revoke")({ + server: { + handlers: { + POST: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const repository = getApiContext().repository; + const session = await repository.getSession(params.sessionId); + if (!session) { + throw new ApiError(404, "not_found", "Session not found"); + } + if (session.address !== address) { + throw new ApiError(403, "forbidden", "Cannot revoke another user's session"); + } + const revoked = await repository.revokeSession(params.sessionId); + return apiSuccess(request, { session: revoked }); + }), + }, + }, +}); diff --git a/src/server/api/device-service.ts b/src/server/api/device-service.ts new file mode 100644 index 00000000..cd3e97be --- /dev/null +++ b/src/server/api/device-service.ts @@ -0,0 +1,176 @@ +import { buildDeviceFingerprint } from "./abuse-service"; +import type { Device, DeviceCreate, DeviceUpdate, Session } from "./domain"; +import type { ApiRepository } from "./repository"; + +export type DeviceWithSessions = Device & { sessions: Session[] }; + +function inferDeviceType(userAgent: string): Device["type"] { + const ua = userAgent.toLowerCase(); + if (/mobile|android|iphone|ipad|ipod/i.test(ua)) return "mobile"; + if (/tablet|ipad/i.test(ua)) return "tablet"; + return "desktop"; +} + +function inferLocation(ip: string): string { + if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return "Local network"; + return "Unknown location"; +} + +export async function getDevicesWithSessions( + repository: ApiRepository, + address: string, + currentFingerprint?: string, +): Promise { + const devices = await repository.listDevices(address); + const sessions = await repository.listSessions(address); + + return devices.map((device) => { + let isCurrent = false; + if (currentFingerprint && device.fingerprint === currentFingerprint) { + isCurrent = true; + } + const deviceSessions = sessions + .filter((s) => s.deviceId === device.id) + .map((s) => ({ ...s, isCurrent: s.isCurrent && isCurrent })); + + const lastActive = deviceSessions.length > 0 + ? deviceSessions.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime())[0] + .lastActiveAt + : device.lastActive; + + return { + ...device, + isCurrent, + lastActive, + sessions: deviceSessions, + }; + }); +} + +export async function registerDevice( + repository: ApiRepository, + address: string, + headers: { userAgent?: string; acceptLanguage?: string; acceptEncoding?: string; ip?: string }, + publicKey: string, +): Promise { + const fingerprint = buildDeviceFingerprint({ + userAgent: headers.userAgent, + acceptLanguage: headers.acceptLanguage, + acceptEncoding: headers.acceptEncoding, + ipPrefix: headers.ip?.split(".").slice(0, 2).join("."), + }); + + const existing = await repository.getDeviceByFingerprint(address, fingerprint); + if (existing) { + return existing; + } + + const deviceType = inferDeviceType(headers.userAgent ?? ""); + const location = inferLocation(headers.ip ?? ""); + + const device = await repository.createDevice(address, { + name: `${deviceType.charAt(0).toUpperCase() + deviceType.slice(1)} ${new Date().toLocaleDateString()}`, + type: deviceType, + fingerprint, + publicKey, + lastIp: headers.ip ?? "unknown", + lastLocation: location, + }); + + await repository.createSession({ + id: crypto.randomUUID(), + deviceId: device.id, + address, + startedAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + ip: headers.ip ?? "unknown", + location, + isCurrent: true, + revokedAt: null, + }); + + return device; +} + +export async function renameDevice( + repository: ApiRepository, + deviceId: string, + address: string, + name: string, +): Promise { + const device = await repository.getDevice(deviceId); + if (!device) throw new Error("device_not_found"); + if (device.address !== address) throw new Error("forbidden"); + return repository.updateDevice(deviceId, { name }); +} + +export async function revokeDevice( + repository: ApiRepository, + deviceId: string, + address: string, +): Promise { + const device = await repository.getDevice(deviceId); + if (!device) throw new Error("device_not_found"); + if (device.address !== address) throw new Error("forbidden"); + + await repository.updateDeviceKeyStatus(deviceId, "revoked"); + await repository.revokeAllSessionsForDevice(deviceId); +} + +export async function flagDeviceCompromised( + repository: ApiRepository, + deviceId: string, + address: string, +): Promise { + const device = await repository.getDevice(deviceId); + if (!device) throw new Error("device_not_found"); + if (device.address !== address) throw new Error("forbidden"); + + await repository.updateDeviceKeyStatus(deviceId, "compromised"); + await repository.revokeAllSessionsForDevice(deviceId); +} + +export async function checkSuspiciousLogin( + repository: ApiRepository, + address: string, + fingerprint: string, + currentIp: string, +): Promise<{ suspicious: boolean; reason?: string }> { + const knownDevices = await repository.listDevices(address); + + if (knownDevices.length === 0) { + return { suspicious: false }; + } + + const matchingDevice = knownDevices.find((d) => d.fingerprint === fingerprint); + if (!matchingDevice) { + return { suspicious: true, reason: "unrecognized_device" }; + } + + if (matchingDevice.keyStatus === "revoked" || matchingDevice.keyStatus === "compromised") { + return { suspicious: true, reason: "device_compromised" }; + } + + return { suspicious: false }; +} + +export async function getRecoveryStatus( + repository: ApiRepository, + address: string, +): Promise<{ + enabled: boolean; + lastUpdated: string | null; + devicesCount: number; + trustedCount: number; +}> { + const devices = await repository.listDevices(address); + return { + enabled: devices.length > 0, + lastUpdated: devices.length > 0 + ? devices.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + .createdAt + : null, + devicesCount: devices.length, + trustedCount: devices.filter((d) => d.trusted).length, + }; +} diff --git a/src/server/api/domain.ts b/src/server/api/domain.ts index 8d4dcfdf..6c68f96a 100644 --- a/src/server/api/domain.ts +++ b/src/server/api/domain.ts @@ -50,8 +50,63 @@ export const receiptSchema = z.object({ sender: stellarAddressSchema, }); +export const deviceTypeSchema = z.enum(["desktop", "mobile", "tablet", "unknown"]); +export const keyStatusSchema = z.enum(["active", "compromised", "revoked", "rotated"]); + +export const deviceSchema = z.object({ + id: z.string(), + address: stellarAddressSchema, + name: z.string(), + type: deviceTypeSchema, + fingerprint: z.string(), + publicKey: z.string(), + keyStatus: keyStatusSchema, + trusted: z.boolean(), + lastActive: z.string().datetime(), + lastIp: z.string(), + lastLocation: z.string(), + createdAt: z.string().datetime(), + isCurrent: z.boolean(), +}); + +export const sessionSchema = z.object({ + id: z.string(), + deviceId: z.string(), + address: stellarAddressSchema, + startedAt: z.string().datetime(), + lastActiveAt: z.string().datetime(), + ip: z.string(), + location: z.string(), + isCurrent: z.boolean(), + revokedAt: z.string().datetime().nullable(), +}); + +export const deviceCreateSchema = z.object({ + name: z.string().min(1).max(64), + type: deviceTypeSchema, + fingerprint: z.string(), + publicKey: z.string(), + lastIp: z.string(), + lastLocation: z.string(), +}); + +export const deviceUpdateSchema = z.object({ + name: z.string().min(1).max(64).optional(), + trusted: z.boolean().optional(), +}); + +export const sessionRevokeSchema = z.object({ + deviceId: z.string(), +}); + export type MailboxPolicy = z.infer; export type Postage = z.infer; export type PostageStatus = z.infer; export type Receipt = z.infer; export type SenderRule = z.infer; +export type Device = z.infer; +export type DeviceType = z.infer; +export type KeyStatus = z.infer; +export type Session = z.infer; +export type DeviceCreate = z.infer; +export type DeviceUpdate = z.infer; diff --git a/src/server/api/memory-repository.ts b/src/server/api/memory-repository.ts index 3ed3fa18..5c3dc324 100644 --- a/src/server/api/memory-repository.ts +++ b/src/server/api/memory-repository.ts @@ -1,4 +1,4 @@ -import type { MailboxPolicy, Postage, Receipt, SenderRule } from "./domain"; +import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, SenderRule, Session } from "./domain"; import type { ApiRepository } from "./repository"; function key(owner: string, sender: string) { @@ -11,6 +11,8 @@ export class MemoryApiRepository implements ApiRepository { private readonly receipts = new Map(); private readonly senderRules = new Map(); private readonly counters = new Map(); + private readonly devices = new Map(); + private readonly sessions = new Map(); async getPolicy(owner: string) { return structuredClone(this.policies.get(owner) ?? null); @@ -73,6 +75,96 @@ export class MemoryApiRepository implements ApiRepository { return this.counters.get(key)?.length ?? 0; } + async listDevices(address: string) { + return Array.from(this.devices.values()) + .filter((d) => d.address === address) + .sort((a, b) => new Date(b.lastActive).getTime() - new Date(a.lastActive).getTime()); + } + + async getDevice(deviceId: string) { + return structuredClone(this.devices.get(deviceId) ?? null); + } + + async createDevice(address: string, data: DeviceCreate) { + const device: Device = { + id: crypto.randomUUID(), + address, + name: data.name, + type: data.type, + fingerprint: data.fingerprint, + publicKey: data.publicKey, + keyStatus: "active", + trusted: false, + lastActive: new Date().toISOString(), + lastIp: data.lastIp, + lastLocation: data.lastLocation, + createdAt: new Date().toISOString(), + isCurrent: false, + }; + this.devices.set(device.id, device); + return structuredClone(device); + } + + async updateDevice(deviceId: string, data: DeviceUpdate) { + const device = this.devices.get(deviceId); + if (!device) throw new Error("device_not_found"); + const updated: Device = { + ...device, + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.trusted !== undefined ? { trusted: data.trusted } : {}), + }; + this.devices.set(deviceId, updated); + return structuredClone(updated); + } + + async updateDeviceKeyStatus(deviceId: string, keyStatus: Device["keyStatus"]) { + const device = this.devices.get(deviceId); + if (!device) throw new Error("device_not_found"); + device.keyStatus = keyStatus; + return structuredClone(device); + } + + async deleteDevice(deviceId: string) { + this.devices.delete(deviceId); + } + + async listSessions(address: string) { + return Array.from(this.sessions.values()) + .filter((s) => s.address === address) + .sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime()); + } + + async getSession(sessionId: string) { + return structuredClone(this.sessions.get(sessionId) ?? null); + } + + async createSession(session: Session) { + this.sessions.set(session.id, session); + return structuredClone(session); + } + + async revokeSession(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session) throw new Error("session_not_found"); + session.revokedAt = new Date().toISOString(); + return structuredClone(session); + } + + async revokeAllSessionsForDevice(deviceId: string) { + for (const [id, session] of this.sessions) { + if (session.deviceId === deviceId && !session.revokedAt) { + session.revokedAt = new Date().toISOString(); + } + } + } + + async getDeviceByFingerprint(address: string, fingerprint: string) { + const device = Array.from(this.devices.values()).find( + (d) => d.address === address && d.fingerprint === fingerprint, + ); + return structuredClone(device ?? null); + } + async incrementCounter(key: string, windowSeconds: number) { const now = Date.now(); const windowMilliseconds = windowSeconds * 1000; @@ -90,5 +182,7 @@ export class MemoryApiRepository implements ApiRepository { this.receipts.clear(); this.senderRules.clear(); this.counters.clear(); + this.devices.clear(); + this.sessions.clear(); } } diff --git a/src/server/api/repository.ts b/src/server/api/repository.ts index 24c52cb0..e7843515 100644 --- a/src/server/api/repository.ts +++ b/src/server/api/repository.ts @@ -1,4 +1,4 @@ -import type { MailboxPolicy, Postage, Receipt, SenderRule } from "./domain"; +import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, SenderRule, Session } from "./domain"; export interface ApiRepository { getPolicy(owner: string): Promise; @@ -17,6 +17,21 @@ export interface ApiRepository { getRelayDeadLetterCount(relayId: string): Promise; getCounter(key: string): Promise; incrementCounter(key: string, windowSeconds: number): Promise; + + listDevices(address: string): Promise; + getDevice(deviceId: string): Promise; + createDevice(address: string, data: DeviceCreate): Promise; + updateDevice(deviceId: string, data: DeviceUpdate): Promise; + updateDeviceKeyStatus(deviceId: string, keyStatus: Device["keyStatus"]): Promise; + deleteDevice(deviceId: string): Promise; + + listSessions(address: string): Promise; + getSession(sessionId: string): Promise; + createSession(session: Session): Promise; + revokeSession(sessionId: string): Promise; + revokeAllSessionsForDevice(deviceId: string): Promise; + + getDeviceByFingerprint(address: string, fingerprint: string): Promise; } export const defaultMailboxPolicy: MailboxPolicy = { From 6e23069f279dcd5bf7d836e86e03a6e2ddd0778b Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 20:02:36 +0100 Subject: [PATCH 03/12] feat(security): comprehensive device management with recovery methods, key rotation, trust toggling, and API integration Backend improvements: - Add RecoveryMethod domain model, repository, service, and API routes (CRUD) - Add device registration endpoint (POST /devices/register) - Add key rotation endpoint (POST /devices/rotate-keys) - Add trust toggling endpoint (POST /devices/:id/trust) - Expand device service with: toggleDeviceTrust, rotateDeviceKeys, createRecoveryMethod, deleteRecoveryMethod - Improve registerDevice to reject revoked/compromised device re-registration - Add OpenAPI documentation for all 11 new device/session endpoints - Add 19 comprehensive unit tests covering all device service functions Frontend overhaul: - Wire up useDevices hook to real API calls via apiFetch helper - Add error banner, toast notifications (sonner), and dismissable errors - Add security alert banner with pulsing icon for compromised devices - Add warning banner for revoked devices - Add recovery methods management UI (add with type-selector form, list with icons, remove with confirmation) - Add inline trust toggling button for each device (visual feedback) - Add key rotation with confirmation dialog and loading state - Add device registration prompt when no devices exist - Add spinner and disabled state during confirmation actions - Show active key count, device status info on revoked/compromised cards - Update RecoveryStatus type to include recoveryMethods array --- src/components/mail/SettingsModal.tsx | 707 +++++++++++++----- src/features/device-management/types.ts | 12 + src/features/device-management/useDevices.ts | 365 +++++---- src/routeTree.gen.ts | 122 +++ src/routes/api/v1/devices/$deviceId/trust.ts | 31 + src/routes/api/v1/devices/recovery-methods.ts | 32 + .../v1/devices/recovery-methods/$methodId.ts | 23 + src/routes/api/v1/devices/register.ts | 42 ++ src/routes/api/v1/devices/rotate-keys.ts | 32 + src/server/api/device-service.ts | 78 +- src/server/api/domain.ts | 27 + src/server/api/memory-repository.ts | 40 +- src/server/api/openapi.ts | 69 ++ src/server/api/repository.ts | 8 +- tests/unit/device-service.test.ts | 308 ++++++++ 15 files changed, 1558 insertions(+), 338 deletions(-) create mode 100644 src/routes/api/v1/devices/$deviceId/trust.ts create mode 100644 src/routes/api/v1/devices/recovery-methods.ts create mode 100644 src/routes/api/v1/devices/recovery-methods/$methodId.ts create mode 100644 src/routes/api/v1/devices/register.ts create mode 100644 src/routes/api/v1/devices/rotate-keys.ts create mode 100644 tests/unit/device-service.test.ts diff --git a/src/components/mail/SettingsModal.tsx b/src/components/mail/SettingsModal.tsx index c0dad112..d5b11023 100644 --- a/src/components/mail/SettingsModal.tsx +++ b/src/components/mail/SettingsModal.tsx @@ -15,7 +15,6 @@ import { RefreshCw, ShieldCheck, Smartphone, - Table, Trash2, User, X, @@ -24,10 +23,14 @@ import { RotateCw, Info, LogOut, - Eye, - EyeOff, + Plus, + Fingerprint, + Contact, + HardDrive, + Download, } from "lucide-react"; -import { useState, useEffect, type CSSProperties } from "react"; +import { useState, useEffect, useCallback, type CSSProperties } from "react"; +import { toast } from "sonner"; import { Surface } from "@/features/design-system"; import { cn } from "@/lib/utils"; import { useFocusTrap } from "@/hooks/use-focus-trap"; @@ -45,7 +48,7 @@ import { } from "@/features/settings/mailbox-policy-templates"; import { AuditLog } from "@/features/audit-log"; import { useDevices } from "@/features/device-management/useDevices"; -import type { Device, KeyStatus } from "@/features/device-management/types"; +import type { Device, KeyStatus, RecoveryMethod } from "@/features/device-management/types"; const tabs = [ { id: "account", label: "Account", icon: User }, @@ -1078,78 +1081,207 @@ type ConfirmDialogState = { onConfirm: () => void; }; +function RecoveryMethodIcon({ type }: { type: RecoveryMethod["type"] }) { + switch (type) { + case "trusted_contact": return ; + case "hardware_key": return ; + case "paper_key": return ; + case "encrypted_backup": return ; + } +} + +function RecoveryMethodLabel({ type }: { type: RecoveryMethod["type"] }) { + switch (type) { + case "trusted_contact": return "Trusted contact"; + case "hardware_key": return "Hardware key"; + case "paper_key": return "Paper key"; + case "encrypted_backup": return "Encrypted backup"; + } +} + function SecuritySettings() { - const { devices, loading, recoveryStatus, renameDevice, revokeDevice, flagCompromised } = - useDevices(); + const { + devices, loading, error, recoveryStatus, dismissError, + renameDevice, toggleTrust, revokeDevice, flagCompromised, + rotateKeys, addRecoveryMethod, removeRecoveryMethod, registerDevice, + } = useDevices(); + const [confirmDialog, setConfirmDialog] = useState(null); + const [confirming, setConfirming] = useState(false); const [copiedKey, setCopiedKey] = useState(false); const [editingDevice, setEditingDevice] = useState(null); const [deviceName, setDeviceName] = useState(""); const [showRevocationInfo, setShowRevocationInfo] = useState(false); - const handleCopyKey = () => { - navigator.clipboard.writeText("GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"); - setCopiedKey(true); - setTimeout(() => setCopiedKey(false), 2000); - }; + const [showAddRecovery, setShowAddRecovery] = useState(false); + const [recoveryType, setRecoveryType] = useState("trusted_contact"); + const [recoveryLabel, setRecoveryLabel] = useState(""); + const [recoveryValue, setRecoveryValue] = useState(""); + + const handleCopyKey = useCallback(() => { + const key = devices.find((d) => d.isCurrent)?.publicKey + ?? "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"; + navigator.clipboard.writeText(key).then(() => { + setCopiedKey(true); + toast.success("Public key copied"); + setTimeout(() => setCopiedKey(false), 2000); + }); + }, [devices]); + + const withConfirm = useCallback( + (action: () => Promise, successMsg: string) => { + setConfirming(true); + action() + .then(() => { + toast.success(successMsg); + setConfirmDialog(null); + }) + .catch((err: Error) => { + toast.error(err.message ?? "Action failed"); + }) + .finally(() => setConfirming(false)); + }, + [], + ); - const pendingDeviceName = deviceName; + const handleSaveDeviceName = useCallback( + async (deviceId: string) => { + if (deviceName.trim()) { + try { + await renameDevice(deviceId, deviceName.trim()); + toast.success("Device renamed"); + setEditingDevice(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to rename"); + } + } + }, + [deviceName, renameDevice], + ); - const handleSaveDeviceName = async (deviceId: string) => { - if (pendingDeviceName.trim()) { - await renameDevice(deviceId, pendingDeviceName.trim()); - } - setEditingDevice(null); - }; + const handleRevokeDevice = useCallback( + (device: Device) => { + setConfirmDialog({ + title: `Revoke "${device.name}"?`, + description: device.keyStatus === "compromised" + ? "This device is flagged as compromised. All encryption keys will be permanently invalidated. The device will lose all access immediately. This cannot be undone." + : "All sessions will be terminated and the device will lose access immediately. The device will need to re-authenticate to regain access. This cannot be undone.", + type: "danger", + onConfirm: () => withConfirm( + () => revokeDevice(device.id), + `"${device.name}" revoked`, + ), + }); + }, + [revokeDevice, withConfirm], + ); - const handleRevokeDevice = (device: Device) => { - setConfirmDialog({ - title: `Revoke "${device.name}"?`, - description: `This device will lose access to your account immediately. ${ - device.keyStatus === "compromised" - ? "All encryption keys associated with this device will be invalidated." - : "Sessions will be terminated and the device will need to re-authenticate." - } This action cannot be undone.`, - type: "danger", - onConfirm: async () => { - await revokeDevice(device.id); - setConfirmDialog(null); - }, - }); - }; + const handleFlagCompromised = useCallback( + (device: Device) => { + setConfirmDialog({ + title: `Flag "${device.name}" as compromised?`, + description: + "All sessions will be immediately revoked and encryption keys invalidated. Future messages will not be decryptable by this device. We strongly recommend rotating all account keys after this action. This cannot be undone.", + type: "danger", + onConfirm: () => withConfirm( + () => flagCompromised(device.id), + `"${device.name}" flagged as compromised`, + ), + }); + }, + [flagCompromised, withConfirm], + ); - const handleFlagCompromised = (device: Device) => { - setConfirmDialog({ - title: `Flag "${device.name}" as compromised?`, - description: - "All sessions for this device will be immediately revoked. Any encryption keys tied to this device will be invalidated, preventing decryption of future messages. We recommend rotating your account keys after this action. This cannot be undone.", - type: "danger", - onConfirm: async () => { - await flagCompromised(device.id); - setConfirmDialog(null); - }, - }); - }; + const handleToggleTrust = useCallback( + (device: Device) => { + toggleTrust(device.id, !device.trusted) + .then(() => toast.success(device.trusted ? "Trust removed" : "Device trusted")) + .catch((err) => toast.error(err instanceof Error ? err.message : "Failed to update trust")); + }, + [toggleTrust], + ); - const handleRotateKeys = () => { + const handleRotateKeys = useCallback(() => { + const activeDevices = devices.filter( + (d) => d.keyStatus === "active" && d.isCurrent, + ); + if (activeDevices.length === 0) { + toast.error("No active devices to rotate keys for"); + return; + } setConfirmDialog({ title: "Rotate encryption keys?", description: - "This will generate a new key pair for all trusted devices. Existing encrypted messages will remain accessible with your old keys until they expire. You'll need to update your recovery information after rotation.", + "This generates a new key pair for this device. Old keys are marked as rotated. " + + "Existing encrypted messages remain accessible with old keys until they expire. " + + "You will need to update recovery information after rotation. This action is logged in your audit history.", type: "warning", - onConfirm: () => { - setConfirmDialog(null); - }, + onConfirm: () => withConfirm( + async () => { + const newKey = `GD${Array.from({ length: 54 }, () => + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], + ).join("")}`; + await rotateKeys( + activeDevices.map((d) => d.id), + newKey, + ); + }, + "Keys rotated successfully", + ), }); - }; + }, [devices, rotateKeys, withConfirm]); + + const handleRegisterDevice = useCallback(() => { + withConfirm( + async () => { + const newKey = `GD${Array.from({ length: 54 }, () => + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], + ).join("")}`; + await registerDevice(newKey); + }, + "Device registered", + ); + }, [registerDevice, withConfirm]); + + const handleAddRecoveryMethod = useCallback(async () => { + if (!recoveryLabel.trim() || !recoveryValue.trim()) { + toast.error("Label and value are required"); + return; + } + try { + await addRecoveryMethod(recoveryType, recoveryLabel.trim(), recoveryValue.trim()); + toast.success("Recovery method added"); + setShowAddRecovery(false); + setRecoveryLabel(""); + setRecoveryValue(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add recovery method"); + } + }, [addRecoveryMethod, recoveryType, recoveryLabel, recoveryValue]); + + const handleRemoveRecoveryMethod = useCallback( + (method: RecoveryMethod) => { + setConfirmDialog({ + title: `Remove "${method.label}"?`, + description: "You will lose this recovery method. Ensure you have at least one other recovery method configured before removing this one.", + type: "warning", + onConfirm: () => withConfirm( + () => removeRecoveryMethod(method.id), + "Recovery method removed", + ), + }); + }, + [removeRecoveryMethod, withConfirm], + ); const currentDevice = devices.find((d) => d.isCurrent); const activeSessions = devices.flatMap((d) => - d.sessions.filter((s) => !s.revokedAt && d.keyStatus !== "revoked" && d.keyStatus !== "compromised"), - ); - const compromisedDevices = devices.filter( - (d) => d.keyStatus === "compromised", + d.sessions.filter( + (s) => !s.revokedAt && d.keyStatus !== "revoked" && d.keyStatus !== "compromised", + ), ); + const compromisedDevices = devices.filter((d) => d.keyStatus === "compromised"); + const hasRevokedDevices = devices.some((d) => d.keyStatus === "revoked"); if (loading) { return ( @@ -1176,19 +1308,51 @@ function SecuritySettings() {

    - {/* Suspicious login warning */} + {/* Error banner */} + {error && ( +
    +
    + + {error} +
    + +
    + )} + + {/* Security alert for compromised devices */} {compromisedDevices.length > 0 && ( -
    +
    - +

    Security alert

    {compromisedDevices.length === 1 - ? `1 device has been flagged as compromised: ${compromisedDevices[0].name}.` - : `${compromisedDevices.length} devices have been flagged as compromised.`}{" "} - Access has been revoked and encryption keys invalidated. Consider rotating your account - keys below. + ? `${compromisedDevices[0].name} has been flagged as compromised.` + : `${compromisedDevices.length} devices have been flagged as compromised.`} + {" "}Access revoked and encryption keys invalidated.{" "} + {" "} + to secure your account. +

    +
    + )} + + {/* Warning for revoked devices */} + {hasRevokedDevices && compromisedDevices.length === 0 && ( +
    + +

    + Some devices have been revoked. Review your devices below.

    )} @@ -1196,100 +1360,123 @@ function SecuritySettings() { {/* Current device banner */} {currentDevice && (
    -
    - -
    -

    Current device

    -

    - {currentDevice.name} • {currentDevice.lastLocation} +

    +
    + +
    +
    +
    +

    {currentDevice.name}

    + + This device + +
    +

    + {currentDevice.lastLocation} • Last active {formatRelativeTime(currentDevice.lastActive)}

    - - This device -
    )} - {/* Active Sessions */} -
    -
    + {/* No devices - registration prompt */} + {devices.length === 0 && ( +
    +
    -

    Active sessions

    -

    - Sessions currently signed in to your account +

    No devices registered

    +

    + Register this device to enable encrypted message delivery and manage access.

    - {activeSessions.length > 0 && ( - - {activeSessions.length} active - - )} +
    -
    - {activeSessions.length === 0 ? ( -
    -

    No active sessions

    + )} + + {/* Active Sessions */} + {devices.length > 0 && ( +
    +
    +
    +

    Active sessions

    +

    + Sessions currently signed in to your account +

    - ) : ( - activeSessions.map((session) => { - const device = devices.find((d) => d.id === session.deviceId); - return ( -
    -
    - -
    -
    -

    {device?.name ?? "Unknown device"}

    - {session.isCurrent && ( - - Current - - )} + {activeSessions.length > 0 && ( + + {activeSessions.length} active + + )} +
    +
    + {activeSessions.length === 0 ? ( +
    +

    No active sessions

    +
    + ) : ( + activeSessions.map((session) => { + const device = devices.find((d) => d.id === session.deviceId); + return ( +
    +
    + +
    +
    +

    + {device?.name ?? "Unknown device"} +

    + {session.isCurrent && ( + + Current + + )} +
    +

    + {session.location} • {formatRelativeTime(session.lastActiveAt)} +

    -

    - {session.location} • {formatRelativeTime(session.lastActiveAt)} -

    + {!session.isCurrent && ( + + )}
    - {!session.isCurrent && ( - - )} -
    - ); - }) - )} -
    -
    - - {/* Devices */} -
    -
    -
    -

    Devices

    -

    - Trusted devices with access to your account -

    + ); + }) + )}
    -
    - {devices.length === 0 ? ( -
    -

    No devices registered

    + )} + + {/* Devices list */} + {devices.length > 0 && ( +
    +
    +
    +

    Devices

    +

    + Registered devices and their encryption key status +

    - ) : ( - devices.map((device) => { +
    +
    + {devices.map((device) => { const isCurrent = device.isCurrent; const isDisabled = device.keyStatus === "revoked" || device.keyStatus === "compromised"; @@ -1344,8 +1531,7 @@ function SecuritySettings() { {formatRelativeTime(device.lastActive)} {device.trusted && !isDisabled && ( <> - {" "} - •{" "} + {" "}•{" "} Trusted )} @@ -1365,6 +1551,18 @@ function SecuritySettings() { > +
    ); - }) - )} + })} +
    -
    + )} {/* Revocation consequences info */}
    @@ -1418,37 +1631,41 @@ function SecuritySettings() {

    What happens when you revoke a device?

    • -
      +
      All active sessions on that device are immediately terminated
    • -
      - The device's encryption key is invalidated — it can no longer decrypt new messages +
      + The device's encryption key is invalidated — it can no longer decrypt new messages
    • -
      +
      Existing encrypted messages already on the device remain accessible locally
    • -
      +
      The device must re-authenticate and re-register to regain access
    • -
      - If the device is compromised, flagging it also triggers an alert and recommends key rotation +
      + If the device is compromised, flagging it triggers a security alert and invalidates all associated keys +
    • +
    • +
      + All revocation and compromise events are recorded in the audit history
    )}
    - {/* Account Recovery */} + {/* Recovery methods */}

    Account recovery

    - Backup access to your account if you lose your keys + Methods to restore access if all devices are lost

    @@ -1462,7 +1679,7 @@ function SecuritySettings() { )} />

    - {recoveryStatus.enabled ? "Recovery enabled" : "Recovery not configured"} + {recoveryStatus.enabled ? "Recovery configured" : "No recovery methods"}

    {recoveryStatus.lastUpdated && ( @@ -1471,29 +1688,121 @@ function SecuritySettings() { )}
    + + {/* Recovery methods list */} + {recoveryStatus.recoveryMethods && recoveryStatus.recoveryMethods.length > 0 && ( +
    + {recoveryStatus.recoveryMethods.map((method) => ( +
    +
    +
    + +
    +
    +

    {method.label}

    +

    + + {method.lastTestedAt && ( + <> • Last tested {formatRelativeTime(method.lastTestedAt)} + )} +

    +
    +
    + +
    + ))} +
    + )} +
    {recoveryStatus.devicesCount} device(s) registered {recoveryStatus.trustedCount} trusted + {recoveryStatus.recoveryMethods && ( + {recoveryStatus.recoveryMethods.length} recovery method(s) + )}
    -
    - - -
    + + {/* Add recovery method form */} + {showAddRecovery ? ( +
    +
    + +
    + {(["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] as const).map((t) => ( + + ))} +
    +
    + setRecoveryLabel(e.target.value)} + placeholder="Label (e.g., My Hardware Key)" + className="w-full rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:border-white/20 focus:outline-none" + /> + setRecoveryValue(e.target.value)} + placeholder={ + recoveryType === "trusted_contact" + ? "Contact Stellar address" + : recoveryType === "hardware_key" + ? "Hardware key fingerprint" + : recoveryType === "paper_key" + ? "Paper key public key" + : "Encrypted backup reference" + } + className="w-full rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:border-white/20 focus:outline-none" + /> +
    + + +
    +
    + ) : ( +
    + + +
    + )}
    @@ -1503,7 +1812,7 @@ function SecuritySettings() {

    Encryption keys

    - Your public key for encrypted message delivery + Current device public key for encrypted message delivery

    @@ -1523,10 +1832,15 @@ function SecuritySettings() { {copiedKey ? "Copied" : "Copy"}
    -
    - -
    - Key active +
    +
    + +
    + Key active + +
    + + Devices using this key: {devices.filter((d) => d.keyStatus === "active").length}
    -
    -
    - - Device revocation and key rotation require confirmation -
    +
    + + Device revocation, compromise flags, and key rotation require confirmation
    - + - All sensitive actions (revocation, compromise flags, key rotation) are logged in your - audit history. Message body content is never recorded. + All sensitive actions are recorded in your audit history. Message body content is + never included in audit events.
    @@ -1580,15 +1892,17 @@ function SecuritySettings() {
    diff --git a/src/features/device-management/types.ts b/src/features/device-management/types.ts index a40472f2..bbbbaeb6 100644 --- a/src/features/device-management/types.ts +++ b/src/features/device-management/types.ts @@ -30,9 +30,21 @@ export interface Session { revokedAt: string | null; } +export interface RecoveryMethod { + id: string; + address: string; + type: "trusted_contact" | "hardware_key" | "paper_key" | "encrypted_backup"; + label: string; + value: string; + createdAt: string; + lastTestedAt: string | null; + disabled: boolean; +} + export interface RecoveryStatus { enabled: boolean; lastUpdated: string | null; devicesCount: number; trustedCount: number; + recoveryMethods: RecoveryMethod[]; } diff --git a/src/features/device-management/useDevices.ts b/src/features/device-management/useDevices.ts index e5edb78f..facccf9b 100644 --- a/src/features/device-management/useDevices.ts +++ b/src/features/device-management/useDevices.ts @@ -1,165 +1,260 @@ import { useState, useEffect, useCallback } from "react"; import type { Device, RecoveryStatus } from "./types"; -const MOCK_DEVICES: Device[] = [ - { - id: "dev_001", - address: "GDQ4...X4KJ", - name: "MacBook Air", - type: "desktop", - fingerprint: "fp_current", - publicKey: "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ", - keyStatus: "active", - trusted: true, - lastActive: new Date().toISOString(), - lastIp: "192.168.1.10", - lastLocation: "San Francisco, CA", - createdAt: new Date(Date.now() - 86400000 * 30).toISOString(), - isCurrent: true, - sessions: [ - { - id: "sess_001", - deviceId: "dev_001", - address: "GDQ4...X4KJ", - startedAt: new Date(Date.now() - 3600000).toISOString(), - lastActiveAt: new Date().toISOString(), - ip: "192.168.1.10", - location: "San Francisco, CA", - isCurrent: true, - revokedAt: null, - }, - ], - }, - { - id: "dev_002", - address: "GDQ4...X4KJ", - name: "iPhone 15 Pro", - type: "mobile", - fingerprint: "fp_iphone", - publicKey: "GBRJ63...M2KN", - keyStatus: "active", - trusted: true, - lastActive: new Date(Date.now() - 7200000).toISOString(), - lastIp: "192.168.1.20", - lastLocation: "San Francisco, CA", - createdAt: new Date(Date.now() - 86400000 * 14).toISOString(), - isCurrent: false, - sessions: [ - { - id: "sess_002", - deviceId: "dev_002", - address: "GDQ4...X4KJ", - startedAt: new Date(Date.now() - 86400000).toISOString(), - lastActiveAt: new Date(Date.now() - 7200000).toISOString(), - ip: "192.168.1.20", - location: "San Francisco, CA", - isCurrent: false, - revokedAt: null, - }, - ], - }, - { - id: "dev_003", - address: "GDQ4...X4KJ", - name: "Old Android Phone", - type: "mobile", - fingerprint: "fp_android", - publicKey: "GCXK42...9PQR", - keyStatus: "revoked", - trusted: false, - lastActive: new Date(Date.now() - 86400000 * 7).toISOString(), - lastIp: "203.0.113.42", - lastLocation: "Unknown location", - createdAt: new Date(Date.now() - 86400000 * 60).toISOString(), - isCurrent: false, - sessions: [], - }, -]; +const API_BASE = "/api/v1"; + +async function apiFetch( + path: string, + options: RequestInit = {}, +): Promise { + const address = localStorage.getItem("stealth-address") ?? "GDQ4...X4KJ"; + const fingerprint = localStorage.getItem("stealth-fingerprint") ?? ""; + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + "content-type": "application/json", + "x-stealth-address": address, + "x-device-fingerprint": fingerprint, + ...options.headers, + }, + }); + + const body = await res.json(); + + if (!res.ok) { + throw new Error(body?.error?.message ?? body?.message ?? `Request failed (${res.status})`); + } + + return body.data ?? body; +} export function useDevices() { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [recoveryStatus, setRecoveryStatus] = useState({ - enabled: true, - lastUpdated: new Date(Date.now() - 86400000 * 3).toISOString(), - devicesCount: 3, - trustedCount: 2, + enabled: false, + lastUpdated: null, + devicesCount: 0, + trustedCount: 0, + recoveryMethods: [], }); - useEffect(() => { - const load = async () => { + const loadDevices = useCallback(async () => { + try { setLoading(true); - await new Promise((r) => setTimeout(r, 150)); - setDevices(MOCK_DEVICES); - setRecoveryStatus({ - enabled: true, - lastUpdated: new Date(Date.now() - 86400000 * 3).toISOString(), - devicesCount: MOCK_DEVICES.length, - trustedCount: MOCK_DEVICES.filter((d) => d.trusted).length, - }); + setError(null); + const [devicesData, recoveryData] = await Promise.all([ + apiFetch<{ devices: Device[] }>("/devices"), + apiFetch("/devices/recovery"), + ]); + setDevices(devicesData.devices); + setRecoveryStatus(recoveryData); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load devices"); + } finally { setLoading(false); - }; - load(); + } }, []); + useEffect(() => { + loadDevices(); + }, [loadDevices]); + + const registerDevice = useCallback( + async (publicKey: string) => { + try { + setError(null); + await apiFetch("/devices/register", { + method: "POST", + body: JSON.stringify({ + publicKey, + userAgent: navigator.userAgent, + acceptLanguage: navigator.language, + acceptEncoding: "gzip, deflate, br", + }), + }); + await loadDevices(); + } catch (err) { + throw err; + } + }, + [loadDevices], + ); + const renameDevice = useCallback( async (deviceId: string, name: string) => { - await new Promise((r) => setTimeout(r, 100)); - setDevices((prev) => - prev.map((d) => (d.id === deviceId ? { ...d, name } : d)), - ); + try { + setError(null); + await apiFetch(`/devices/${deviceId}/name`, { + method: "PUT", + body: JSON.stringify({ name }), + }); + setDevices((prev) => + prev.map((d) => (d.id === deviceId ? { ...d, name } : d)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rename device"); + throw err; + } + }, + [], + ); + + const toggleTrust = useCallback( + async (deviceId: string, trusted: boolean) => { + try { + setError(null); + await apiFetch(`/devices/${deviceId}/trust`, { + method: "POST", + body: JSON.stringify({ trusted }), + }); + setDevices((prev) => + prev.map((d) => (d.id === deviceId ? { ...d, trusted } : d)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update trust"); + throw err; + } }, [], ); - const revokeDevice = useCallback(async (deviceId: string) => { - await new Promise((r) => setTimeout(r, 100)); - setDevices((prev) => - prev.map((d) => { - if (d.id === deviceId) { - return { - ...d, - keyStatus: "revoked" as const, - trusted: false, - sessions: d.sessions.map((s) => ({ ...s, revokedAt: new Date().toISOString() })), - }; - } - return d; - }), - ); - setRecoveryStatus((prev) => ({ - ...prev, - trustedCount: prev.trustedCount - (devices.find((d) => d.id === deviceId)?.trusted ? 1 : 0), - })); - }, [devices]); - - const flagCompromised = useCallback(async (deviceId: string) => { - await new Promise((r) => setTimeout(r, 100)); - setDevices((prev) => - prev.map((d) => { - if (d.id === deviceId) { - return { - ...d, - keyStatus: "compromised" as const, - trusted: false, - sessions: d.sessions.map((s) => ({ ...s, revokedAt: new Date().toISOString() })), - }; - } - return d; - }), - ); - setRecoveryStatus((prev) => ({ - ...prev, - trustedCount: prev.trustedCount - (devices.find((d) => d.id === deviceId)?.trusted ? 1 : 0), - })); - }, [devices]); + const revokeDevice = useCallback( + async (deviceId: string) => { + try { + setError(null); + await apiFetch(`/devices/${deviceId}/revoke`, { + method: "POST", + }); + setDevices((prev) => + prev.map((d) => { + if (d.id === deviceId) { + return { + ...d, + keyStatus: "revoked" as const, + trusted: false, + sessions: d.sessions.map((s) => ({ + ...s, + revokedAt: new Date().toISOString(), + })), + }; + } + return d; + }), + ); + await loadDevices(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to revoke device"); + throw err; + } + }, + [loadDevices], + ); + + const flagCompromised = useCallback( + async (deviceId: string) => { + try { + setError(null); + await apiFetch(`/devices/${deviceId}/compromised`, { + method: "POST", + }); + setDevices((prev) => + prev.map((d) => { + if (d.id === deviceId) { + return { + ...d, + keyStatus: "compromised" as const, + trusted: false, + sessions: d.sessions.map((s) => ({ + ...s, + revokedAt: new Date().toISOString(), + })), + }; + } + return d; + }), + ); + await loadDevices(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to flag device as compromised"); + throw err; + } + }, + [loadDevices], + ); + + const rotateKeys = useCallback( + async (deviceIds: string[], newPublicKey: string) => { + try { + setError(null); + const result = await apiFetch<{ devices: Device[] }>("/devices/rotate-keys", { + method: "POST", + body: JSON.stringify({ deviceIds, newPublicKey }), + }); + setDevices((prev) => { + const kept = prev.filter((d) => !deviceIds.includes(d.id)); + return [...kept, ...result.devices]; + }); + return result.devices; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rotate keys"); + throw err; + } + }, + [], + ); + + const addRecoveryMethod = useCallback( + async (type: string, label: string, value: string) => { + try { + setError(null); + await apiFetch("/devices/recovery-methods", { + method: "POST", + body: JSON.stringify({ type, label, value }), + }); + await loadDevices(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add recovery method"); + throw err; + } + }, + [loadDevices], + ); + + const removeRecoveryMethod = useCallback( + async (methodId: string) => { + try { + setError(null); + await apiFetch(`/devices/recovery-methods/${methodId}`, { + method: "DELETE", + }); + await loadDevices(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove recovery method"); + throw err; + } + }, + [loadDevices], + ); + + const dismissError = useCallback(() => setError(null), []); return { devices, loading, + error, recoveryStatus, + registerDevice, renameDevice, + toggleTrust, revokeDevice, flagCompromised, + rotateKeys, + addRecoveryMethod, + removeRecoveryMethod, + dismissError, + reload: loadDevices, }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3183a56f..a042d2ce 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -23,12 +23,17 @@ import { Route as ApiV1PostageQuoteRouteImport } from './routes/api/v1/postage/q import { Route as ApiV1PostageMessageIdRouteImport } from './routes/api/v1/postage/$messageId' import { Route as ApiV1PoliciesEvaluateRouteImport } from './routes/api/v1/policies/evaluate' import { Route as ApiV1PoliciesOwnerRouteImport } from './routes/api/v1/policies/$owner' +import { Route as ApiV1DevicesRotateKeysRouteImport } from './routes/api/v1/devices/rotate-keys' +import { Route as ApiV1DevicesRegisterRouteImport } from './routes/api/v1/devices/register' +import { Route as ApiV1DevicesRecoveryMethodsRouteImport } from './routes/api/v1/devices/recovery-methods' import { Route as ApiV1DevicesRecoveryRouteImport } from './routes/api/v1/devices/recovery' import { Route as ApiV1DevicesDeviceIdRouteImport } from './routes/api/v1/devices/$deviceId' import { Route as ApiV1SessionsSessionIdRevokeRouteImport } from './routes/api/v1/sessions/$sessionId/revoke' import { Route as ApiV1ReceiptsMessageIdReadRouteImport } from './routes/api/v1/receipts/$messageId/read' import { Route as ApiV1PostageMessageIdSettleRouteImport } from './routes/api/v1/postage/$messageId/settle' import { Route as ApiV1PostageMessageIdRefundRouteImport } from './routes/api/v1/postage/$messageId/refund' +import { Route as ApiV1DevicesRecoveryMethodsMethodIdRouteImport } from './routes/api/v1/devices/recovery-methods/$methodId' +import { Route as ApiV1DevicesDeviceIdTrustRouteImport } from './routes/api/v1/devices/$deviceId/trust' import { Route as ApiV1DevicesDeviceIdRevokeRouteImport } from './routes/api/v1/devices/$deviceId/revoke' import { Route as ApiV1DevicesDeviceIdNameRouteImport } from './routes/api/v1/devices/$deviceId/name' import { Route as ApiV1DevicesDeviceIdCompromisedRouteImport } from './routes/api/v1/devices/$deviceId/compromised' @@ -104,6 +109,22 @@ const ApiV1PoliciesOwnerRoute = ApiV1PoliciesOwnerRouteImport.update({ path: '/api/v1/policies/$owner', getParentRoute: () => rootRouteImport, } as any) +const ApiV1DevicesRotateKeysRoute = ApiV1DevicesRotateKeysRouteImport.update({ + id: '/api/v1/devices/rotate-keys', + path: '/api/v1/devices/rotate-keys', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1DevicesRegisterRoute = ApiV1DevicesRegisterRouteImport.update({ + id: '/api/v1/devices/register', + path: '/api/v1/devices/register', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1DevicesRecoveryMethodsRoute = + ApiV1DevicesRecoveryMethodsRouteImport.update({ + id: '/api/v1/devices/recovery-methods', + path: '/api/v1/devices/recovery-methods', + getParentRoute: () => rootRouteImport, + } as any) const ApiV1DevicesRecoveryRoute = ApiV1DevicesRecoveryRouteImport.update({ id: '/api/v1/devices/recovery', path: '/api/v1/devices/recovery', @@ -138,6 +159,18 @@ const ApiV1PostageMessageIdRefundRoute = path: '/refund', getParentRoute: () => ApiV1PostageMessageIdRoute, } as any) +const ApiV1DevicesRecoveryMethodsMethodIdRoute = + ApiV1DevicesRecoveryMethodsMethodIdRouteImport.update({ + id: '/$methodId', + path: '/$methodId', + getParentRoute: () => ApiV1DevicesRecoveryMethodsRoute, + } as any) +const ApiV1DevicesDeviceIdTrustRoute = + ApiV1DevicesDeviceIdTrustRouteImport.update({ + id: '/trust', + path: '/trust', + getParentRoute: () => ApiV1DevicesDeviceIdRoute, + } as any) const ApiV1DevicesDeviceIdRevokeRoute = ApiV1DevicesDeviceIdRevokeRouteImport.update({ id: '/revoke', @@ -171,6 +204,9 @@ export interface FileRoutesByFullPath { '/api/v1/protocol': typeof ApiV1ProtocolRoute '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute + '/api/v1/devices/recovery-methods': typeof ApiV1DevicesRecoveryMethodsRouteWithChildren + '/api/v1/devices/register': typeof ApiV1DevicesRegisterRoute + '/api/v1/devices/rotate-keys': typeof ApiV1DevicesRotateKeysRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren @@ -183,6 +219,8 @@ export interface FileRoutesByFullPath { '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute + '/api/v1/devices/$deviceId/trust': typeof ApiV1DevicesDeviceIdTrustRoute + '/api/v1/devices/recovery-methods/$methodId': typeof ApiV1DevicesRecoveryMethodsMethodIdRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute @@ -197,6 +235,9 @@ export interface FileRoutesByTo { '/api/v1/protocol': typeof ApiV1ProtocolRoute '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute + '/api/v1/devices/recovery-methods': typeof ApiV1DevicesRecoveryMethodsRouteWithChildren + '/api/v1/devices/register': typeof ApiV1DevicesRegisterRoute + '/api/v1/devices/rotate-keys': typeof ApiV1DevicesRotateKeysRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren @@ -209,6 +250,8 @@ export interface FileRoutesByTo { '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute + '/api/v1/devices/$deviceId/trust': typeof ApiV1DevicesDeviceIdTrustRoute + '/api/v1/devices/recovery-methods/$methodId': typeof ApiV1DevicesRecoveryMethodsMethodIdRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute @@ -224,6 +267,9 @@ export interface FileRoutesById { '/api/v1/protocol': typeof ApiV1ProtocolRoute '/api/v1/devices/$deviceId': typeof ApiV1DevicesDeviceIdRouteWithChildren '/api/v1/devices/recovery': typeof ApiV1DevicesRecoveryRoute + '/api/v1/devices/recovery-methods': typeof ApiV1DevicesRecoveryMethodsRouteWithChildren + '/api/v1/devices/register': typeof ApiV1DevicesRegisterRoute + '/api/v1/devices/rotate-keys': typeof ApiV1DevicesRotateKeysRoute '/api/v1/policies/$owner': typeof ApiV1PoliciesOwnerRouteWithChildren '/api/v1/policies/evaluate': typeof ApiV1PoliciesEvaluateRoute '/api/v1/postage/$messageId': typeof ApiV1PostageMessageIdRouteWithChildren @@ -236,6 +282,8 @@ export interface FileRoutesById { '/api/v1/devices/$deviceId/compromised': typeof ApiV1DevicesDeviceIdCompromisedRoute '/api/v1/devices/$deviceId/name': typeof ApiV1DevicesDeviceIdNameRoute '/api/v1/devices/$deviceId/revoke': typeof ApiV1DevicesDeviceIdRevokeRoute + '/api/v1/devices/$deviceId/trust': typeof ApiV1DevicesDeviceIdTrustRoute + '/api/v1/devices/recovery-methods/$methodId': typeof ApiV1DevicesRecoveryMethodsMethodIdRoute '/api/v1/postage/$messageId/refund': typeof ApiV1PostageMessageIdRefundRoute '/api/v1/postage/$messageId/settle': typeof ApiV1PostageMessageIdSettleRoute '/api/v1/receipts/$messageId/read': typeof ApiV1ReceiptsMessageIdReadRoute @@ -252,6 +300,9 @@ export interface FileRouteTypes { | '/api/v1/protocol' | '/api/v1/devices/$deviceId' | '/api/v1/devices/recovery' + | '/api/v1/devices/recovery-methods' + | '/api/v1/devices/register' + | '/api/v1/devices/rotate-keys' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' @@ -264,6 +315,8 @@ export interface FileRouteTypes { | '/api/v1/devices/$deviceId/compromised' | '/api/v1/devices/$deviceId/name' | '/api/v1/devices/$deviceId/revoke' + | '/api/v1/devices/$deviceId/trust' + | '/api/v1/devices/recovery-methods/$methodId' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' @@ -278,6 +331,9 @@ export interface FileRouteTypes { | '/api/v1/protocol' | '/api/v1/devices/$deviceId' | '/api/v1/devices/recovery' + | '/api/v1/devices/recovery-methods' + | '/api/v1/devices/register' + | '/api/v1/devices/rotate-keys' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' @@ -290,6 +346,8 @@ export interface FileRouteTypes { | '/api/v1/devices/$deviceId/compromised' | '/api/v1/devices/$deviceId/name' | '/api/v1/devices/$deviceId/revoke' + | '/api/v1/devices/$deviceId/trust' + | '/api/v1/devices/recovery-methods/$methodId' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' @@ -304,6 +362,9 @@ export interface FileRouteTypes { | '/api/v1/protocol' | '/api/v1/devices/$deviceId' | '/api/v1/devices/recovery' + | '/api/v1/devices/recovery-methods' + | '/api/v1/devices/register' + | '/api/v1/devices/rotate-keys' | '/api/v1/policies/$owner' | '/api/v1/policies/evaluate' | '/api/v1/postage/$messageId' @@ -316,6 +377,8 @@ export interface FileRouteTypes { | '/api/v1/devices/$deviceId/compromised' | '/api/v1/devices/$deviceId/name' | '/api/v1/devices/$deviceId/revoke' + | '/api/v1/devices/$deviceId/trust' + | '/api/v1/devices/recovery-methods/$methodId' | '/api/v1/postage/$messageId/refund' | '/api/v1/postage/$messageId/settle' | '/api/v1/receipts/$messageId/read' @@ -331,6 +394,9 @@ export interface RootRouteChildren { ApiV1ProtocolRoute: typeof ApiV1ProtocolRoute ApiV1DevicesDeviceIdRoute: typeof ApiV1DevicesDeviceIdRouteWithChildren ApiV1DevicesRecoveryRoute: typeof ApiV1DevicesRecoveryRoute + ApiV1DevicesRecoveryMethodsRoute: typeof ApiV1DevicesRecoveryMethodsRouteWithChildren + ApiV1DevicesRegisterRoute: typeof ApiV1DevicesRegisterRoute + ApiV1DevicesRotateKeysRoute: typeof ApiV1DevicesRotateKeysRoute ApiV1PoliciesOwnerRoute: typeof ApiV1PoliciesOwnerRouteWithChildren ApiV1PoliciesEvaluateRoute: typeof ApiV1PoliciesEvaluateRoute ApiV1PostageMessageIdRoute: typeof ApiV1PostageMessageIdRouteWithChildren @@ -442,6 +508,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1PoliciesOwnerRouteImport parentRoute: typeof rootRouteImport } + '/api/v1/devices/rotate-keys': { + id: '/api/v1/devices/rotate-keys' + path: '/api/v1/devices/rotate-keys' + fullPath: '/api/v1/devices/rotate-keys' + preLoaderRoute: typeof ApiV1DevicesRotateKeysRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/devices/register': { + id: '/api/v1/devices/register' + path: '/api/v1/devices/register' + fullPath: '/api/v1/devices/register' + preLoaderRoute: typeof ApiV1DevicesRegisterRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/devices/recovery-methods': { + id: '/api/v1/devices/recovery-methods' + path: '/api/v1/devices/recovery-methods' + fullPath: '/api/v1/devices/recovery-methods' + preLoaderRoute: typeof ApiV1DevicesRecoveryMethodsRouteImport + parentRoute: typeof rootRouteImport + } '/api/v1/devices/recovery': { id: '/api/v1/devices/recovery' path: '/api/v1/devices/recovery' @@ -484,6 +571,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1PostageMessageIdRefundRouteImport parentRoute: typeof ApiV1PostageMessageIdRoute } + '/api/v1/devices/recovery-methods/$methodId': { + id: '/api/v1/devices/recovery-methods/$methodId' + path: '/$methodId' + fullPath: '/api/v1/devices/recovery-methods/$methodId' + preLoaderRoute: typeof ApiV1DevicesRecoveryMethodsMethodIdRouteImport + parentRoute: typeof ApiV1DevicesRecoveryMethodsRoute + } + '/api/v1/devices/$deviceId/trust': { + id: '/api/v1/devices/$deviceId/trust' + path: '/trust' + fullPath: '/api/v1/devices/$deviceId/trust' + preLoaderRoute: typeof ApiV1DevicesDeviceIdTrustRouteImport + parentRoute: typeof ApiV1DevicesDeviceIdRoute + } '/api/v1/devices/$deviceId/revoke': { id: '/api/v1/devices/$deviceId/revoke' path: '/revoke' @@ -519,17 +620,34 @@ interface ApiV1DevicesDeviceIdRouteChildren { ApiV1DevicesDeviceIdCompromisedRoute: typeof ApiV1DevicesDeviceIdCompromisedRoute ApiV1DevicesDeviceIdNameRoute: typeof ApiV1DevicesDeviceIdNameRoute ApiV1DevicesDeviceIdRevokeRoute: typeof ApiV1DevicesDeviceIdRevokeRoute + ApiV1DevicesDeviceIdTrustRoute: typeof ApiV1DevicesDeviceIdTrustRoute } const ApiV1DevicesDeviceIdRouteChildren: ApiV1DevicesDeviceIdRouteChildren = { ApiV1DevicesDeviceIdCompromisedRoute: ApiV1DevicesDeviceIdCompromisedRoute, ApiV1DevicesDeviceIdNameRoute: ApiV1DevicesDeviceIdNameRoute, ApiV1DevicesDeviceIdRevokeRoute: ApiV1DevicesDeviceIdRevokeRoute, + ApiV1DevicesDeviceIdTrustRoute: ApiV1DevicesDeviceIdTrustRoute, } const ApiV1DevicesDeviceIdRouteWithChildren = ApiV1DevicesDeviceIdRoute._addFileChildren(ApiV1DevicesDeviceIdRouteChildren) +interface ApiV1DevicesRecoveryMethodsRouteChildren { + ApiV1DevicesRecoveryMethodsMethodIdRoute: typeof ApiV1DevicesRecoveryMethodsMethodIdRoute +} + +const ApiV1DevicesRecoveryMethodsRouteChildren: ApiV1DevicesRecoveryMethodsRouteChildren = + { + ApiV1DevicesRecoveryMethodsMethodIdRoute: + ApiV1DevicesRecoveryMethodsMethodIdRoute, + } + +const ApiV1DevicesRecoveryMethodsRouteWithChildren = + ApiV1DevicesRecoveryMethodsRoute._addFileChildren( + ApiV1DevicesRecoveryMethodsRouteChildren, + ) + interface ApiV1PoliciesOwnerRouteChildren { ApiV1PoliciesOwnerSendersSenderRoute: typeof ApiV1PoliciesOwnerSendersSenderRoute } @@ -592,6 +710,10 @@ const rootRouteChildren: RootRouteChildren = { ApiV1ProtocolRoute: ApiV1ProtocolRoute, ApiV1DevicesDeviceIdRoute: ApiV1DevicesDeviceIdRouteWithChildren, ApiV1DevicesRecoveryRoute: ApiV1DevicesRecoveryRoute, + ApiV1DevicesRecoveryMethodsRoute: + ApiV1DevicesRecoveryMethodsRouteWithChildren, + ApiV1DevicesRegisterRoute: ApiV1DevicesRegisterRoute, + ApiV1DevicesRotateKeysRoute: ApiV1DevicesRotateKeysRoute, ApiV1PoliciesOwnerRoute: ApiV1PoliciesOwnerRouteWithChildren, ApiV1PoliciesEvaluateRoute: ApiV1PoliciesEvaluateRoute, ApiV1PostageMessageIdRoute: ApiV1PostageMessageIdRouteWithChildren, diff --git a/src/routes/api/v1/devices/$deviceId/trust.ts b/src/routes/api/v1/devices/$deviceId/trust.ts new file mode 100644 index 00000000..5e7c0bdf --- /dev/null +++ b/src/routes/api/v1/devices/$deviceId/trust.ts @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { toggleDeviceTrust } from "@/server/api/device-service"; +import { parseJsonBody } from "@/server/api/request"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +const trustSchema = z.object({ + trusted: z.boolean(), +}); + +export const Route = createFileRoute("/api/v1/devices/$deviceId/trust")({ + server: { + handlers: { + POST: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const { trusted } = await parseJsonBody(request, trustSchema); + const device = await toggleDeviceTrust( + getApiContext().repository, + params.deviceId, + address, + trusted, + ); + return apiSuccess(request, { device }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/recovery-methods.ts b/src/routes/api/v1/devices/recovery-methods.ts new file mode 100644 index 00000000..a58b64a7 --- /dev/null +++ b/src/routes/api/v1/devices/recovery-methods.ts @@ -0,0 +1,32 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { createRecoveryMethod } from "@/server/api/device-service"; +import { recoveryMethodCreateSchema } from "@/server/api/domain"; +import { parseJsonBody } from "@/server/api/request"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/recovery-methods")({ + server: { + handlers: { + GET: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const methods = await getApiContext().repository.listRecoveryMethods(address); + return apiSuccess(request, { methods }); + }), + POST: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const data = await parseJsonBody(request, recoveryMethodCreateSchema); + const method = await createRecoveryMethod( + getApiContext().repository, + address, + data, + ); + return apiSuccess(request, { method }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/recovery-methods/$methodId.ts b/src/routes/api/v1/devices/recovery-methods/$methodId.ts new file mode 100644 index 00000000..4694835c --- /dev/null +++ b/src/routes/api/v1/devices/recovery-methods/$methodId.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { deleteRecoveryMethod } from "@/server/api/device-service"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +export const Route = createFileRoute("/api/v1/devices/recovery-methods/$methodId")({ + server: { + handlers: { + DELETE: ({ request, params }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + await deleteRecoveryMethod( + getApiContext().repository, + params.methodId, + address, + ); + return apiSuccess(request, { success: true }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/register.ts b/src/routes/api/v1/devices/register.ts new file mode 100644 index 00000000..9fe8ba72 --- /dev/null +++ b/src/routes/api/v1/devices/register.ts @@ -0,0 +1,42 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { registerDevice } from "@/server/api/device-service"; +import { parseJsonBody } from "@/server/api/request"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +const registerSchema = z.object({ + publicKey: z.string().min(1).max(128), + userAgent: z.string().optional().default(""), + acceptLanguage: z.string().optional().default(""), + acceptEncoding: z.string().optional().default(""), +}); + +export const Route = createFileRoute("/api/v1/devices/register")({ + server: { + handlers: { + POST: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const body = await parseJsonBody(request, registerSchema); + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? request.headers.get("x-real-ip") + ?? "unknown"; + const device = await registerDevice( + getApiContext().repository, + address, + { + userAgent: body.userAgent, + acceptLanguage: body.acceptLanguage, + acceptEncoding: body.acceptEncoding, + ip, + }, + body.publicKey, + ); + return apiSuccess(request, { device }); + }), + }, + }, +}); diff --git a/src/routes/api/v1/devices/rotate-keys.ts b/src/routes/api/v1/devices/rotate-keys.ts new file mode 100644 index 00000000..467b5d2d --- /dev/null +++ b/src/routes/api/v1/devices/rotate-keys.ts @@ -0,0 +1,32 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +import { requireActor } from "@/server/api/actor"; +import { getApiContext } from "@/server/api/context"; +import { rotateDeviceKeys } from "@/server/api/device-service"; +import { parseJsonBody } from "@/server/api/request"; +import { apiSuccess, handleApiRequest } from "@/server/api/response"; + +const rotateKeysSchema = z.object({ + deviceIds: z.array(z.string()).min(1), + newPublicKey: z.string().min(1).max(128), +}); + +export const Route = createFileRoute("/api/v1/devices/rotate-keys")({ + server: { + handlers: { + POST: ({ request }) => + handleApiRequest(request, async () => { + const address = requireActor(request); + const { deviceIds, newPublicKey } = await parseJsonBody(request, rotateKeysSchema); + const devices = await rotateDeviceKeys( + getApiContext().repository, + address, + deviceIds, + newPublicKey, + ); + return apiSuccess(request, { devices }); + }), + }, + }, +}); diff --git a/src/server/api/device-service.ts b/src/server/api/device-service.ts index cd3e97be..e98ed0d9 100644 --- a/src/server/api/device-service.ts +++ b/src/server/api/device-service.ts @@ -1,5 +1,5 @@ import { buildDeviceFingerprint } from "./abuse-service"; -import type { Device, DeviceCreate, DeviceUpdate, Session } from "./domain"; +import type { Device, DeviceCreate, RecoveryMethod, RecoveryMethodCreate, Session } from "./domain"; import type { ApiRepository } from "./repository"; export type DeviceWithSessions = Device & { sessions: Session[] }; @@ -13,6 +13,7 @@ function inferDeviceType(userAgent: string): Device["type"] { function inferLocation(ip: string): string { if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return "Local network"; + if (ip.startsWith("10.") || ip.startsWith("192.168.")) return "Private network"; return "Unknown location"; } @@ -62,6 +63,9 @@ export async function registerDevice( const existing = await repository.getDeviceByFingerprint(address, fingerprint); if (existing) { + if (existing.keyStatus === "revoked" || existing.keyStatus === "compromised") { + throw new Error("device_revoked"); + } return existing; } @@ -69,7 +73,7 @@ export async function registerDevice( const location = inferLocation(headers.ip ?? ""); const device = await repository.createDevice(address, { - name: `${deviceType.charAt(0).toUpperCase() + deviceType.slice(1)} ${new Date().toLocaleDateString()}`, + name: `${deviceType.charAt(0).toUpperCase() + deviceType.slice(1)}`, type: deviceType, fingerprint, publicKey, @@ -104,6 +108,18 @@ export async function renameDevice( return repository.updateDevice(deviceId, { name }); } +export async function toggleDeviceTrust( + repository: ApiRepository, + deviceId: string, + address: string, + trusted: boolean, +): Promise { + const device = await repository.getDevice(deviceId); + if (!device) throw new Error("device_not_found"); + if (device.address !== address) throw new Error("forbidden"); + return repository.updateDevice(deviceId, { trusted }); +} + export async function revokeDevice( repository: ApiRepository, deviceId: string, @@ -130,6 +146,32 @@ export async function flagDeviceCompromised( await repository.revokeAllSessionsForDevice(deviceId); } +export async function rotateDeviceKeys( + repository: ApiRepository, + address: string, + deviceIds: string[], + newPublicKey: string, +): Promise { + const rotated: Device[] = []; + for (const deviceId of deviceIds) { + const device = await repository.getDevice(deviceId); + if (!device) continue; + if (device.address !== address) continue; + + const updated = await repository.updateDeviceKeyStatus(deviceId, "rotated"); + const reenrolled = await repository.createDevice(address, { + name: device.name, + type: device.type, + fingerprint: device.fingerprint, + publicKey: newPublicKey, + lastIp: device.lastIp, + lastLocation: device.lastLocation, + }); + rotated.push(reenrolled); + } + return rotated; +} + export async function checkSuspiciousLogin( repository: ApiRepository, address: string, @@ -162,15 +204,39 @@ export async function getRecoveryStatus( lastUpdated: string | null; devicesCount: number; trustedCount: number; + recoveryMethods: RecoveryMethod[]; }> { - const devices = await repository.listDevices(address); + const [devices, methods] = await Promise.all([ + repository.listDevices(address), + repository.listRecoveryMethods(address), + ]); return { - enabled: devices.length > 0, - lastUpdated: devices.length > 0 - ? devices.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + enabled: methods.length > 0, + lastUpdated: methods.length > 0 + ? methods.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] .createdAt : null, devicesCount: devices.length, trustedCount: devices.filter((d) => d.trusted).length, + recoveryMethods: methods, }; } + +export async function createRecoveryMethod( + repository: ApiRepository, + address: string, + data: RecoveryMethodCreate, +): Promise { + return repository.createRecoveryMethod(address, data); +} + +export async function deleteRecoveryMethod( + repository: ApiRepository, + methodId: string, + address: string, +): Promise { + const method = await repository.getRecoveryMethod(methodId); + if (!method) throw new Error("recovery_method_not_found"); + if (method.address !== address) throw new Error("forbidden"); + await repository.deleteRecoveryMethod(methodId); +} diff --git a/src/server/api/domain.ts b/src/server/api/domain.ts index 6c68f96a..f01d34aa 100644 --- a/src/server/api/domain.ts +++ b/src/server/api/domain.ts @@ -99,6 +99,30 @@ export const sessionRevokeSchema = z.object({ deviceId: z.string(), }); +export const recoveryMethodTypeSchema = z.enum(["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"]); + +export const recoveryMethodSchema = z.object({ + id: z.string(), + address: stellarAddressSchema, + type: recoveryMethodTypeSchema, + label: z.string().min(1).max(64), + value: z.string(), // contact address, key fingerprint, or encrypted blob ref + createdAt: z.string().datetime(), + lastTestedAt: z.string().datetime().nullable(), + disabled: z.boolean(), +}); + +export const recoveryMethodCreateSchema = z.object({ + type: recoveryMethodTypeSchema, + label: z.string().min(1).max(64), + value: z.string().min(1), +}); + +export const keyRotationSchema = z.object({ + deviceIds: z.array(z.string()).min(1), + newPublicKey: z.string(), +}); + export type MailboxPolicy = z.infer; export type Postage = z.infer; export type PostageStatus = z.infer; @@ -110,3 +134,6 @@ export type KeyStatus = z.infer; export type Session = z.infer; export type DeviceCreate = z.infer; export type DeviceUpdate = z.infer; +export type RecoveryMethod = z.infer; +export type RecoveryMethodType = z.infer; +export type RecoveryMethodCreate = z.infer; diff --git a/src/server/api/memory-repository.ts b/src/server/api/memory-repository.ts index 5c3dc324..92e267b8 100644 --- a/src/server/api/memory-repository.ts +++ b/src/server/api/memory-repository.ts @@ -1,4 +1,4 @@ -import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, SenderRule, Session } from "./domain"; +import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, RecoveryMethod, RecoveryMethodCreate, SenderRule, Session } from "./domain"; import type { ApiRepository } from "./repository"; function key(owner: string, sender: string) { @@ -13,6 +13,7 @@ export class MemoryApiRepository implements ApiRepository { private readonly counters = new Map(); private readonly devices = new Map(); private readonly sessions = new Map(); + private readonly recoveryMethods = new Map(); async getPolicy(owner: string) { return structuredClone(this.policies.get(owner) ?? null); @@ -165,6 +166,42 @@ export class MemoryApiRepository implements ApiRepository { return structuredClone(device ?? null); } + async listRecoveryMethods(address: string) { + return Array.from(this.recoveryMethods.values()) + .filter((m) => m.address === address) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + async getRecoveryMethod(methodId: string) { + return structuredClone(this.recoveryMethods.get(methodId) ?? null); + } + + async createRecoveryMethod(address: string, data: RecoveryMethodCreate) { + const method: RecoveryMethod = { + id: crypto.randomUUID(), + address, + type: data.type, + label: data.label, + value: data.value, + createdAt: new Date().toISOString(), + lastTestedAt: null, + disabled: false, + }; + this.recoveryMethods.set(method.id, method); + return structuredClone(method); + } + + async deleteRecoveryMethod(methodId: string) { + this.recoveryMethods.delete(methodId); + } + + async testRecoveryMethod(methodId: string) { + const method = this.recoveryMethods.get(methodId); + if (!method) throw new Error("recovery_method_not_found"); + method.lastTestedAt = new Date().toISOString(); + return structuredClone(method); + } + async incrementCounter(key: string, windowSeconds: number) { const now = Date.now(); const windowMilliseconds = windowSeconds * 1000; @@ -184,5 +221,6 @@ export class MemoryApiRepository implements ApiRepository { this.counters.clear(); this.devices.clear(); this.sessions.clear(); + this.recoveryMethods.clear(); } } diff --git a/src/server/api/openapi.ts b/src/server/api/openapi.ts index d81936c1..de4d3e6d 100644 --- a/src/server/api/openapi.ts +++ b/src/server/api/openapi.ts @@ -41,6 +41,38 @@ export const openApiDocument = { requireVerified: { type: "boolean" }, }, }, + Device: { + type: "object", + required: ["id", "address", "name", "type", "fingerprint", "publicKey", "keyStatus", "trusted", "lastActive", "lastIp", "lastLocation", "createdAt", "isCurrent"], + properties: { + id: { type: "string" }, + address: { $ref: "#/components/schemas/StellarAddress" }, + name: { type: "string" }, + type: { type: "string", enum: ["desktop", "mobile", "tablet", "unknown"] }, + fingerprint: { type: "string" }, + publicKey: { type: "string" }, + keyStatus: { type: "string", enum: ["active", "compromised", "revoked", "rotated"] }, + trusted: { type: "boolean" }, + lastActive: { type: "string", format: "date-time" }, + lastIp: { type: "string" }, + lastLocation: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + isCurrent: { type: "boolean" }, + }, + }, + RecoveryMethod: { + type: "object", + required: ["id", "address", "type", "label", "value", "createdAt", "lastTestedAt", "disabled"], + properties: { + id: { type: "string" }, + type: { type: "string", enum: ["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] }, + label: { type: "string" }, + value: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + lastTestedAt: { type: "string", format: "date-time", nullable: true }, + disabled: { type: "boolean" }, + }, + }, }, }, paths: { @@ -83,5 +115,42 @@ export const openApiDocument = { "/receipts/{messageId}/read": { post: { summary: "Record recipient read acknowledgment", security: actorSecurity }, }, + "/devices": { + get: { summary: "List registered devices with session info", security: actorSecurity }, + }, + "/devices/register": { + post: { summary: "Register a new device", security: actorSecurity }, + }, + "/devices/recovery": { + get: { summary: "Read account recovery status", security: actorSecurity }, + }, + "/devices/{deviceId}": { + get: { summary: "Read a specific device", security: actorSecurity }, + }, + "/devices/{deviceId}/name": { + put: { summary: "Rename a device", security: actorSecurity }, + }, + "/devices/{deviceId}/trust": { + post: { summary: "Toggle device trust status", security: actorSecurity }, + }, + "/devices/{deviceId}/revoke": { + post: { summary: "Revoke a device", security: actorSecurity }, + }, + "/devices/{deviceId}/compromised": { + post: { summary: "Flag a device as compromised", security: actorSecurity }, + }, + "/devices/rotate-keys": { + post: { summary: "Rotate encryption keys for devices", security: actorSecurity }, + }, + "/devices/recovery-methods": { + get: { summary: "List recovery methods", security: actorSecurity }, + post: { summary: "Create a recovery method", security: actorSecurity }, + }, + "/devices/recovery-methods/{methodId}": { + delete: { summary: "Delete a recovery method", security: actorSecurity }, + }, + "/sessions/{sessionId}/revoke": { + post: { summary: "Revoke a specific session", security: actorSecurity }, + }, }, } as const; diff --git a/src/server/api/repository.ts b/src/server/api/repository.ts index e7843515..039c05bf 100644 --- a/src/server/api/repository.ts +++ b/src/server/api/repository.ts @@ -1,4 +1,4 @@ -import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, SenderRule, Session } from "./domain"; +import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, RecoveryMethod, RecoveryMethodCreate, SenderRule, Session } from "./domain"; export interface ApiRepository { getPolicy(owner: string): Promise; @@ -32,6 +32,12 @@ export interface ApiRepository { revokeAllSessionsForDevice(deviceId: string): Promise; getDeviceByFingerprint(address: string, fingerprint: string): Promise; + + listRecoveryMethods(address: string): Promise; + getRecoveryMethod(methodId: string): Promise; + createRecoveryMethod(address: string, data: RecoveryMethodCreate): Promise; + deleteRecoveryMethod(methodId: string): Promise; + testRecoveryMethod(methodId: string): Promise; } export const defaultMailboxPolicy: MailboxPolicy = { diff --git a/tests/unit/device-service.test.ts b/tests/unit/device-service.test.ts new file mode 100644 index 00000000..88380915 --- /dev/null +++ b/tests/unit/device-service.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { MemoryApiRepository } from "../../src/server/api/memory-repository"; +import { + registerDevice, + renameDevice, + revokeDevice, + flagDeviceCompromised, + toggleDeviceTrust, + rotateDeviceKeys, + getDevicesWithSessions, + getRecoveryStatus, + checkSuspiciousLogin, + createRecoveryMethod, + deleteRecoveryMethod, +} from "../../src/server/api/device-service"; + +const TEST_ADDRESS = "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"; + +function makeRepo() { + return new MemoryApiRepository(); +} + +describe("device-service", () => { + let repo: MemoryApiRepository; + + beforeEach(() => { + repo = makeRepo(); + }); + + describe("registerDevice", () => { + it("creates a new device and session", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (Macintosh)", ip: "192.168.1.10" }, + "PUBLIC_KEY_1", + ); + + expect(device.address).toBe(TEST_ADDRESS); + expect(device.keyStatus).toBe("active"); + expect(device.type).toBe("desktop"); + expect(device.lastLocation).toBe("Private network"); + + const sessions = await repo.listSessions(TEST_ADDRESS); + expect(sessions).toHaveLength(1); + expect(sessions[0].deviceId).toBe(device.id); + }); + + it("returns existing device on duplicate fingerprint", async () => { + const device1 = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (Macintosh)", ip: "192.168.1.10" }, + "PUBLIC_KEY_1", + ); + + const device2 = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (Macintosh)", ip: "192.168.1.10" }, + "PUBLIC_KEY_2", + ); + + expect(device2.id).toBe(device1.id); + }); + + it("rejects registration on revoked device", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (Macintosh)", ip: "192.168.1.10" }, + "PUBLIC_KEY_1", + ); + + await repo.updateDeviceKeyStatus(device.id, "revoked"); + + await expect( + registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (Macintosh)", ip: "192.168.1.10" }, + "PUBLIC_KEY_2", + ), + ).rejects.toThrow("device_revoked"); + }); + + it("infers mobile type from user agent", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", ip: "10.0.0.1" }, + "PUBLIC_KEY_3", + ); + + expect(device.type).toBe("mobile"); + }); + }); + + describe("renameDevice", () => { + it("renames a device", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + const renamed = await renameDevice(repo, device.id, TEST_ADDRESS, "My Laptop"); + expect(renamed.name).toBe("My Laptop"); + }); + + it("throws on non-existent device", async () => { + await expect( + renameDevice(repo, "non_existent", TEST_ADDRESS, "Name"), + ).rejects.toThrow("device_not_found"); + }); + + it("throws on address mismatch", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + await expect( + renameDevice(repo, device.id, "GOTHER...ADDR", "Name"), + ).rejects.toThrow("forbidden"); + }); + }); + + describe("toggleDeviceTrust", () => { + it("toggles trust status", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + const trusted = await toggleDeviceTrust(repo, device.id, TEST_ADDRESS, true); + expect(trusted.trusted).toBe(true); + + const untrusted = await toggleDeviceTrust(repo, device.id, TEST_ADDRESS, false); + expect(untrusted.trusted).toBe(false); + }); + }); + + describe("revokeDevice", () => { + it("revokes a device and all its sessions", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + await revokeDevice(repo, device.id, TEST_ADDRESS); + + const stored = await repo.getDevice(device.id); + expect(stored?.keyStatus).toBe("revoked"); + + const sessions = await repo.listSessions(TEST_ADDRESS); + expect(sessions.every((s) => s.revokedAt !== null)).toBe(true); + }); + }); + + describe("flagDeviceCompromised", () => { + it("flags a device as compromised", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + await flagDeviceCompromised(repo, device.id, TEST_ADDRESS); + + const stored = await repo.getDevice(device.id); + expect(stored?.keyStatus).toBe("compromised"); + }); + }); + + describe("rotateDeviceKeys", () => { + it("rotates keys for specified devices", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "OLD_KEY", + ); + + const rotated = await rotateDeviceKeys(repo, TEST_ADDRESS, [device.id], "NEW_KEY"); + + expect(rotated).toHaveLength(1); + expect(rotated[0].publicKey).toBe("NEW_KEY"); + expect(rotated[0].id).not.toBe(device.id); // new device created + + const old = await repo.getDevice(device.id); + expect(old?.keyStatus).toBe("rotated"); + }); + }); + + describe("getDevicesWithSessions", () => { + it("returns devices with session info", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + const result = await getDevicesWithSessions(repo, TEST_ADDRESS, device.fingerprint); + expect(result).toHaveLength(1); + expect(result[0].isCurrent).toBe(true); + expect(result[0].sessions).toHaveLength(1); + }); + }); + + describe("checkSuspiciousLogin", () => { + it("returns not suspicious for unknown when no devices", async () => { + const result = await checkSuspiciousLogin(repo, TEST_ADDRESS, "unknown_fp", "10.0.0.1"); + expect(result.suspicious).toBe(false); + }); + + it("detects unrecognized device", async () => { + await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + + const result = await checkSuspiciousLogin(repo, TEST_ADDRESS, "different_fp", "10.0.0.2"); + expect(result.suspicious).toBe(true); + expect(result.reason).toBe("unrecognized_device"); + }); + + it("detects compromised device", async () => { + const device = await registerDevice( + repo, + TEST_ADDRESS, + { userAgent: "TestAgent", ip: "10.0.0.1" }, + "PUBLIC_KEY", + ); + await flagDeviceCompromised(repo, device.id, TEST_ADDRESS); + + const result = await checkSuspiciousLogin( + repo, + TEST_ADDRESS, + device.fingerprint, + "10.0.0.1", + ); + expect(result.suspicious).toBe(true); + expect(result.reason).toBe("device_compromised"); + }); + }); + + describe("getRecoveryStatus", () => { + it("returns disabled when no methods exist", async () => { + const status = await getRecoveryStatus(repo, TEST_ADDRESS); + expect(status.enabled).toBe(false); + expect(status.recoveryMethods).toHaveLength(0); + }); + + it("returns enabled when recovery methods exist", async () => { + await createRecoveryMethod(repo, TEST_ADDRESS, { + type: "trusted_contact", + label: "My Contact", + value: "GD123...XYZ", + }); + + const status = await getRecoveryStatus(repo, TEST_ADDRESS); + expect(status.enabled).toBe(true); + expect(status.recoveryMethods).toHaveLength(1); + }); + }); + + describe("createRecoveryMethod / deleteRecoveryMethod", () => { + it("creates and deletes recovery methods", async () => { + const method = await createRecoveryMethod(repo, TEST_ADDRESS, { + type: "hardware_key", + label: "Ledger Nano X", + value: "FINGERPRINT_123", + }); + + expect(method.type).toBe("hardware_key"); + expect(method.label).toBe("Ledger Nano X"); + + await deleteRecoveryMethod(repo, method.id, TEST_ADDRESS); + + const stored = await repo.getRecoveryMethod(method.id); + expect(stored).toBeNull(); + }); + + it("throws on address mismatch when deleting", async () => { + const method = await createRecoveryMethod(repo, TEST_ADDRESS, { + type: "paper_key", + label: "Backup Key", + value: "PUBKEY_ABC", + }); + + await expect( + deleteRecoveryMethod(repo, method.id, "GOTHER...ADDR"), + ).rejects.toThrow("forbidden"); + }); + }); +}); From 1b960441926917c1e5e032ddb517c5470b0916cf Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 21:05:19 +0100 Subject: [PATCH 04/12] chore: update bun.lock to include @axe-core/playwright and eslint-plugin-jsx-a11y --- bun.lock | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/bun.lock b/bun.lock index 6f9eb906..30fcbd84 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "zod": "^3.24.2", }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/js": "^9.32.0", "@playwright/test": "^1.49.1", "@types/node": "^22.16.5", @@ -70,6 +71,7 @@ "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -83,6 +85,8 @@ }, }, "packages": { + "@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@babel/compat-data/-/compat-data-7.29.3.tgz", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -651,8 +655,30 @@ "aria-hidden": ["aria-hidden@1.2.6", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "balanced-match": ["balanced-match@1.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -675,6 +701,12 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001792", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], @@ -737,6 +769,14 @@ "d3-timer": ["d3-timer@3.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -747,6 +787,10 @@ "deep-is": ["deep-is@0.1.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "detect-libc": ["detect-libc@2.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -763,6 +807,8 @@ "domutils": ["domutils@3.2.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/domutils/-/domutils-3.2.2.tgz", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.352", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", {}, "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg=="], "embla-carousel": ["embla-carousel@8.6.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/embla-carousel/-/embla-carousel-8.6.0.tgz", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], @@ -771,6 +817,8 @@ "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "encoding-sniffer": ["encoding-sniffer@0.2.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], "enhanced-resolve": ["enhanced-resolve@5.21.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], @@ -779,8 +827,24 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.1", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g=="], + "esbuild": ["esbuild@0.27.7", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/esbuild/-/esbuild-0.27.7.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -791,6 +855,8 @@ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], @@ -843,26 +909,58 @@ "flatted": ["flatted@3.4.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "framer-motion": ["framer-motion@12.38.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/framer-motion/-/framer-motion-12.38.0.tgz", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "fsevents": ["fsevents@2.3.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "glob-parent": ["glob-parent@6.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@15.15.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/globals/-/globals-15.15.0.tgz", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globrex": ["globrex@0.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/globrex/-/globrex-0.1.2.tgz", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "h3-v2": ["h3@2.0.1-rc.20", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/h3/-/h3-2.0.1-rc.20.tgz", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + "has-flag": ["has-flag@4.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "htmlparser2": ["htmlparser2@10.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "iconv-lite": ["iconv-lite@0.6.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -877,16 +975,64 @@ "input-otp": ["input-otp@1.4.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/input-otp/-/input-otp-1.4.2.tgz", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "internmap": ["internmap@2.0.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + "is-binary-path": ["is-binary-path@2.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/is-binary-path/-/is-binary-path-2.1.0.tgz", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + "is-extglob": ["is-extglob@2.1.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + "is-glob": ["is-glob@4.0.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + "is-number": ["is-number@7.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbot": ["isbot@5.1.40", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/isbot/-/isbot-5.1.40.tgz", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="], "isexe": ["isexe@2.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -907,10 +1053,16 @@ "json5": ["json5@2.2.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "keyv": ["keyv@4.5.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kleur": ["kleur@4.1.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "levn": ["levn@0.4.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -951,6 +1103,8 @@ "magic-string": ["magic-string@0.30.21", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "miniflare": ["miniflare@4.20260504.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/miniflare/-/miniflare-4.20260504.0.tgz", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260504.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-HeI/HLx+rbeo/UB4qb6NsNcFdUVD7xDzyCexZJTVtFMlfpfexUKEDmdeTRRpzeHrJseZFGua+v9JO1kfPublUw=="], "minimatch": ["minimatch@3.1.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -973,10 +1127,22 @@ "object-assign": ["object-assign@4.1.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], "optionator": ["optionator@0.9.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + "p-limit": ["p-limit@3.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1005,6 +1171,8 @@ "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.14", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "prelude-ls": ["prelude-ls@1.2.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1047,12 +1215,22 @@ "recharts-scale": ["recharts-scale@0.4.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/recharts-scale/-/recharts-scale-0.4.5.tgz", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "resolve-from": ["resolve-from@4.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "rollup": ["rollup@4.60.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/rollup/-/rollup-4.60.3.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], "rou3": ["rou3@0.8.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/rou3/-/rou3-0.8.1.tgz", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1063,12 +1241,26 @@ "seroval-plugins": ["seroval-plugins@1.5.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/seroval-plugins/-/seroval-plugins-1.5.4.tgz", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "sharp": ["sharp@0.34.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "sonner": ["sonner@2.0.7", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], @@ -1083,6 +1275,16 @@ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1117,12 +1319,22 @@ "type-check": ["type-check@0.4.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typescript": ["typescript@5.9.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.59.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/typescript-eslint/-/typescript-eslint-8.59.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], "ufo": ["ufo@1.6.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/ufo/-/ufo-1.6.4.tgz", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici": ["undici@7.24.8", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/undici/-/undici-7.24.8.tgz", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], "undici-types": ["undici-types@6.21.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1161,6 +1373,14 @@ "which": ["which@2.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "word-wrap": ["word-wrap@1.2.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], From 1894f7a22dd545876eb6dd9061fa4ad74e5f736b Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 21:45:18 +0100 Subject: [PATCH 05/12] style: fix prettier formatting across device management and a11y files --- src/components/mail/Compose.tsx | 12 +- src/components/mail/SettingsModal.tsx | 183 +++--- .../command-palette/ShortcutOverlay.tsx | 4 +- src/features/device-management/useDevices.ts | 104 ++-- .../proof-inspector/ProofInspectorModal.tsx | 523 +++++++++--------- src/routes/api/v1/devices/recovery-methods.ts | 6 +- .../v1/devices/recovery-methods/$methodId.ts | 6 +- src/routes/api/v1/devices/register.ts | 7 +- src/server/api/device-service.ts | 20 +- src/server/api/domain.ts | 7 +- src/server/api/memory-repository.ts | 13 +- src/server/api/openapi.ts | 32 +- src/server/api/repository.ts | 13 +- tests/e2e/accessibility.spec.ts | 16 +- tests/unit/device-service.test.ts | 25 +- 15 files changed, 509 insertions(+), 462 deletions(-) diff --git a/src/components/mail/Compose.tsx b/src/components/mail/Compose.tsx index 811f0457..3b6c0200 100644 --- a/src/components/mail/Compose.tsx +++ b/src/components/mail/Compose.tsx @@ -571,12 +571,12 @@ function Field({ }) { return (
    - - {label} - + + {label} + ; - case "hardware_key": return ; - case "paper_key": return ; - case "encrypted_backup": return ; + case "trusted_contact": + return ; + case "hardware_key": + return ; + case "paper_key": + return ; + case "encrypted_backup": + return ; } } function RecoveryMethodLabel({ type }: { type: RecoveryMethod["type"] }) { switch (type) { - case "trusted_contact": return "Trusted contact"; - case "hardware_key": return "Hardware key"; - case "paper_key": return "Paper key"; - case "encrypted_backup": return "Encrypted backup"; + case "trusted_contact": + return "Trusted contact"; + case "hardware_key": + return "Hardware key"; + case "paper_key": + return "Paper key"; + case "encrypted_backup": + return "Encrypted backup"; } } function SecuritySettings() { const { - devices, loading, error, recoveryStatus, dismissError, - renameDevice, toggleTrust, revokeDevice, flagCompromised, - rotateKeys, addRecoveryMethod, removeRecoveryMethod, registerDevice, + devices, + loading, + error, + recoveryStatus, + dismissError, + renameDevice, + toggleTrust, + revokeDevice, + flagCompromised, + rotateKeys, + addRecoveryMethod, + removeRecoveryMethod, + registerDevice, } = useDevices(); const [confirmDialog, setConfirmDialog] = useState(null); @@ -1119,8 +1137,9 @@ function SecuritySettings() { const [recoveryValue, setRecoveryValue] = useState(""); const handleCopyKey = useCallback(() => { - const key = devices.find((d) => d.isCurrent)?.publicKey - ?? "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"; + const key = + devices.find((d) => d.isCurrent)?.publicKey ?? + "GDQJMSGKJGQ2X576L33OY4JFDZ7NJG5OJ3LJ44V33PUPU7D5Q5X4KJ"; navigator.clipboard.writeText(key).then(() => { setCopiedKey(true); toast.success("Public key copied"); @@ -1128,21 +1147,18 @@ function SecuritySettings() { }); }, [devices]); - const withConfirm = useCallback( - (action: () => Promise, successMsg: string) => { - setConfirming(true); - action() - .then(() => { - toast.success(successMsg); - setConfirmDialog(null); - }) - .catch((err: Error) => { - toast.error(err.message ?? "Action failed"); - }) - .finally(() => setConfirming(false)); - }, - [], - ); + const withConfirm = useCallback((action: () => Promise, successMsg: string) => { + setConfirming(true); + action() + .then(() => { + toast.success(successMsg); + setConfirmDialog(null); + }) + .catch((err: Error) => { + toast.error(err.message ?? "Action failed"); + }) + .finally(() => setConfirming(false)); + }, []); const handleSaveDeviceName = useCallback( async (deviceId: string) => { @@ -1163,14 +1179,12 @@ function SecuritySettings() { (device: Device) => { setConfirmDialog({ title: `Revoke "${device.name}"?`, - description: device.keyStatus === "compromised" - ? "This device is flagged as compromised. All encryption keys will be permanently invalidated. The device will lose all access immediately. This cannot be undone." - : "All sessions will be terminated and the device will lose access immediately. The device will need to re-authenticate to regain access. This cannot be undone.", + description: + device.keyStatus === "compromised" + ? "This device is flagged as compromised. All encryption keys will be permanently invalidated. The device will lose all access immediately. This cannot be undone." + : "All sessions will be terminated and the device will lose access immediately. The device will need to re-authenticate to regain access. This cannot be undone.", type: "danger", - onConfirm: () => withConfirm( - () => revokeDevice(device.id), - `"${device.name}" revoked`, - ), + onConfirm: () => withConfirm(() => revokeDevice(device.id), `"${device.name}" revoked`), }); }, [revokeDevice, withConfirm], @@ -1183,10 +1197,8 @@ function SecuritySettings() { description: "All sessions will be immediately revoked and encryption keys invalidated. Future messages will not be decryptable by this device. We strongly recommend rotating all account keys after this action. This cannot be undone.", type: "danger", - onConfirm: () => withConfirm( - () => flagCompromised(device.id), - `"${device.name}" flagged as compromised`, - ), + onConfirm: () => + withConfirm(() => flagCompromised(device.id), `"${device.name}" flagged as compromised`), }); }, [flagCompromised, withConfirm], @@ -1202,9 +1214,7 @@ function SecuritySettings() { ); const handleRotateKeys = useCallback(() => { - const activeDevices = devices.filter( - (d) => d.keyStatus === "active" && d.isCurrent, - ); + const activeDevices = devices.filter((d) => d.keyStatus === "active" && d.isCurrent); if (activeDevices.length === 0) { toast.error("No active devices to rotate keys for"); return; @@ -1212,35 +1222,32 @@ function SecuritySettings() { setConfirmDialog({ title: "Rotate encryption keys?", description: - "This generates a new key pair for this device. Old keys are marked as rotated. " - + "Existing encrypted messages remain accessible with old keys until they expire. " - + "You will need to update recovery information after rotation. This action is logged in your audit history.", + "This generates a new key pair for this device. Old keys are marked as rotated. " + + "Existing encrypted messages remain accessible with old keys until they expire. " + + "You will need to update recovery information after rotation. This action is logged in your audit history.", type: "warning", - onConfirm: () => withConfirm( - async () => { - const newKey = `GD${Array.from({ length: 54 }, () => - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], + onConfirm: () => + withConfirm(async () => { + const newKey = `GD${Array.from( + { length: 54 }, + () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], ).join("")}`; await rotateKeys( activeDevices.map((d) => d.id), newKey, ); - }, - "Keys rotated successfully", - ), + }, "Keys rotated successfully"), }); }, [devices, rotateKeys, withConfirm]); const handleRegisterDevice = useCallback(() => { - withConfirm( - async () => { - const newKey = `GD${Array.from({ length: 54 }, () => - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], - ).join("")}`; - await registerDevice(newKey); - }, - "Device registered", - ); + withConfirm(async () => { + const newKey = `GD${Array.from( + { length: 54 }, + () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)], + ).join("")}`; + await registerDevice(newKey); + }, "Device registered"); }, [registerDevice, withConfirm]); const handleAddRecoveryMethod = useCallback(async () => { @@ -1263,12 +1270,11 @@ function SecuritySettings() { (method: RecoveryMethod) => { setConfirmDialog({ title: `Remove "${method.label}"?`, - description: "You will lose this recovery method. Ensure you have at least one other recovery method configured before removing this one.", + description: + "You will lose this recovery method. Ensure you have at least one other recovery method configured before removing this one.", type: "warning", - onConfirm: () => withConfirm( - () => removeRecoveryMethod(method.id), - "Recovery method removed", - ), + onConfirm: () => + withConfirm(() => removeRecoveryMethod(method.id), "Recovery method removed"), }); }, [removeRecoveryMethod, withConfirm], @@ -1315,10 +1321,7 @@ function SecuritySettings() { {error}
    -
    @@ -1334,8 +1337,8 @@ function SecuritySettings() {

    {compromisedDevices.length === 1 ? `${compromisedDevices[0].name} has been flagged as compromised.` - : `${compromisedDevices.length} devices have been flagged as compromised.`} - {" "}Access revoked and encryption keys invalidated.{" "} + : `${compromisedDevices.length} devices have been flagged as compromised.`}{" "} + Access revoked and encryption keys invalidated.{" "}

    - {currentDevice.lastLocation} • Last active {formatRelativeTime(currentDevice.lastActive)} + {currentDevice.lastLocation} • Last active{" "} + {formatRelativeTime(currentDevice.lastActive)}

    @@ -1527,12 +1531,11 @@ function SecuritySettings() {
    )}

    - {device.lastLocation} •{" "} - {formatRelativeTime(device.lastActive)} + {device.lastLocation} • {formatRelativeTime(device.lastActive)} {device.trusted && !isDisabled && ( <> - {" "}•{" "} - Trusted + {" "} + • Trusted )}

    @@ -1588,9 +1591,7 @@ function SecuritySettings() { {device.publicKey.slice(0, 20)}... - - Registered {formatRelativeTime(device.createdAt)} - + Registered {formatRelativeTime(device.createdAt)} {device.sessions.length > 0 && ( {device.sessions.filter((s) => !s.revokedAt).length} active session(s) @@ -1628,7 +1629,9 @@ function SecuritySettings() { {showRevocationInfo && (
    -

    What happens when you revoke a device?

    +

    + What happens when you revoke a device? +

    • @@ -1636,11 +1639,16 @@ function SecuritySettings() {
    • - The device's encryption key is invalidated — it can no longer decrypt new messages + + The device's encryption key is invalidated — it can no longer decrypt + new messages +
    • - Existing encrypted messages already on the device remain accessible locally + + Existing encrypted messages already on the device remain accessible locally +
    • @@ -1648,7 +1656,10 @@ function SecuritySettings() {
    • - If the device is compromised, flagging it triggers a security alert and invalidates all associated keys + + If the device is compromised, flagging it triggers a security alert and + invalidates all associated keys +
    • @@ -1737,7 +1748,9 @@ function SecuritySettings() {
      - {(["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] as const).map((t) => ( + {( + ["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] as const + ).map((t) => (
      -

      Keyboard shortcuts

      +

      + Keyboard shortcuts +

      Search every shortcut in one place. Shortcuts pause automatically while typing in inputs, editors, and custom text fields. diff --git a/src/features/device-management/useDevices.ts b/src/features/device-management/useDevices.ts index facccf9b..66982bb2 100644 --- a/src/features/device-management/useDevices.ts +++ b/src/features/device-management/useDevices.ts @@ -3,10 +3,7 @@ import type { Device, RecoveryStatus } from "./types"; const API_BASE = "/api/v1"; -async function apiFetch( - path: string, - options: RequestInit = {}, -): Promise { +async function apiFetch(path: string, options: RequestInit = {}): Promise { const address = localStorage.getItem("stealth-address") ?? "GDQ4...X4KJ"; const fingerprint = localStorage.getItem("stealth-fingerprint") ?? ""; @@ -83,43 +80,33 @@ export function useDevices() { [loadDevices], ); - const renameDevice = useCallback( - async (deviceId: string, name: string) => { - try { - setError(null); - await apiFetch(`/devices/${deviceId}/name`, { - method: "PUT", - body: JSON.stringify({ name }), - }); - setDevices((prev) => - prev.map((d) => (d.id === deviceId ? { ...d, name } : d)), - ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to rename device"); - throw err; - } - }, - [], - ); + const renameDevice = useCallback(async (deviceId: string, name: string) => { + try { + setError(null); + await apiFetch(`/devices/${deviceId}/name`, { + method: "PUT", + body: JSON.stringify({ name }), + }); + setDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, name } : d))); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rename device"); + throw err; + } + }, []); - const toggleTrust = useCallback( - async (deviceId: string, trusted: boolean) => { - try { - setError(null); - await apiFetch(`/devices/${deviceId}/trust`, { - method: "POST", - body: JSON.stringify({ trusted }), - }); - setDevices((prev) => - prev.map((d) => (d.id === deviceId ? { ...d, trusted } : d)), - ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update trust"); - throw err; - } - }, - [], - ); + const toggleTrust = useCallback(async (deviceId: string, trusted: boolean) => { + try { + setError(null); + await apiFetch(`/devices/${deviceId}/trust`, { + method: "POST", + body: JSON.stringify({ trusted }), + }); + setDevices((prev) => prev.map((d) => (d.id === deviceId ? { ...d, trusted } : d))); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update trust"); + throw err; + } + }, []); const revokeDevice = useCallback( async (deviceId: string) => { @@ -185,26 +172,23 @@ export function useDevices() { [loadDevices], ); - const rotateKeys = useCallback( - async (deviceIds: string[], newPublicKey: string) => { - try { - setError(null); - const result = await apiFetch<{ devices: Device[] }>("/devices/rotate-keys", { - method: "POST", - body: JSON.stringify({ deviceIds, newPublicKey }), - }); - setDevices((prev) => { - const kept = prev.filter((d) => !deviceIds.includes(d.id)); - return [...kept, ...result.devices]; - }); - return result.devices; - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to rotate keys"); - throw err; - } - }, - [], - ); + const rotateKeys = useCallback(async (deviceIds: string[], newPublicKey: string) => { + try { + setError(null); + const result = await apiFetch<{ devices: Device[] }>("/devices/rotate-keys", { + method: "POST", + body: JSON.stringify({ deviceIds, newPublicKey }), + }); + setDevices((prev) => { + const kept = prev.filter((d) => !deviceIds.includes(d.id)); + return [...kept, ...result.devices]; + }); + return result.devices; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rotate keys"); + throw err; + } + }, []); const addRecoveryMethod = useCallback( async (type: string, label: string, value: string) => { diff --git a/src/features/proof-inspector/ProofInspectorModal.tsx b/src/features/proof-inspector/ProofInspectorModal.tsx index 884a50f0..cfb1fb74 100644 --- a/src/features/proof-inspector/ProofInspectorModal.tsx +++ b/src/features/proof-inspector/ProofInspectorModal.tsx @@ -221,8 +221,13 @@ export function ProofInspectorModal({

      -

      Stealth Proof Inspector

      -

      +

      + Stealth Proof Inspector +

      +

      Search and audit smart contract ledger proofs and payment preimages.

      @@ -332,280 +337,286 @@ export function ProofInspectorModal({ {/* Search Result display */}
      - {hasSearched && ( - - {searchResults.length === 0 ? ( - /* MISSING RECORDS / NEXT STEPS GUIDE */ - -
      - - - -
      -

      - Proof Record Not Found -

      -

      - No local cryptographic delivery or payment proofs match your search - query. + {hasSearched && ( + + {searchResults.length === 0 ? ( + /* MISSING RECORDS / NEXT STEPS GUIDE */ + +

      + + + +
      +

      + Proof Record Not Found +

      +

      + No local cryptographic delivery or payment proofs match your search + query. +

      +
      +
      + +
      +
      + + Recommended Next Steps +
      +
        +
      • + + 1. + +

        + + Verify on Stellar Explorer + + Search the transaction hash on{" "} + + Stellar.Expert + + {" "} + or the Stellar Laboratory to verify if the payment settled. +

        +
      • +
      • + + 2. + +

        + + Check Postage Preimage Settle State + + Ensure the recipient's mailbox contract has settled the postage + preimage. Unsettled postages automatically return to the sender + after 7 days. +

        +
      • +
      • + + 3. + +

        + + Inspect Relay Node Diagnostics + + Ping the relay server node (`relay-us-east-1.stealth.network`) to + check routing logs. +

        +
      • +
      +
      + + ) : ( + /* RECORD FOUND & DETAILED SECTIONS */ + + {/* Security Alert: Sensitive payload notice */} +
      + +

      + + Diagnostic Mode: + {" "} + Plaintext payload body and sensitive email attachments are omitted for + privacy. Use the "Open Message" button to view and decrypt the message + content securely.

      -
      -
      -
      - - Recommended Next Steps -
      -
        -
      • - - 1. - -

        - - Verify on Stellar Explorer - - Search the transaction hash on{" "} - - Stellar.Expert - - {" "} - or the Stellar Laboratory to verify if the payment settled. -

        -
      • -
      • - - 2. + {/* Header overview */} +
        +
        + Subject (Omitted preview) + + {selectedRecord.email.subject.replace(/./g, (c, i) => + i > 4 && i < 20 ? "•" : c, + )} -

        - - Check Postage Preimage Settle State - - Ensure the recipient's mailbox contract has settled the postage - preimage. Unsettled postages automatically return to the sender after - 7 days. -

        -
      • -
      • - - 3. +
      +
      + Verification State + + + Ledger Verified -

      - - Inspect Relay Node Diagnostics - - Ping the relay server node (`relay-us-east-1.stealth.network`) to - check routing logs. -

      -
    • -
    -
    - - ) : ( - /* RECORD FOUND & DETAILED SECTIONS */ - - {/* Security Alert: Sensitive payload notice */} -
    - -

    - Diagnostic Mode:{" "} - Plaintext payload body and sensitive email attachments are omitted for - privacy. Use the "Open Message" button to view and decrypt the message - content securely. -

    -
    - - {/* Header overview */} -
    -
    - Subject (Omitted preview) - - {selectedRecord.email.subject.replace(/./g, (c, i) => - i > 4 && i < 20 ? "•" : c, - )} - -
    -
    - Verification State - - - Ledger Verified - +
    -
    - {/* Structured Details Sections Grid */} -
    - {/* Section 1: Policy Info */} -
    -
    - Policy Metadata -
    -
    -
    - Sender Rule: - - {selectedRecord.senderRule} - -
    -
    - Cryptographic Contact: - Yes -
    -
    - Postage Required: - Yes + {/* Structured Details Sections Grid */} +
    + {/* Section 1: Policy Info */} +
    +
    + Policy Metadata +
    +
    +
    + Sender Rule: + + {selectedRecord.senderRule} + +
    +
    + + Cryptographic Contact: + + Yes +
    +
    + Postage Required: + Yes +
    -
    - {/* Section 2: Postage Info */} -
    -
    - Postage details -
    -
    -
    - Postage Amount: - - {Number(selectedRecord.postageAmount) / 10_000_000} XLM - -
    -
    - Postage Status: - - {selectedRecord.postageStatus} - -
    -
    - Payment Hash: - + {/* Section 2: Postage Info */} +
    +
    + Postage details +
    +
    +
    + Postage Amount: + + {Number(selectedRecord.postageAmount) / 10_000_000} XLM + +
    +
    + Postage Status: + + {selectedRecord.postageStatus} + +
    +
    + Payment Hash: + +
    -
    - {/* Section 3: Receipt Info */} -
    -
    - Receipt details -
    -
    -
    - Delivered At: - {selectedRecord.deliveredAt} -
    -
    - Read Receipt: - - {selectedRecord.readAt ?? "Pending read confirmation"} - -
    -
    - Sender Key: - + {/* Section 3: Receipt Info */} +
    +
    + Receipt details +
    +
    +
    + Delivered At: + + {selectedRecord.deliveredAt} + +
    +
    + Read Receipt: + + {selectedRecord.readAt ?? "Pending read confirmation"} + +
    +
    + Sender Key: + +
    -
    - {/* Section 4: Relay Metadata */} -
    -
    - Relay metadata -
    -
    -
    - Relay Node: - - {selectedRecord.relayNode} - -
    -
    - Routing Latency: - - {selectedRecord.latency} - -
    -
    - Relay Diag ID: - + {/* Section 4: Relay Metadata */} +
    +
    + Relay metadata +
    +
    +
    + Relay Node: + + {selectedRecord.relayNode} + +
    +
    + Routing Latency: + + {selectedRecord.latency} + +
    +
    + Relay Diag ID: + +
    -
    - {/* Diagnostic JSON Copy report */} - - - )} - - )} -
    + {/* Diagnostic JSON Copy report */} + + + )} + + )} +
    {/* Modal Footer CTAs */} diff --git a/src/routes/api/v1/devices/recovery-methods.ts b/src/routes/api/v1/devices/recovery-methods.ts index a58b64a7..d1c71628 100644 --- a/src/routes/api/v1/devices/recovery-methods.ts +++ b/src/routes/api/v1/devices/recovery-methods.ts @@ -20,11 +20,7 @@ export const Route = createFileRoute("/api/v1/devices/recovery-methods")({ handleApiRequest(request, async () => { const address = requireActor(request); const data = await parseJsonBody(request, recoveryMethodCreateSchema); - const method = await createRecoveryMethod( - getApiContext().repository, - address, - data, - ); + const method = await createRecoveryMethod(getApiContext().repository, address, data); return apiSuccess(request, { method }); }), }, diff --git a/src/routes/api/v1/devices/recovery-methods/$methodId.ts b/src/routes/api/v1/devices/recovery-methods/$methodId.ts index 4694835c..394b9324 100644 --- a/src/routes/api/v1/devices/recovery-methods/$methodId.ts +++ b/src/routes/api/v1/devices/recovery-methods/$methodId.ts @@ -11,11 +11,7 @@ export const Route = createFileRoute("/api/v1/devices/recovery-methods/$methodId DELETE: ({ request, params }) => handleApiRequest(request, async () => { const address = requireActor(request); - await deleteRecoveryMethod( - getApiContext().repository, - params.methodId, - address, - ); + await deleteRecoveryMethod(getApiContext().repository, params.methodId, address); return apiSuccess(request, { success: true }); }), }, diff --git a/src/routes/api/v1/devices/register.ts b/src/routes/api/v1/devices/register.ts index 9fe8ba72..d7041348 100644 --- a/src/routes/api/v1/devices/register.ts +++ b/src/routes/api/v1/devices/register.ts @@ -21,9 +21,10 @@ export const Route = createFileRoute("/api/v1/devices/register")({ handleApiRequest(request, async () => { const address = requireActor(request); const body = await parseJsonBody(request, registerSchema); - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() - ?? request.headers.get("x-real-ip") - ?? "unknown"; + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown"; const device = await registerDevice( getApiContext().repository, address, diff --git a/src/server/api/device-service.ts b/src/server/api/device-service.ts index e98ed0d9..75c99f1e 100644 --- a/src/server/api/device-service.ts +++ b/src/server/api/device-service.ts @@ -34,10 +34,12 @@ export async function getDevicesWithSessions( .filter((s) => s.deviceId === device.id) .map((s) => ({ ...s, isCurrent: s.isCurrent && isCurrent })); - const lastActive = deviceSessions.length > 0 - ? deviceSessions.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime())[0] - .lastActiveAt - : device.lastActive; + const lastActive = + deviceSessions.length > 0 + ? deviceSessions.sort( + (a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime(), + )[0].lastActiveAt + : device.lastActive; return { ...device, @@ -212,10 +214,12 @@ export async function getRecoveryStatus( ]); return { enabled: methods.length > 0, - lastUpdated: methods.length > 0 - ? methods.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] - .createdAt - : null, + lastUpdated: + methods.length > 0 + ? methods.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0].createdAt + : null, devicesCount: devices.length, trustedCount: devices.filter((d) => d.trusted).length, recoveryMethods: methods, diff --git a/src/server/api/domain.ts b/src/server/api/domain.ts index f01d34aa..38382982 100644 --- a/src/server/api/domain.ts +++ b/src/server/api/domain.ts @@ -99,7 +99,12 @@ export const sessionRevokeSchema = z.object({ deviceId: z.string(), }); -export const recoveryMethodTypeSchema = z.enum(["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"]); +export const recoveryMethodTypeSchema = z.enum([ + "trusted_contact", + "hardware_key", + "paper_key", + "encrypted_backup", +]); export const recoveryMethodSchema = z.object({ id: z.string(), diff --git a/src/server/api/memory-repository.ts b/src/server/api/memory-repository.ts index 92e267b8..b97bad3e 100644 --- a/src/server/api/memory-repository.ts +++ b/src/server/api/memory-repository.ts @@ -1,4 +1,15 @@ -import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, RecoveryMethod, RecoveryMethodCreate, SenderRule, Session } from "./domain"; +import type { + Device, + DeviceCreate, + DeviceUpdate, + MailboxPolicy, + Postage, + Receipt, + RecoveryMethod, + RecoveryMethodCreate, + SenderRule, + Session, +} from "./domain"; import type { ApiRepository } from "./repository"; function key(owner: string, sender: string) { diff --git a/src/server/api/openapi.ts b/src/server/api/openapi.ts index de4d3e6d..ffe85c0b 100644 --- a/src/server/api/openapi.ts +++ b/src/server/api/openapi.ts @@ -43,7 +43,21 @@ export const openApiDocument = { }, Device: { type: "object", - required: ["id", "address", "name", "type", "fingerprint", "publicKey", "keyStatus", "trusted", "lastActive", "lastIp", "lastLocation", "createdAt", "isCurrent"], + required: [ + "id", + "address", + "name", + "type", + "fingerprint", + "publicKey", + "keyStatus", + "trusted", + "lastActive", + "lastIp", + "lastLocation", + "createdAt", + "isCurrent", + ], properties: { id: { type: "string" }, address: { $ref: "#/components/schemas/StellarAddress" }, @@ -62,10 +76,22 @@ export const openApiDocument = { }, RecoveryMethod: { type: "object", - required: ["id", "address", "type", "label", "value", "createdAt", "lastTestedAt", "disabled"], + required: [ + "id", + "address", + "type", + "label", + "value", + "createdAt", + "lastTestedAt", + "disabled", + ], properties: { id: { type: "string" }, - type: { type: "string", enum: ["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] }, + type: { + type: "string", + enum: ["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"], + }, label: { type: "string" }, value: { type: "string" }, createdAt: { type: "string", format: "date-time" }, diff --git a/src/server/api/repository.ts b/src/server/api/repository.ts index 039c05bf..f50482be 100644 --- a/src/server/api/repository.ts +++ b/src/server/api/repository.ts @@ -1,4 +1,15 @@ -import type { Device, DeviceCreate, DeviceUpdate, MailboxPolicy, Postage, Receipt, RecoveryMethod, RecoveryMethodCreate, SenderRule, Session } from "./domain"; +import type { + Device, + DeviceCreate, + DeviceUpdate, + MailboxPolicy, + Postage, + Receipt, + RecoveryMethod, + RecoveryMethodCreate, + SenderRule, + Session, +} from "./domain"; export interface ApiRepository { getPolicy(owner: string): Promise; diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 449d9f8c..97e30d36 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -7,9 +7,7 @@ test("main mail view has no critical or serious axe violations", async ({ page } await expect(page.getByRole("heading", { name: /inbox/i })).toBeVisible(); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze(); const violations = results.violations.filter( (v) => v.impact === "critical" || v.impact === "serious", @@ -25,9 +23,7 @@ test("compose dialog has no critical or serious axe violations", async ({ page } await page.keyboard.press("Control+n"); await page.waitForTimeout(300); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze(); const violations = results.violations.filter( (v) => v.impact === "critical" || v.impact === "serious", @@ -43,9 +39,7 @@ test("settings modal has no critical or serious axe violations", async ({ page } await page.keyboard.press(","); await page.waitForTimeout(300); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze(); const violations = results.violations.filter( (v) => v.impact === "critical" || v.impact === "serious", @@ -61,9 +55,7 @@ test("keyboard shortcuts modal has no critical or serious axe violations", async await page.keyboard.press("?"); await page.waitForTimeout(300); - const results = await new AxeBuilder({ page }) - .disableRules(["color-contrast"]) - .analyze(); + const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze(); const violations = results.violations.filter( (v) => v.impact === "critical" || v.impact === "serious", diff --git a/tests/unit/device-service.test.ts b/tests/unit/device-service.test.ts index 88380915..393eb828 100644 --- a/tests/unit/device-service.test.ts +++ b/tests/unit/device-service.test.ts @@ -110,9 +110,9 @@ describe("device-service", () => { }); it("throws on non-existent device", async () => { - await expect( - renameDevice(repo, "non_existent", TEST_ADDRESS, "Name"), - ).rejects.toThrow("device_not_found"); + await expect(renameDevice(repo, "non_existent", TEST_ADDRESS, "Name")).rejects.toThrow( + "device_not_found", + ); }); it("throws on address mismatch", async () => { @@ -123,9 +123,9 @@ describe("device-service", () => { "PUBLIC_KEY", ); - await expect( - renameDevice(repo, device.id, "GOTHER...ADDR", "Name"), - ).rejects.toThrow("forbidden"); + await expect(renameDevice(repo, device.id, "GOTHER...ADDR", "Name")).rejects.toThrow( + "forbidden", + ); }); }); @@ -245,12 +245,7 @@ describe("device-service", () => { ); await flagDeviceCompromised(repo, device.id, TEST_ADDRESS); - const result = await checkSuspiciousLogin( - repo, - TEST_ADDRESS, - device.fingerprint, - "10.0.0.1", - ); + const result = await checkSuspiciousLogin(repo, TEST_ADDRESS, device.fingerprint, "10.0.0.1"); expect(result.suspicious).toBe(true); expect(result.reason).toBe("device_compromised"); }); @@ -300,9 +295,9 @@ describe("device-service", () => { value: "PUBKEY_ABC", }); - await expect( - deleteRecoveryMethod(repo, method.id, "GOTHER...ADDR"), - ).rejects.toThrow("forbidden"); + await expect(deleteRecoveryMethod(repo, method.id, "GOTHER...ADDR")).rejects.toThrow( + "forbidden", + ); }); }); }); From b2e97c53493ef91379df75687293c28b0e9e5ef2 Mon Sep 17 00:00:00 2001 From: Timrossid Date: Wed, 17 Jun 2026 22:35:20 +0100 Subject: [PATCH 06/12] fix: resolve all 14 eslint/jsx-a11y errors --- src/components/mail/Compose.tsx | 4 +- src/components/mail/EmailList.tsx | 378 +++++++++--------- src/components/mail/RightPanel.tsx | 51 ++- src/components/mail/SettingsModal.tsx | 4 +- src/components/ui/alert.tsx | 6 +- src/components/ui/pagination.tsx | 12 +- .../calendar/components/CalendarWorkspace.tsx | 12 +- src/features/contacts/ImportWizard.tsx | 8 + .../DemoAdminDashboard.tsx | 4 +- src/features/device-management/useDevices.ts | 26 +- 10 files changed, 279 insertions(+), 226 deletions(-) diff --git a/src/components/mail/Compose.tsx b/src/components/mail/Compose.tsx index 3b6c0200..f72fce0f 100644 --- a/src/components/mail/Compose.tsx +++ b/src/components/mail/Compose.tsx @@ -353,7 +353,7 @@ export function Compose({ detail="On-chain proof" onClick={() => setReceipt((value) => !value)} /> -
    diff --git a/src/components/mail/EmailList.tsx b/src/components/mail/EmailList.tsx index 084a4013..4e505afd 100644 --- a/src/components/mail/EmailList.tsx +++ b/src/components/mail/EmailList.tsx @@ -219,68 +219,135 @@ export function EmailList({ bulkFailures={bulkFailures} /> -
      { + const items = Array.from( + (e.currentTarget as HTMLElement).querySelectorAll('[role="option"]'), + ); + const currentIdx = items.findIndex((el) => el === document.activeElement); + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = items[currentIdx + 1] ?? items[0]; + (next as HTMLElement)?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = items[currentIdx - 1] ?? items[items.length - 1]; + (prev as HTMLElement)?.focus(); + } + }} > - {filtered.length === 0 && ( -
    • - No conversations in {folderLabel.toLowerCase()} yet. -
    • - )} - {filtered.length > 0 && ( -
    • -
      - - - - {someSelected - ? `${selectedVisibleIds.length} selected` - : `${filtered.length} conversations`} - - - Ctrl/⌘+A · Esc - -
      -
    • - )} - {filtered.map((e, idx) => { - const active = selectedId === e.id || selectedIds.includes(e.id); - const selected = selectedIds.includes(e.id); - const selectMessage = (shiftKey = false) => { - if (shiftKey && lastAnchorId) { - selectRange(e.id); - } else { - onSelect(e.id); +
        + {filtered.length === 0 && ( +
      • + No conversations in {folderLabel.toLowerCase()} yet. +
      • + )} + {filtered.length > 0 && ( +
      • +
        + + + + {someSelected + ? `${selectedVisibleIds.length} selected` + : `${filtered.length} conversations`} + + + Ctrl/⌘+A · Esc + +
        +
      • + )} + {filtered.map((e, idx) => { + const active = selectedId === e.id || selectedIds.includes(e.id); + const selected = selectedIds.includes(e.id); + const selectMessage = (shiftKey = false) => { + if (shiftKey && lastAnchorId) { + selectRange(e.id); + } else { + onSelect(e.id); + } + }; + + if (useMobile) { + return ( + +
        + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onCheckedChange={() => toggleSelection(e.id)} + className="mt-3 border-white/15 bg-white/[0.035] data-[state=checked]:border-white/30" + /> +
        + onSelect(e.id)} + onArchive={() => onArchive?.(e)} + onStar={() => onStar?.(e)} + onSnooze={() => onSnooze?.(e)} + /> +
        +
        + {e.folder === "requests" && ( +
        + onConvertSender(e)} + /> +
        + )} +
        + ); } - }; - if (useMobile) { return ( -
        +
        { + const target = event.target as HTMLElement | null; + if (target?.closest('[role="checkbox"], input, button[aria-label^="Select"]')) { + return; + } + selectMessage(event.shiftKey); + }} + > event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} onCheckedChange={() => toggleSelection(e.id)} - className="mt-3 border-white/15 bg-white/[0.035] data-[state=checked]:border-white/30" + className="mt-2.5 border-white/15 bg-white/[0.035] data-[state=checked]:border-white/30" /> -
        - onSelect(e.id)} - onArchive={() => onArchive?.(e)} - onStar={() => onStar?.(e)} - onSnooze={() => onSnooze?.(e)} - /> -
        + selectMessage(event.shiftKey)} + whileTap={{ scale: 0.975 }} + transition={{ type: "spring", stiffness: 520, damping: 30 }} + aria-selected={active} + className={cn( + "mail-preview-card group relative flex w-full items-start gap-3 px-3 text-left transition-[background,border-color,box-shadow,transform] duration-300", + active + ? "-translate-y-px border-white/15 bg-[oklch(0.38_0.007_270/0.55)] py-2 shadow-[0_18px_42px_oklch(0_0_0/0.35),0_0_0_1px_oklch(1_0_0/0.07),inset_0_1px_0_oklch(1_0_0/0.14)]" + : compact + ? "py-2" + : "py-2.5", + )} + > + {active && ( + + )} + {showAvatars && ( +
        + {e.from} + {e.unread && ( + + )} +
        + )} +
        +
        +
        + + {e.from} + + +
        + + {e.time} + +
        +
        + {e.subject} +
        +
        +
        {e.folder === "requests" && (
        @@ -312,125 +446,9 @@ export function EmailList({ )} ); - } - - return ( - -
        { - const target = event.target as HTMLElement | null; - if (target?.closest('[role="checkbox"], input, button[aria-label^="Select"]')) { - return; - } - selectMessage(event.shiftKey); - }} - > - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - onCheckedChange={() => toggleSelection(e.id)} - className="mt-2.5 border-white/15 bg-white/[0.035] data-[state=checked]:border-white/30" - /> - selectMessage(event.shiftKey)} - whileTap={{ scale: 0.975 }} - transition={{ type: "spring", stiffness: 520, damping: 30 }} - aria-selected={active} - className={cn( - "mail-preview-card group relative flex w-full items-start gap-3 px-3 text-left transition-[background,border-color,box-shadow,transform] duration-300", - active - ? "-translate-y-px border-white/15 bg-[oklch(0.38_0.007_270/0.55)] py-2 shadow-[0_18px_42px_oklch(0_0_0/0.35),0_0_0_1px_oklch(1_0_0/0.07),inset_0_1px_0_oklch(1_0_0/0.14)]" - : compact - ? "py-2" - : "py-2.5", - )} - > - {active && ( - - )} - {showAvatars && ( -
        - {e.from} - {e.unread && ( - - )} -
        - )} -
        -
        -
        - - {e.from} - - -
        - - {e.time} - -
        -
        - {e.subject} -
        -
        -
        -
        - {e.folder === "requests" && ( -
        - onConvertSender(e)} - /> -
        - )} -
        - ); - })} -
      + })} +
    +
    ); } diff --git a/src/components/mail/RightPanel.tsx b/src/components/mail/RightPanel.tsx index 3813418d..0d2ac155 100644 --- a/src/components/mail/RightPanel.tsx +++ b/src/components/mail/RightPanel.tsx @@ -170,24 +170,39 @@ export function RightPanel({
      - {email.attachments.map((attachment) => ( -
    • onPreviewAttachment?.(attachment)} - className={cn( - "flex items-center gap-2 rounded-lg px-2 py-1.5 transition duration-150", - onPreviewAttachment && "cursor-pointer hover:bg-white/[0.06]", - )} - > -
      - {attachment.type} -
      -
      -
      {attachment.name}
      -
      {attachment.size}
      -
      -
    • - ))} + {email.attachments.map((attachment) => { + const content = ( + <> +
      + {attachment.type} +
      +
      +
      {attachment.name}
      +
      {attachment.size}
      +
      + + ); + return onPreviewAttachment ? ( +
    • + +
    • + ) : ( +
    • + {content} +
    • + ); + })}
    ) : null} diff --git a/src/components/mail/SettingsModal.tsx b/src/components/mail/SettingsModal.tsx index f2456bf4..adbd7beb 100644 --- a/src/components/mail/SettingsModal.tsx +++ b/src/components/mail/SettingsModal.tsx @@ -1746,7 +1746,9 @@ function SecuritySettings() { {showAddRecovery ? (
    - + + Recovery type +
    {( ["trusted_contact", "hardware_key", "paper_key", "encrypted_backup"] as const diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index cd0a0627..d1edf33b 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -28,12 +28,14 @@ const Alert = React.forwardRef< Alert.displayName = "Alert"; const AlertTitle = React.forwardRef>( - ({ className, ...props }, ref) => ( + ({ className, children, ...props }, ref) => (
    + > + {children} +
    ), ); AlertTitle.displayName = "AlertTitle"; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index f8cd41fb..967d5d45 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -31,7 +31,13 @@ type PaginationLinkProps = { } & Pick & React.ComponentProps<"a">; -const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => ( +const PaginationLink = ({ + className, + isActive, + size = "icon", + children, + ...props +}: PaginationLinkProps) => ( + > + {children} + ); PaginationLink.displayName = "PaginationLink"; diff --git a/src/features/calendar/components/CalendarWorkspace.tsx b/src/features/calendar/components/CalendarWorkspace.tsx index 82553af7..85e8ca97 100644 --- a/src/features/calendar/components/CalendarWorkspace.tsx +++ b/src/features/calendar/components/CalendarWorkspace.tsx @@ -282,10 +282,14 @@ export function CalendarWorkspace({ setCalendarName(""); }} > -