fix(frontend): send local API key as Bearer on /v1 + /api requests (#471)

When `jarvis serve` runs with an API key configured, AuthMiddleware 401s
every /v1 and /api request that lacks a Bearer token. The frontend never
sent one, so telemetry, managed-agents, savings, etc. all failed (#266).

- Add getApiKey() (reads settings.apiKey, with optional
  VITE_OPENJARVIS_API_KEY build-time override) and authHeaders() to
  api.ts, plus an apiFetch() wrapper that prepends getBase() and injects
  the Bearer header on every local-server call. Route all /v1 + /api
  fetches through it so none can omit auth.
- Add `apiKey` to the Settings model (store.ts) and a password field in
  Settings → Connection so users can enter it.

Keyless local servers are unaffected: with no key, no Authorization
header is sent (byte-for-byte unchanged). The Supabase savings path keeps
its own anon key — not conflated with the local key.

Bootstraps vitest (no prior frontend test runner) + a `test` script, and
adds api.auth.test.ts covering getApiKey/authHeaders. Verified: tsc
--noEmit clean, vitest 6/6, vite build succeeds.

Deferred (not in scope): WebSocket auth (browsers can't set WS headers)
and Tauri auto-injecting a generated key.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-06-01 13:25:14 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 690b95e050
commit 4b4fd587b2
6 changed files with 641 additions and 50 deletions
+441 -1
View File
@@ -48,7 +48,8 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^1.2.0"
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=20"
@@ -4064,6 +4065,17 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -4136,6 +4148,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4274,6 +4293,131 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
"integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.6",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
"integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
"integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
"integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.6",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
"integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.6",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
"integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
"integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.6",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -4414,6 +4558,16 @@
"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",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -4654,6 +4808,16 @@
"node": ">= 0.8"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -4741,6 +4905,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -4793,6 +4974,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5390,6 +5581,16 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5749,6 +5950,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5975,6 +6183,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -8156,6 +8374,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
@@ -9742,6 +9967,23 @@
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10987,6 +11229,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -11072,6 +11321,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11081,6 +11337,13 @@
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@@ -11294,6 +11557,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -11459,6 +11742,20 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -11475,6 +11772,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
@@ -12164,6 +12491,29 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-pwa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
@@ -12195,6 +12545,79 @@
}
}
},
"node_modules/vitest": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.6",
"@vitest/mocker": "3.2.6",
"@vitest/pretty-format": "^3.2.6",
"@vitest/runner": "3.2.6",
"@vitest/snapshot": "3.2.6",
"@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.6",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.6",
"@vitest/ui": "3.2.6",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -12343,6 +12766,23 @@
"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",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/workbox-background-sync": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
+4 -2
View File
@@ -11,7 +11,8 @@
"build": "tsc -b && vite build",
"build:tauri": "tsc -b && vite build --outDir dist",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -54,6 +55,7 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^1.2.0"
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.6"
}
}
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// Regression for #266: the frontend must send the local API key as a Bearer
// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s
// every data-plane call. These tests cover the pure helpers (getApiKey,
// authHeaders) that source the key and build the header.
const SETTINGS_KEY = 'openjarvis-settings';
// Minimal in-memory localStorage stub so the helpers can run under node
// (no jsdom dependency).
class MemoryStorage {
private store = new Map<string, string>();
getItem(k: string): string | null {
return this.store.has(k) ? (this.store.get(k) as string) : null;
}
setItem(k: string, v: string): void {
this.store.set(k, String(v));
}
removeItem(k: string): void {
this.store.delete(k);
}
clear(): void {
this.store.clear();
}
}
beforeEach(() => {
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
async function freshApi() {
// Re-import to pick up the current localStorage stub.
return await import('./api');
}
describe('getApiKey', () => {
it('returns empty string when no key is configured', async () => {
const { getApiKey } = await freshApi();
expect(getApiKey()).toBe('');
});
it('reads apiKey from the openjarvis-settings localStorage blob', async () => {
localStorage.setItem(
SETTINGS_KEY,
JSON.stringify({ apiUrl: 'http://x', apiKey: 'sk-local-123' }),
);
const { getApiKey } = await freshApi();
expect(getApiKey()).toBe('sk-local-123');
});
it('returns empty string when the blob has no apiKey field', async () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiUrl: 'http://x' }));
const { getApiKey } = await freshApi();
expect(getApiKey()).toBe('');
});
});
describe('authHeaders', () => {
it('omits Authorization when no key is set (keyless default unchanged)', async () => {
const { authHeaders } = await freshApi();
expect(authHeaders()).toEqual({});
});
it('adds a Bearer Authorization header when a key is set', async () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' }));
const { authHeaders } = await freshApi();
expect(authHeaders()).toEqual({ Authorization: 'Bearer sk-local-123' });
});
it('merges extra headers alongside Authorization', async () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' }));
const { authHeaders } = await freshApi();
expect(authHeaders({ 'Content-Type': 'application/json' })).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer sk-local-123',
});
});
});
+91 -47
View File
@@ -52,6 +52,50 @@ export const getBase = (): string => {
return '';
};
// Resolve the local server API key (OPENJARVIS_API_KEY). When `jarvis serve`
// is started with a key, AuthMiddleware 401s every /v1 and /api request that
// lacks a Bearer token — so the frontend must send it (#266). Sourced from the
// same settings blob as the API URL, with an optional build-time env override.
// Returns '' when unset, so a keyless local server keeps working unchanged.
export const getApiKey = (): string => {
try {
const raw = localStorage.getItem('openjarvis-settings');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.apiKey) return String(parsed.apiKey);
}
} catch {}
if (import.meta.env.VITE_OPENJARVIS_API_KEY) {
return import.meta.env.VITE_OPENJARVIS_API_KEY as string;
}
return '';
};
// Build request headers with the Bearer Authorization token when a local key
// is configured, merging any caller-supplied headers. Adds no Authorization
// header when no key is set, so keyless local dev is byte-for-byte unchanged.
export const authHeaders = (
extra: Record<string, string> = {},
): Record<string, string> => {
const key = getApiKey();
return key ? { ...extra, Authorization: `Bearer ${key}` } : { ...extra };
};
// Centralized fetch for the local server: prepends getBase() and injects the
// Bearer auth header (when a key is set) on every call. Using this everywhere
// guarantees no /v1 or /api request is sent without auth — the bug in #266 was
// that direct fetch() calls omitted the header and 401'd. `path` is the
// server-relative path (e.g. "/v1/savings").
export const apiFetch = (
path: string,
init: RequestInit = {},
): Promise<Response> => {
const headers = authHeaders(
(init.headers as Record<string, string> | undefined) ?? {},
);
return fetch(`${getBase()}${path}`, { ...init, headers });
};
async function tauriInvoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
const { invoke } = await import('@tauri-apps/api/core');
const apiUrl = getBase();
@@ -95,14 +139,14 @@ export async function fetchModels(): Promise<ModelInfo[]> {
// Fall through to fetch
}
}
const res = await fetch(`${getBase()}/v1/models`);
const res = await apiFetch(`/v1/models`);
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
const data = await res.json();
return data.data || [];
}
export async function fetchRecommendedModel(): Promise<{ model: string; reason: string }> {
const res = await fetch(`${getBase()}/v1/recommended-model`);
const res = await apiFetch(`/v1/recommended-model`);
if (!res.ok) return { model: '', reason: 'Failed to fetch' };
return res.json();
}
@@ -119,7 +163,7 @@ export async function pullModel(modelName: string): Promise<void> {
throw new Error(e?.message || e || 'Download failed');
}
}
const res = await fetch(`${getBase()}/v1/models/pull`, {
const res = await apiFetch(`/v1/models/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelName }),
@@ -140,7 +184,7 @@ export async function deleteModel(modelName: string): Promise<void> {
throw new Error(e?.message || e || 'Delete failed');
}
}
const res = await fetch(`${getBase()}/v1/models/${encodeURIComponent(modelName)}`, {
const res = await apiFetch(`/v1/models/${encodeURIComponent(modelName)}`, {
method: 'DELETE',
});
if (!res.ok) {
@@ -173,13 +217,13 @@ export async function preloadModel(modelName: string): Promise<void> {
}
export async function fetchSavings(): Promise<SavingsData> {
const res = await fetch(`${getBase()}/v1/savings`);
const res = await apiFetch(`/v1/savings`);
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
return res.json();
}
export async function fetchServerInfo(): Promise<ServerInfo> {
const res = await fetch(`${getBase()}/v1/info`);
const res = await apiFetch(`/v1/info`);
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
return res.json();
}
@@ -221,7 +265,7 @@ export async function fetchEnergy(): Promise<unknown> {
return await tauriInvoke('fetch_energy', { apiUrl: getBase() });
} catch {}
}
const res = await fetch(`${getBase()}/v1/telemetry/energy`);
const res = await apiFetch(`/v1/telemetry/energy`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -232,7 +276,7 @@ export async function fetchTelemetry(): Promise<unknown> {
return await tauriInvoke('fetch_telemetry', { apiUrl: getBase() });
} catch {}
}
const res = await fetch(`${getBase()}/v1/telemetry/stats`);
const res = await apiFetch(`/v1/telemetry/stats`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -243,7 +287,7 @@ export async function fetchTraces(limit: number = 50): Promise<unknown> {
return await tauriInvoke('fetch_traces', { apiUrl: getBase(), limit });
} catch {}
}
const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
const res = await apiFetch(`/v1/traces?limit=${limit}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -279,7 +323,7 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
}
const formData = new FormData();
formData.append('file', audioBlob, filename);
const res = await fetch(`${getBase()}/v1/speech/transcribe`, {
const res = await apiFetch(`/v1/speech/transcribe`, {
method: 'POST',
body: formData,
});
@@ -295,7 +339,7 @@ export async function fetchSpeechHealth(): Promise<SpeechHealth> {
return { available: false };
}
}
const res = await fetch(`${getBase()}/v1/speech/health`);
const res = await apiFetch(`/v1/speech/health`);
if (!res.ok) return { available: false };
return res.json();
}
@@ -379,14 +423,14 @@ export interface AgentMessage {
}
export async function fetchManagedAgents(): Promise<ManagedAgent[]> {
const res = await fetch(`${getBase()}/v1/managed-agents`);
const res = await apiFetch(`/v1/managed-agents`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.agents || [];
}
export async function fetchManagedAgent(agentId: string): Promise<ManagedAgent> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`);
const res = await apiFetch(`/v1/managed-agents/${agentId}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -397,7 +441,7 @@ export async function createManagedAgent(body: {
template_id?: string;
config?: Record<string, unknown>;
}): Promise<ManagedAgent> {
const res = await fetch(`${getBase()}/v1/managed-agents`, {
const res = await apiFetch(`/v1/managed-agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -410,7 +454,7 @@ export async function updateManagedAgent(
agentId: string,
body: Partial<{ name: string; agent_type: string; config: Record<string, unknown> }>,
): Promise<ManagedAgent> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, {
const res = await apiFetch(`/v1/managed-agents/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -420,29 +464,29 @@ export async function updateManagedAgent(
}
export async function deleteManagedAgent(agentId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, { method: 'DELETE' });
const res = await apiFetch(`/v1/managed-agents/${agentId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function pauseManagedAgent(agentId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/pause`, { method: 'POST' });
const res = await apiFetch(`/v1/managed-agents/${agentId}/pause`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function resumeManagedAgent(agentId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/resume`, { method: 'POST' });
const res = await apiFetch(`/v1/managed-agents/${agentId}/resume`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function fetchAgentTasks(agentId: string): Promise<AgentTask[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.tasks || [];
}
export async function createAgentTask(agentId: string, description: string): Promise<AgentTask> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`, {
const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
@@ -452,7 +496,7 @@ export async function createAgentTask(agentId: string, description: string): Pro
}
export async function fetchAgentChannels(agentId: string): Promise<ChannelBinding[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/channels`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/channels`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.bindings || [];
@@ -496,7 +540,7 @@ export async function sendblueVerify(
apiKeyId: string,
apiSecretKey: string,
): Promise<{ valid: boolean; numbers: string[]; raw: unknown }> {
const res = await fetch(`${getBase()}/v1/channels/sendblue/verify`, {
const res = await apiFetch(`/v1/channels/sendblue/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key_id: apiKeyId, api_secret_key: apiSecretKey }),
@@ -513,7 +557,7 @@ export async function sendblueRegisterWebhook(
apiSecretKey: string,
webhookUrl: string,
): Promise<{ registered: boolean; status: number }> {
const res = await fetch(`${getBase()}/v1/channels/sendblue/register-webhook`, {
const res = await apiFetch(`/v1/channels/sendblue/register-webhook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -535,7 +579,7 @@ export async function sendblueTest(
fromNumber: string,
toNumber: string,
): Promise<{ sent: boolean; status: number }> {
const res = await fetch(`${getBase()}/v1/channels/sendblue/test`, {
const res = await apiFetch(`/v1/channels/sendblue/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -553,20 +597,20 @@ export async function sendblueTest(
}
export async function sendblueHealth(): Promise<{ channel_connected: boolean; bridge_wired: boolean; ready: boolean }> {
const res = await fetch(`${getBase()}/v1/channels/sendblue/health`);
const res = await apiFetch(`/v1/channels/sendblue/health`);
if (!res.ok) return { channel_connected: false, bridge_wired: false, ready: false };
return res.json();
}
export async function fetchTemplates(): Promise<AgentTemplate[]> {
const res = await fetch(`${getBase()}/v1/templates`);
const res = await apiFetch(`/v1/templates`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.templates || [];
}
export async function runManagedAgent(agentId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/run`, { method: 'POST' });
const res = await apiFetch(`/v1/managed-agents/${agentId}/run`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(body.detail || `Failed: ${res.status}`);
@@ -574,7 +618,7 @@ export async function runManagedAgent(agentId: string): Promise<void> {
}
export async function recoverManagedAgent(agentId: string): Promise<{ recovered: boolean; checkpoint: unknown }> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/recover`, { method: 'POST' });
const res = await apiFetch(`/v1/managed-agents/${agentId}/recover`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(body.detail || `Failed: ${res.status}`);
@@ -589,7 +633,7 @@ export async function fetchAgentState(agentId: string): Promise<{
messages: AgentMessage[];
checkpoint: unknown;
}> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/state`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/state`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -618,7 +662,7 @@ export async function sendAgentMessage(
onDone?: (fullContent: string, usage?: Record<string, number>, telemetry?: Record<string, unknown>) => void;
},
): Promise<AgentMessage> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, {
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mode, stream: true }),
@@ -733,7 +777,7 @@ export async function sendAgentMessage(
* the `/v1/agents/events` WebSocket and the resulting trace.
*/
export async function askAgent(agentId: string, content: string): Promise<AgentMessage> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, {
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mode: 'immediate', stream: false }),
@@ -743,14 +787,14 @@ export async function askAgent(agentId: string, content: string): Promise<AgentM
}
export async function fetchAgentMessages(agentId: string): Promise<AgentMessage[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.messages || [];
}
export async function fetchErrorAgents(): Promise<ManagedAgent[]> {
const res = await fetch(`${getBase()}/v1/agents/errors`);
const res = await apiFetch(`/v1/agents/errors`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.agents || [];
@@ -790,7 +834,7 @@ export interface ToolInfo {
}
export async function fetchAvailableTools(): Promise<ToolInfo[]> {
const res = await fetch(`${getBase()}/v1/tools`);
const res = await apiFetch(`/v1/tools`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.tools || [];
@@ -800,7 +844,7 @@ export async function saveToolCredentials(
toolName: string,
credentials: Record<string, string>,
): Promise<void> {
const res = await fetch(`${getBase()}/v1/tools/${toolName}/credentials`, {
const res = await apiFetch(`/v1/tools/${toolName}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
@@ -824,26 +868,26 @@ export interface AgentTraceDetail {
}
export async function fetchLearningLog(agentId: string): Promise<LearningLogEntry[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/learning`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.learning_log || [];
}
export async function triggerLearning(agentId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' });
const res = await apiFetch(`/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function fetchAgentTraces(agentId: string, limit = 20): Promise<AgentTrace[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces?limit=${limit}`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/traces?limit=${limit}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.traces || [];
}
export async function fetchAgentTrace(agentId: string, traceId: string): Promise<AgentTraceDetail> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces/${traceId}`);
const res = await apiFetch(`/v1/managed-agents/${agentId}/traces/${traceId}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -911,13 +955,13 @@ export interface MemoryConfig {
}
export async function getMemoryStats(): Promise<MemoryStats> {
const res = await fetch(`${getBase()}/v1/memory/stats`);
const res = await apiFetch(`/v1/memory/stats`);
if (!res.ok) throw new Error('Failed to fetch memory stats');
return res.json();
}
export async function searchMemory(query: string, topK: number = 5): Promise<MemorySearchResult[]> {
const res = await fetch(`${getBase()}/v1/memory/search`, {
const res = await apiFetch(`/v1/memory/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, top_k: topK }),
@@ -928,7 +972,7 @@ export async function searchMemory(query: string, topK: number = 5): Promise<Mem
}
export async function storeMemory(content: string, metadata?: Record<string, unknown>): Promise<void> {
const res = await fetch(`${getBase()}/v1/memory/store`, {
const res = await apiFetch(`/v1/memory/store`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, metadata }),
@@ -937,7 +981,7 @@ export async function storeMemory(content: string, metadata?: Record<string, unk
}
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number }> {
const res = await fetch(`${getBase()}/v1/memory/index`, {
const res = await apiFetch(`/v1/memory/index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
@@ -947,7 +991,7 @@ export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: n
}
export async function getMemoryConfig(): Promise<MemoryConfig> {
const res = await fetch(`${getBase()}/v1/memory/config`);
const res = await apiFetch(`/v1/memory/config`);
if (!res.ok) throw new Error('Failed to fetch memory config');
return res.json();
}
@@ -969,19 +1013,19 @@ export interface PendingApproval {
}
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
const res = await fetch(`${getBase()}/v1/approvals/pending`);
const res = await apiFetch(`/v1/approvals/pending`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data = await res.json();
return data.actions || [];
}
export async function approveAction(actionId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' });
const res = await apiFetch(`/v1/approvals/${actionId}/approve`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function denyAction(actionId: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' });
const res = await apiFetch(`/v1/approvals/${actionId}/deny`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
+5
View File
@@ -70,6 +70,10 @@ export type ThemeMode = 'light' | 'dark' | 'system';
interface Settings {
theme: ThemeMode;
apiUrl: string;
// Local server API key (OPENJARVIS_API_KEY). Sent as a Bearer token on
// /v1 + /api requests so a key-protected `jarvis serve` doesn't 401 the
// frontend (#266). Empty = no auth header (keyless local default).
apiKey: string;
fontSize: 'small' | 'default' | 'large';
defaultModel: string;
defaultAgent: string;
@@ -82,6 +86,7 @@ function loadSettings(): Settings {
const defaults: Settings = {
theme: 'system',
apiUrl: '',
apiKey: '',
fontSize: 'default',
defaultModel: '',
defaultAgent: '',
+15
View File
@@ -345,6 +345,21 @@ export function SettingsPage() {
}}
/>
</SettingRow>
<SettingRow label="API key" description="Required only if the server was started with an API key">
<input
type="password"
value={settings.apiKey}
onChange={(e) => { updateSettings({ apiKey: e.target.value }); showSaved(); }}
placeholder="OPENJARVIS_API_KEY"
autoComplete="off"
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
style={{
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
/>
</SettingRow>
</Section>
{/* Inference source */}