@karmaniverous/jeeves-server 3.0.0-0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuildinfo +1 -1
- package/CHANGELOG.md +18 -1
- package/dist/src/config/index.js +11 -2
- package/dist/src/config/loadConfig.test.js +28 -7
- package/dist/src/config/resolve.js +87 -7
- package/dist/src/config/resolve.test.js +37 -7
- package/dist/src/config/schema.js +57 -1
- package/dist/src/routes/api/fileContent.js +5 -1
- package/dist/src/routes/api/middleware.js +2 -0
- package/dist/src/routes/api/sharing.js +1 -1
- package/dist/src/routes/api/status.js +22 -18
- package/dist/src/routes/api/status.test.js +6 -13
- package/dist/src/routes/auth.js +1 -1
- package/dist/src/routes/keys.js +4 -4
- package/dist/src/services/deepShareLinks.js +2 -2
- package/dist/src/services/diagramCache.js +2 -2
- package/dist/src/services/embeddedDiagrams.js +1 -1
- package/dist/src/services/export.js +2 -2
- package/dist/src/util/state.js +2 -2
- package/knip.json +30 -0
- package/package.json +1 -1
- package/src/auth/google.ts +2 -2
- package/src/auth/resolve.ts +1 -1
- package/src/auth/session.ts +1 -1
- package/src/config/index.ts +12 -2
- package/src/config/loadConfig.test.ts +36 -7
- package/src/config/resolve.test.ts +53 -11
- package/src/config/resolve.ts +115 -7
- package/src/config/schema.ts +63 -1
- package/src/routes/api/fileContent.ts +8 -1
- package/src/routes/api/middleware.ts +1 -0
- package/src/routes/api/sharing.ts +1 -1
- package/src/routes/api/status.test.ts +11 -15
- package/src/routes/api/status.ts +21 -18
- package/src/routes/auth.ts +1 -1
- package/src/routes/keys.ts +4 -4
- package/src/services/deepShareLinks.ts +2 -2
- package/src/services/diagramCache.ts +2 -2
- package/src/services/embeddedDiagrams.ts +1 -1
- package/src/services/export.ts +3 -3
- package/src/util/breadcrumbs.ts +1 -1
- package/src/util/state.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
|
4
4
|
|
|
5
|
-
#### [v3.0.0
|
|
5
|
+
#### [v3.0.0](https://github.com/karmaniverous/jeeves-server/compare/v2.9.3...v3.0.0)
|
|
6
6
|
|
|
7
|
+
- feat: named access scopes [`#84`](https://github.com/karmaniverous/jeeves-server/pull/84)
|
|
8
|
+
- fix: plugin auth chain, status endpoint improvements [`#83`](https://github.com/karmaniverous/jeeves-server/pull/83)
|
|
9
|
+
- fix: resetConfig should reload, not clear [`#82`](https://github.com/karmaniverous/jeeves-server/pull/82)
|
|
7
10
|
- chore: integrate client as npm workspace member [`#81`](https://github.com/karmaniverous/jeeves-server/pull/81)
|
|
8
11
|
- fix: force white background in panzoom fullscreen (dark mode) [`#80`](https://github.com/karmaniverous/jeeves-server/pull/80)
|
|
9
12
|
- chore: make both packages releasable [`#79`](https://github.com/karmaniverous/jeeves-server/pull/79)
|
|
@@ -31,9 +34,11 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
31
34
|
- refactor: monorepo scaffolding (Phase 1, Step 1) [`15cf2ba`](https://github.com/karmaniverous/jeeves-server/commit/15cf2baa7079057f095c973932d233dda2013659)
|
|
32
35
|
- feat: migrate config from jiti/TS to cosmiconfig/JSON [`adfba33`](https://github.com/karmaniverous/jeeves-server/commit/adfba3387219d49f4675a6c6579253d563788394)
|
|
33
36
|
- fix: resolve all client ESLint errors and warnings [`768541a`](https://github.com/karmaniverous/jeeves-server/commit/768541a71644c3ddcbc4a50bc6ed7a42a1c6d9a5)
|
|
37
|
+
- chore: release @karmaniverous/jeeves-server v3.0.0-0 [`07a6e47`](https://github.com/karmaniverous/jeeves-server/commit/07a6e475a08ccd485765bfaa535f9d63cb8bb42a)
|
|
34
38
|
- chore: SOLID/DRY pass #3 + plugin test coverage [`7dcc4c3`](https://github.com/karmaniverous/jeeves-server/commit/7dcc4c3ad36a45a46dc99c8a1e749dfd2ec01e47)
|
|
35
39
|
- feat: add CLI commands (start, config validate/show, service) [`0437984`](https://github.com/karmaniverous/jeeves-server/commit/0437984e7f783604f9cbdd333db61f8d1af42961)
|
|
36
40
|
- fix: resolve knip unused files, dependencies, and exports [`964beba`](https://github.com/karmaniverous/jeeves-server/commit/964beba273f99be8d3302648c2dd442a3e3dcc07)
|
|
41
|
+
- chore: release @karmaniverous/jeeves-server-openclaw v0.1.0-0 [`b6c0f6d`](https://github.com/karmaniverous/jeeves-server/commit/b6c0f6db6ab8bbdcffb40affc00f9f573ca2caa5)
|
|
37
42
|
- fix: address all gap analysis findings [`1eb02ab`](https://github.com/karmaniverous/jeeves-server/commit/1eb02abf0634be38c687d4927a2360cd27c3aad8)
|
|
38
43
|
- npm audit fix [`3a144b4`](https://github.com/karmaniverous/jeeves-server/commit/3a144b4b07e1387cf40733486e60aeaf7a34d0a6)
|
|
39
44
|
- feat: add GET /api/link-info endpoint [`02ece39`](https://github.com/karmaniverous/jeeves-server/commit/02ece3960aa91861455034aa4bfe8850c0b0f363)
|
|
@@ -42,25 +47,37 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
42
47
|
- feat: add GET /api/status endpoint [`764c3a7`](https://github.com/karmaniverous/jeeves-server/commit/764c3a7313bd895032cc17bdc811808cdc8eb80e)
|
|
43
48
|
- chore: SOLID/DRY/test coverage pass [`e491bfb`](https://github.com/karmaniverous/jeeves-server/commit/e491bfbbce7fef13252c2488378b85e983a2207a)
|
|
44
49
|
- refactor: extract buildRuntimeConfig to resolve.ts (DRY) [`87bd749`](https://github.com/karmaniverous/jeeves-server/commit/87bd749c6827e3c95e2ff9996ab1b781cb316932)
|
|
50
|
+
- chore: add knip configs, remove dead exports, clean all code quality checks [`2a81072`](https://github.com/karmaniverous/jeeves-server/commit/2a81072cc5341047b2ef40333405a8cc9760dab4)
|
|
45
51
|
- fix: address Gemini code review feedback across PRs #65-#76 [`2fef919`](https://github.com/karmaniverous/jeeves-server/commit/2fef9192b00c80605c9cca348ea7feff5af2602a)
|
|
52
|
+
- fix: make resetConfig reload runtime config [`79e8602`](https://github.com/karmaniverous/jeeves-server/commit/79e8602b4a8ee13c4de94bb3d262dd8bdb7cd2c8)
|
|
46
53
|
- refactor: extract shared renderMarkdownContent pipeline [`244dddf`](https://github.com/karmaniverous/jeeves-server/commit/244dddf83da846d2863e776ae78a029610532d2d)
|
|
47
54
|
- feat: schema-driven search facet filters (Step 10) [`c305c2a`](https://github.com/karmaniverous/jeeves-server/commit/c305c2ae261514411b6329bdc84052ee5c189759)
|
|
48
55
|
- feat: add scroll anchoring for async diagram renders [`e4bd972`](https://github.com/karmaniverous/jeeves-server/commit/e4bd97293dbb8a9a63d5cf3bceb6e0cbb7a26916)
|
|
49
56
|
- feat: document rendering pipeline (Phase 4, Steps 16-18) [`56095ee`](https://github.com/karmaniverous/jeeves-server/commit/56095ee8f7153509048f0b8af1ed73bb95aa5ae0)
|
|
50
57
|
- npm audit fix [`3810c0f`](https://github.com/karmaniverous/jeeves-server/commit/3810c0fad8067752c4513bcccdd721698c8b3c3b)
|
|
58
|
+
- lintfix [`00dc2b2`](https://github.com/karmaniverous/jeeves-server/commit/00dc2b2ec30ea2a4365d93b86c66330b831737a7)
|
|
51
59
|
- fix: resolve package.json path portably for version [`29d775c`](https://github.com/karmaniverous/jeeves-server/commit/29d775c318ee85003fd6be7f67303633e092a110)
|
|
52
60
|
- feat: add GET /api/search/facets proxy endpoint [`28fc200`](https://github.com/karmaniverous/jeeves-server/commit/28fc20084453b7bee28b4b9b23932a85a88567d0)
|
|
53
61
|
- ni [`e40deac`](https://github.com/karmaniverous/jeeves-server/commit/e40deacf8659a59328ceec50abb6f1f27041b5d8)
|
|
62
|
+
- lintfix [`38a9376`](https://github.com/karmaniverous/jeeves-server/commit/38a937631ab2ac3c22240857f69b36d5d9570665)
|
|
54
63
|
- chore: migrate default port to 1934 [`4614a5f`](https://github.com/karmaniverous/jeeves-server/commit/4614a5f4a5a1fd1d75eb730adbb8caa4a7dab7ff)
|
|
55
64
|
- chore: add tsdoc.json to both package roots [`9ccf217`](https://github.com/karmaniverous/jeeves-server/commit/9ccf2171347ed4deabe617b16b236edba5b5bc75)
|
|
56
65
|
- chore: add tsdoc.json to both package roots [`173cf94`](https://github.com/karmaniverous/jeeves-server/commit/173cf94061cfabd3c21796cb10ad0300c1e14e2b)
|
|
66
|
+
- chore: release @karmaniverous/jeeves-server v3.0.0-1 [`db96bc1`](https://github.com/karmaniverous/jeeves-server/commit/db96bc140b9cbca7ad52ddd562a805ac74ca67aa)
|
|
57
67
|
- fix: set rootDir and update start script path for monorepo layout [`a2fd77c`](https://github.com/karmaniverous/jeeves-server/commit/a2fd77cb067fbae733a64e9bc04c0d9159910f90)
|
|
58
68
|
- fix: resolve TS2352 warnings in openclaw test mocks [`88950e2`](https://github.com/karmaniverous/jeeves-server/commit/88950e23bbd206e40a7bc25ea35c64d7609af281)
|
|
59
69
|
- fix: CI failures and SvgViewer panzoom re-init bug [`7392712`](https://github.com/karmaniverous/jeeves-server/commit/73927128276341a37c362c3143334bd8bb416d09)
|
|
70
|
+
- fix: resolve remaining lint errors (type annotations, unused params, unnecessary conditionals) [`e7714ae`](https://github.com/karmaniverous/jeeves-server/commit/e7714ae451a72a1dd9881d85c964d6d8aec17574)
|
|
71
|
+
- fix: remove unnecessary auth from /api/status calls (endpoint is public) [`f651fe7`](https://github.com/karmaniverous/jeeves-server/commit/f651fe7bff4320eb28b5039278bcdcd032cac6d5)
|
|
60
72
|
- fix: update linux-compat CI for monorepo paths [`22dd30f`](https://github.com/karmaniverous/jeeves-server/commit/22dd30ffe5543042287a299523e5b1125c86381e)
|
|
73
|
+
- chore: release @karmaniverous/jeeves-server-openclaw v0.1.0-1 [`d08e571`](https://github.com/karmaniverous/jeeves-server/commit/d08e571d58bb6387e69441dc06511d92fb743c15)
|
|
61
74
|
- chore: eliminate all lint warnings [`ed12c86`](https://github.com/karmaniverous/jeeves-server/commit/ed12c868546a3f14f7d809d7378235ac91415be7)
|
|
62
75
|
- fix: CI rimraf resolution and remove redundant client steps [`757c45c`](https://github.com/karmaniverous/jeeves-server/commit/757c45cf429e7c70358442e353922f7999d74640)
|
|
76
|
+
- chore: release @karmaniverous/jeeves-server-openclaw v0.1.0 [`bbeac17`](https://github.com/karmaniverous/jeeves-server/commit/bbeac17333ebad423b0fd8c2bcd310bbb0b63b27)
|
|
63
77
|
- publishconfig public access [`8e4358c`](https://github.com/karmaniverous/jeeves-server/commit/8e4358cc43dc2f131840047d34f2644eb6293fe2)
|
|
78
|
+
- fix: add pattern to StatusResponse events type [`f64e6af`](https://github.com/karmaniverous/jeeves-server/commit/f64e6af06ce51a54dee4bf69dd0e6fbae79dc683)
|
|
79
|
+
- fix: normalize path for watcher render (Windows backslash + uppercase drive) [`f6c229d`](https://github.com/karmaniverous/jeeves-server/commit/f6c229d7f4ac7ec11099c884b75e6a90cc0cdf59)
|
|
80
|
+
- zero version [`2c0db75`](https://github.com/karmaniverous/jeeves-server/commit/2c0db75d547d812fa638e15f2da732a7b91ad69c)
|
|
64
81
|
- merge: incorporate main (PR #77 gap-analysis) [`845268d`](https://github.com/karmaniverous/jeeves-server/commit/845268de8d3b990187f078f0135053d52c311009)
|
|
65
82
|
- fix: add missing return-await in facets handler [`b08d4a3`](https://github.com/karmaniverous/jeeves-server/commit/b08d4a34b1bc2a648edf4e9aeb23d4db0be45afc)
|
|
66
83
|
- fix: remove unused parameter in linkInfo test [`36fca9a`](https://github.com/karmaniverous/jeeves-server/commit/36fca9ad8e051a47902ce75dab360ac98c169321)
|
package/dist/src/config/index.js
CHANGED
|
@@ -58,6 +58,7 @@ export async function loadConfig(configPath) {
|
|
|
58
58
|
return buildRuntimeConfig(parseResult.data, rootDir, result.filepath);
|
|
59
59
|
}
|
|
60
60
|
let configInstance = null;
|
|
61
|
+
let lastConfigPath;
|
|
61
62
|
/**
|
|
62
63
|
* Check if the config singleton has been initialized.
|
|
63
64
|
*/
|
|
@@ -79,12 +80,20 @@ export function getConfig() {
|
|
|
79
80
|
* @param configPath - Optional explicit path to a config file.
|
|
80
81
|
*/
|
|
81
82
|
export async function initConfig(configPath) {
|
|
83
|
+
lastConfigPath = configPath;
|
|
82
84
|
configInstance = await loadConfig(configPath);
|
|
83
85
|
return configInstance;
|
|
84
86
|
}
|
|
85
87
|
/**
|
|
86
|
-
*
|
|
88
|
+
* Reload the config singleton from the last-used config path.
|
|
89
|
+
* Call after mutating state that affects resolved config (e.g., key rotation).
|
|
87
90
|
*/
|
|
88
|
-
export function resetConfig() {
|
|
91
|
+
export async function resetConfig() {
|
|
92
|
+
configInstance = await loadConfig(lastConfigPath);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Clear the config singleton (for testing only).
|
|
96
|
+
*/
|
|
97
|
+
export function clearConfig() {
|
|
89
98
|
configInstance = null;
|
|
90
99
|
}
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { getConfig, initConfig, loadConfig
|
|
5
|
+
import { clearConfig, getConfig, initConfig, loadConfig } from './index.js';
|
|
6
6
|
const VALID_CONFIG = {
|
|
7
7
|
port: 9999,
|
|
8
8
|
chromePath: '/usr/bin/chromium',
|
|
@@ -22,11 +22,11 @@ describe('loadConfig', () => {
|
|
|
22
22
|
let tmpDir;
|
|
23
23
|
beforeEach(() => {
|
|
24
24
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-config-'));
|
|
25
|
-
|
|
25
|
+
clearConfig();
|
|
26
26
|
});
|
|
27
27
|
afterEach(() => {
|
|
28
28
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
-
|
|
29
|
+
clearConfig();
|
|
30
30
|
});
|
|
31
31
|
it('loads a valid JSON config file', async () => {
|
|
32
32
|
const configPath = writeConfig(tmpDir, VALID_CONFIG);
|
|
@@ -98,16 +98,37 @@ describe('loadConfig', () => {
|
|
|
98
98
|
const config = await loadConfig(configPath);
|
|
99
99
|
expect(config.resolvedKeys.find((k) => k.name === '_plugin')?.seed).toBe('c'.repeat(64));
|
|
100
100
|
});
|
|
101
|
+
it('rejects undefined named scope references', async () => {
|
|
102
|
+
const configPath = writeConfig(tmpDir, {
|
|
103
|
+
...VALID_CONFIG,
|
|
104
|
+
scopes: { restricted: { allow: ['/**'], deny: ['/secret'] } },
|
|
105
|
+
insiders: {
|
|
106
|
+
'a@example.com': { scopes: 'restricted' },
|
|
107
|
+
'b@example.com': { scopes: 'missing' },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
await expect(loadConfig(configPath)).rejects.toThrow('Scope "missing" is not defined');
|
|
111
|
+
});
|
|
112
|
+
it('does not treat path globs as named scope references', async () => {
|
|
113
|
+
const configPath = writeConfig(tmpDir, {
|
|
114
|
+
...VALID_CONFIG,
|
|
115
|
+
insiders: {
|
|
116
|
+
'a@example.com': { scopes: ['/docs/**'] },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const config = await loadConfig(configPath);
|
|
120
|
+
expect(config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes).toEqual({ allow: ['/docs/**'], deny: [] });
|
|
121
|
+
});
|
|
101
122
|
});
|
|
102
123
|
describe('config singleton', () => {
|
|
103
124
|
let tmpDir;
|
|
104
125
|
beforeEach(() => {
|
|
105
126
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-config-'));
|
|
106
|
-
|
|
127
|
+
clearConfig();
|
|
107
128
|
});
|
|
108
129
|
afterEach(() => {
|
|
109
130
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
110
|
-
|
|
131
|
+
clearConfig();
|
|
111
132
|
});
|
|
112
133
|
it('throws if getConfig called before initConfig', () => {
|
|
113
134
|
expect(() => getConfig()).toThrow('Config not initialized');
|
|
@@ -118,10 +139,10 @@ describe('config singleton', () => {
|
|
|
118
139
|
const config = getConfig();
|
|
119
140
|
expect(config.port).toBe(9999);
|
|
120
141
|
});
|
|
121
|
-
it('
|
|
142
|
+
it('clearConfig clears the singleton', async () => {
|
|
122
143
|
const configPath = writeConfig(tmpDir, VALID_CONFIG);
|
|
123
144
|
await initConfig(configPath);
|
|
124
|
-
|
|
145
|
+
clearConfig();
|
|
125
146
|
expect(() => getConfig()).toThrow('Config not initialized');
|
|
126
147
|
});
|
|
127
148
|
});
|
|
@@ -30,21 +30,86 @@ export function normalizeScopes(raw) {
|
|
|
30
30
|
}
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve named scope references (string or string[]) against the top-level scopes map,
|
|
35
|
+
* optionally merging explicit allow/deny overrides.
|
|
36
|
+
*
|
|
37
|
+
* Returns null if no scopes are provided.
|
|
38
|
+
* Falls back to normalizeScopes() for legacy inline scope formats.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveNamedScopes(named, rawScopes, overrides) {
|
|
41
|
+
if (rawScopes === undefined || rawScopes === null) {
|
|
42
|
+
if (overrides?.allow?.length || overrides?.deny?.length) {
|
|
43
|
+
return {
|
|
44
|
+
allow: overrides.allow ?? ['/**'],
|
|
45
|
+
deny: overrides.deny ?? [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// Determine whether rawScopes is a named reference (identifier(s))
|
|
51
|
+
const isName = (v) => /^[A-Za-z0-9_-]+$/.test(v);
|
|
52
|
+
const refs = typeof rawScopes === 'string'
|
|
53
|
+
? isName(rawScopes)
|
|
54
|
+
? [rawScopes]
|
|
55
|
+
: null
|
|
56
|
+
: Array.isArray(rawScopes) &&
|
|
57
|
+
rawScopes.every((v) => typeof v === 'string')
|
|
58
|
+
? rawScopes.filter((v) => isName(v))
|
|
59
|
+
: null;
|
|
60
|
+
if (refs && refs.length > 0) {
|
|
61
|
+
const allow = [];
|
|
62
|
+
const deny = [];
|
|
63
|
+
for (const ref of refs) {
|
|
64
|
+
const scope = named[ref];
|
|
65
|
+
if (!scope)
|
|
66
|
+
continue; // schema should have validated
|
|
67
|
+
if (scope.allow)
|
|
68
|
+
allow.push(...scope.allow);
|
|
69
|
+
if (scope.deny)
|
|
70
|
+
deny.push(...scope.deny);
|
|
71
|
+
}
|
|
72
|
+
if (overrides?.allow)
|
|
73
|
+
allow.push(...overrides.allow);
|
|
74
|
+
if (overrides?.deny)
|
|
75
|
+
deny.push(...overrides.deny);
|
|
76
|
+
return {
|
|
77
|
+
allow: allow.length > 0 ? allow : ['/**'],
|
|
78
|
+
deny,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Legacy inline scopes
|
|
82
|
+
const normalized = normalizeScopes(rawScopes);
|
|
83
|
+
if (!normalized)
|
|
84
|
+
return null;
|
|
85
|
+
if (overrides?.allow)
|
|
86
|
+
normalized.allow.push(...overrides.allow);
|
|
87
|
+
if (overrides?.deny)
|
|
88
|
+
normalized.deny.push(...overrides.deny);
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
33
91
|
/**
|
|
34
92
|
* Resolve raw key entries to ResolvedKey[].
|
|
35
93
|
*/
|
|
36
|
-
export function resolveKeys(keys) {
|
|
94
|
+
export function resolveKeys(keys, namedScopes) {
|
|
37
95
|
return Object.entries(keys).map(([name, entry]) => {
|
|
38
96
|
if (typeof entry === 'string') {
|
|
39
97
|
return { name, seed: entry, scopes: null };
|
|
40
98
|
}
|
|
41
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
seed: entry.key,
|
|
102
|
+
scopes: resolveNamedScopes(namedScopes, entry.scopes, {
|
|
103
|
+
allow: entry.allow,
|
|
104
|
+
deny: entry.deny,
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
42
107
|
});
|
|
43
108
|
}
|
|
44
109
|
/**
|
|
45
110
|
* Resolve insider entries by merging config (identity + scopes) with state (keys).
|
|
46
111
|
*/
|
|
47
|
-
export function resolveInsiders(insiders, stateFile) {
|
|
112
|
+
export function resolveInsiders(insiders, namedScopes, stateFile) {
|
|
48
113
|
let serverState = {};
|
|
49
114
|
try {
|
|
50
115
|
if (fs.existsSync(stateFile)) {
|
|
@@ -56,7 +121,10 @@ export function resolveInsiders(insiders, stateFile) {
|
|
|
56
121
|
}
|
|
57
122
|
return Object.entries(insiders).map(([rawEmail, entry]) => {
|
|
58
123
|
const email = rawEmail.toLowerCase();
|
|
59
|
-
const scopes =
|
|
124
|
+
const scopes = resolveNamedScopes(namedScopes, entry.scopes, {
|
|
125
|
+
allow: entry.allow,
|
|
126
|
+
deny: entry.deny,
|
|
127
|
+
});
|
|
60
128
|
const stateKey = serverState.insiderKeys?.[email];
|
|
61
129
|
return {
|
|
62
130
|
email,
|
|
@@ -101,8 +169,8 @@ export function deriveInternalKey(resolvedKeys) {
|
|
|
101
169
|
*/
|
|
102
170
|
export function buildRuntimeConfig(config, rootDir, configPath) {
|
|
103
171
|
const stateFile = path.join(rootDir, 'state.json');
|
|
104
|
-
const resolvedKeys = resolveKeys(config.keys);
|
|
105
|
-
const resolvedInsiders = resolveInsiders(config.insiders, stateFile);
|
|
172
|
+
const resolvedKeys = resolveKeys(config.keys, config.scopes);
|
|
173
|
+
const resolvedInsiders = resolveInsiders(config.insiders, config.scopes, stateFile);
|
|
106
174
|
return {
|
|
107
175
|
port: config.port,
|
|
108
176
|
eventTimeoutMs: config.eventTimeoutMs,
|
|
@@ -113,7 +181,19 @@ export function buildRuntimeConfig(config, rootDir, configPath) {
|
|
|
113
181
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
114
182
|
mermaidCliPath: config.mermaidCliPath,
|
|
115
183
|
plantuml: resolvePlantuml(config.plantuml, rootDir),
|
|
116
|
-
outsiderPolicy:
|
|
184
|
+
outsiderPolicy: resolveNamedScopes(config.scopes, config.outsiderPolicy &&
|
|
185
|
+
typeof config.outsiderPolicy === 'object' &&
|
|
186
|
+
!Array.isArray(config.outsiderPolicy)
|
|
187
|
+
? (config.outsiderPolicy.scopes ??
|
|
188
|
+
config.outsiderPolicy)
|
|
189
|
+
: config.outsiderPolicy, config.outsiderPolicy &&
|
|
190
|
+
typeof config.outsiderPolicy === 'object' &&
|
|
191
|
+
!Array.isArray(config.outsiderPolicy)
|
|
192
|
+
? {
|
|
193
|
+
allow: config.outsiderPolicy.allow,
|
|
194
|
+
deny: config.outsiderPolicy.deny,
|
|
195
|
+
}
|
|
196
|
+
: undefined) ?? null,
|
|
117
197
|
events: config.events,
|
|
118
198
|
authModes: config.auth.modes,
|
|
119
199
|
resolvedKeys,
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { buildRuntimeConfig, deriveInternalKey, normalizeScopes, resolveInsiders, resolveKeys, resolvePlantuml, } from './resolve.js';
|
|
5
|
+
import { buildRuntimeConfig, deriveInternalKey, normalizeScopes, resolveInsiders, resolveKeys, resolveNamedScopes, resolvePlantuml, } from './resolve.js';
|
|
6
6
|
describe('normalizeScopes', () => {
|
|
7
7
|
it('returns null for undefined', () => {
|
|
8
8
|
expect(normalizeScopes(undefined)).toBe(null);
|
|
@@ -32,7 +32,7 @@ describe('normalizeScopes', () => {
|
|
|
32
32
|
});
|
|
33
33
|
describe('resolveKeys', () => {
|
|
34
34
|
it('handles string key entries', () => {
|
|
35
|
-
const result = resolveKeys({ primary: 'seed123' });
|
|
35
|
+
const result = resolveKeys({ primary: 'seed123' }, {});
|
|
36
36
|
expect(result).toEqual([
|
|
37
37
|
{ name: 'primary', seed: 'seed123', scopes: null },
|
|
38
38
|
]);
|
|
@@ -40,7 +40,7 @@ describe('resolveKeys', () => {
|
|
|
40
40
|
it('handles object key entries with scopes', () => {
|
|
41
41
|
const result = resolveKeys({
|
|
42
42
|
scoped: { key: 'seed456', scopes: ['/docs'] },
|
|
43
|
-
});
|
|
43
|
+
}, {});
|
|
44
44
|
expect(result[0].name).toBe('scoped');
|
|
45
45
|
expect(result[0].seed).toBe('seed456');
|
|
46
46
|
expect(result[0].scopes).toEqual({ allow: ['/docs'], deny: [] });
|
|
@@ -49,7 +49,7 @@ describe('resolveKeys', () => {
|
|
|
49
49
|
const result = resolveKeys({
|
|
50
50
|
plain: 'abc',
|
|
51
51
|
complex: { key: 'def', scopes: { allow: ['/x'], deny: ['/y'] } },
|
|
52
|
-
});
|
|
52
|
+
}, {});
|
|
53
53
|
expect(result).toHaveLength(2);
|
|
54
54
|
expect(result[0].scopes).toBe(null);
|
|
55
55
|
expect(result[1].scopes).toEqual({ allow: ['/x'], deny: ['/y'] });
|
|
@@ -65,7 +65,7 @@ describe('resolveInsiders', () => {
|
|
|
65
65
|
});
|
|
66
66
|
it('normalizes email to lowercase', () => {
|
|
67
67
|
const stateFile = path.join(tmpDir, 'state.json');
|
|
68
|
-
const result = resolveInsiders({ 'Test@Example.COM': {} }, stateFile);
|
|
68
|
+
const result = resolveInsiders({ 'Test@Example.COM': {} }, {}, stateFile);
|
|
69
69
|
expect(result[0].email).toBe('test@example.com');
|
|
70
70
|
});
|
|
71
71
|
it('merges state keys when available', () => {
|
|
@@ -75,17 +75,46 @@ describe('resolveInsiders', () => {
|
|
|
75
75
|
'test@example.com': { seed: 'stateseed', createdAt: '2026-01-01' },
|
|
76
76
|
},
|
|
77
77
|
}));
|
|
78
|
-
const result = resolveInsiders({ 'test@example.com': {} }, stateFile);
|
|
78
|
+
const result = resolveInsiders({ 'test@example.com': {} }, {}, stateFile);
|
|
79
79
|
expect(result[0].seed).toBe('stateseed');
|
|
80
80
|
expect(result[0].keyCreatedAt).toBe('2026-01-01');
|
|
81
81
|
});
|
|
82
82
|
it('returns empty seed when no state exists', () => {
|
|
83
83
|
const stateFile = path.join(tmpDir, 'state.json');
|
|
84
|
-
const result = resolveInsiders({ 'new@example.com': {} }, stateFile);
|
|
84
|
+
const result = resolveInsiders({ 'new@example.com': {} }, {}, stateFile);
|
|
85
85
|
expect(result[0].seed).toBe('');
|
|
86
86
|
expect(result[0].keyCreatedAt).toBe(null);
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
describe('resolveNamedScopes', () => {
|
|
90
|
+
it('resolves a single named scope', () => {
|
|
91
|
+
const named = { restricted: { allow: ['/**'], deny: ['/secret'] } };
|
|
92
|
+
expect(resolveNamedScopes(named, 'restricted')).toEqual({
|
|
93
|
+
allow: ['/**'],
|
|
94
|
+
deny: ['/secret'],
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it('unions multiple named scopes and merges overrides', () => {
|
|
98
|
+
const named = {
|
|
99
|
+
restricted: { allow: ['/**'], deny: ['/secret'] },
|
|
100
|
+
noVc: { deny: ['/vc/**'] },
|
|
101
|
+
};
|
|
102
|
+
expect(resolveNamedScopes(named, ['restricted', 'noVc'], {
|
|
103
|
+
allow: ['/extra'],
|
|
104
|
+
deny: ['/more'],
|
|
105
|
+
})).toEqual({
|
|
106
|
+
allow: ['/**', '/extra'],
|
|
107
|
+
deny: ['/secret', '/vc/**', '/more'],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
it('falls back to legacy inline scope strings', () => {
|
|
111
|
+
const named = { restricted: { allow: ['/**'] } };
|
|
112
|
+
expect(resolveNamedScopes(named, '/docs')).toEqual({
|
|
113
|
+
allow: ['/docs'],
|
|
114
|
+
deny: [],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
89
118
|
describe('resolvePlantuml', () => {
|
|
90
119
|
it('appends community server as fallback', () => {
|
|
91
120
|
const result = resolvePlantuml();
|
|
@@ -133,6 +162,7 @@ describe('buildRuntimeConfig', () => {
|
|
|
133
162
|
eventLogPurgeMs: 2592000000,
|
|
134
163
|
maxZipSizeMb: 100,
|
|
135
164
|
chromePath: '/usr/bin/chrome',
|
|
165
|
+
scopes: {},
|
|
136
166
|
events: {},
|
|
137
167
|
auth: { modes: ['keys'] },
|
|
138
168
|
keys: { primary: 'a'.repeat(64) },
|
|
@@ -50,6 +50,10 @@ export const scopesSchema = z.union([
|
|
|
50
50
|
/** Insider entry (identity + scopes only; keys are in state.json) */
|
|
51
51
|
export const insiderEntrySchema = z.object({
|
|
52
52
|
scopes: scopesSchema.optional(),
|
|
53
|
+
/** Extra allow patterns merged on top of named scope references */
|
|
54
|
+
allow: z.array(z.string()).optional(),
|
|
55
|
+
/** Extra deny patterns merged on top of named scope references */
|
|
56
|
+
deny: z.array(z.string()).optional(),
|
|
53
57
|
});
|
|
54
58
|
/** Key entry â€" plain string (seed, no scopes) or object with key + optional scopes */
|
|
55
59
|
export const keyEntrySchema = z.union([
|
|
@@ -57,14 +61,38 @@ export const keyEntrySchema = z.union([
|
|
|
57
61
|
z.object({
|
|
58
62
|
key: z.string().min(1),
|
|
59
63
|
scopes: scopesSchema.optional(),
|
|
64
|
+
/** Extra allow patterns merged on top of named scope references */
|
|
65
|
+
allow: z.array(z.string()).optional(),
|
|
66
|
+
/** Extra deny patterns merged on top of named scope references */
|
|
67
|
+
deny: z.array(z.string()).optional(),
|
|
60
68
|
}),
|
|
61
69
|
]);
|
|
70
|
+
/** Helper: collect named scope references from a scopes field value.
|
|
71
|
+
*
|
|
72
|
+
* Backward-compat note: `scopes` has historically accepted path globs like "/**" or ["/a","/b"].
|
|
73
|
+
* We only treat values as *named scope references* if they look like identifiers
|
|
74
|
+
* (e.g. "restricted", "no-vc") rather than path globs.
|
|
75
|
+
*/
|
|
76
|
+
function getScopeRefs(scopes) {
|
|
77
|
+
const isName = (v) => /^[A-Za-z0-9_-]+$/.test(v);
|
|
78
|
+
if (typeof scopes === 'string')
|
|
79
|
+
return isName(scopes) ? [scopes] : [];
|
|
80
|
+
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
81
|
+
const strs = scopes.filter((v) => typeof v === 'string');
|
|
82
|
+
if (strs.length !== scopes.length)
|
|
83
|
+
return [];
|
|
84
|
+
return strs.filter((v) => isName(v));
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
62
88
|
/** Top-level Jeeves Server configuration */
|
|
63
89
|
export const jeevesConfigSchema = z
|
|
64
90
|
.object({
|
|
65
91
|
port: z.number().int().positive().default(1934),
|
|
66
92
|
chromePath: z.string().min(1),
|
|
67
93
|
auth: authSchema,
|
|
94
|
+
/** Named scope definitions, referenced by insiders/keys/outsiderPolicy */
|
|
95
|
+
scopes: z.record(z.string(), scopesObjectSchema).default({}),
|
|
68
96
|
insiders: z.record(z.email(), insiderEntrySchema).default({}),
|
|
69
97
|
keys: z.record(z.string(), keyEntrySchema).default({}),
|
|
70
98
|
events: z.record(z.string(), eventConfigSchema).default({}),
|
|
@@ -116,7 +144,7 @@ export const jeevesConfigSchema = z
|
|
|
116
144
|
* Uses the same allow/deny model as insider scopes.
|
|
117
145
|
* If omitted, all paths are shareable with outsiders.
|
|
118
146
|
*/
|
|
119
|
-
outsiderPolicy:
|
|
147
|
+
outsiderPolicy: scopesSchema.optional(),
|
|
120
148
|
})
|
|
121
149
|
.superRefine((config, ctx) => {
|
|
122
150
|
// Google auth mode requires google config + sessionSecret
|
|
@@ -156,4 +184,32 @@ export const jeevesConfigSchema = z
|
|
|
156
184
|
});
|
|
157
185
|
}
|
|
158
186
|
}
|
|
187
|
+
// Validate all scope name references resolve to the top-level scopes map
|
|
188
|
+
const scopeNames = new Set(Object.keys(config.scopes));
|
|
189
|
+
const validateRefs = (refs, refPath) => {
|
|
190
|
+
for (const ref of refs) {
|
|
191
|
+
if (!scopeNames.has(ref)) {
|
|
192
|
+
ctx.addIssue({
|
|
193
|
+
code: 'custom',
|
|
194
|
+
message: `Scope "${ref}" is not defined in the top-level scopes map`,
|
|
195
|
+
path: refPath,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
for (const [email, entry] of Object.entries(config.insiders)) {
|
|
201
|
+
const refs = getScopeRefs(entry.scopes);
|
|
202
|
+
if (refs.length > 0)
|
|
203
|
+
validateRefs(refs, ['insiders', email, 'scopes']);
|
|
204
|
+
}
|
|
205
|
+
for (const [name, entry] of Object.entries(config.keys)) {
|
|
206
|
+
if (typeof entry === 'object') {
|
|
207
|
+
const refs = getScopeRefs(entry.scopes);
|
|
208
|
+
if (refs.length > 0)
|
|
209
|
+
validateRefs(refs, ['keys', name, 'scopes']);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const outsiderRefs = getScopeRefs(config.outsiderPolicy);
|
|
213
|
+
if (outsiderRefs.length > 0)
|
|
214
|
+
validateRefs(outsiderRefs, ['outsiderPolicy']);
|
|
159
215
|
});
|
|
@@ -191,7 +191,11 @@ async function tryWatcherRender(fsPath) {
|
|
|
191
191
|
const res = await fetch(`${config.watcherUrl}/render`, {
|
|
192
192
|
method: 'POST',
|
|
193
193
|
headers: { 'Content-Type': 'application/json' },
|
|
194
|
-
body: JSON.stringify({
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
path: fsPath
|
|
196
|
+
.replace(/\\/g, '/')
|
|
197
|
+
.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ':'),
|
|
198
|
+
}),
|
|
195
199
|
signal: AbortSignal.timeout(5000),
|
|
196
200
|
});
|
|
197
201
|
if (!res.ok)
|
|
@@ -21,6 +21,8 @@ export function addAuthMiddleware(fastify) {
|
|
|
21
21
|
return;
|
|
22
22
|
if (request.url.startsWith('/api/diagram/'))
|
|
23
23
|
return;
|
|
24
|
+
if (request.url.startsWith('/api/status'))
|
|
25
|
+
return;
|
|
24
26
|
const config = getConfig();
|
|
25
27
|
// Utility endpoints handle their own scope checking
|
|
26
28
|
if (request.url.startsWith('/api/util/')) {
|
|
@@ -109,7 +109,7 @@ export const sharingRoutes = async (fastify) => {
|
|
|
109
109
|
const newSeed = crypto.randomBytes(32).toString('hex');
|
|
110
110
|
const now = new Date().toISOString();
|
|
111
111
|
setInsiderKey(insider.email, newSeed, now);
|
|
112
|
-
resetConfig();
|
|
112
|
+
await resetConfig();
|
|
113
113
|
return reply.send({ ok: true, keyCreatedAt: now });
|
|
114
114
|
});
|
|
115
115
|
// POST /api/util/share-for
|
|
@@ -8,28 +8,27 @@ import { getConfig } from '../../config/index.js';
|
|
|
8
8
|
import { packageVersion } from '../../util/packageVersion.js';
|
|
9
9
|
const startTime = Date.now();
|
|
10
10
|
async function checkService(url) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
// Try /status first (watcher), then /health (runner)
|
|
12
|
+
for (const endpoint of ['/status', '/health']) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${url}${endpoint}`, {
|
|
15
|
+
signal: AbortSignal.timeout(3000),
|
|
16
|
+
});
|
|
17
|
+
if (res.ok) {
|
|
18
|
+
const data = (await res.json());
|
|
19
|
+
return { url, reachable: true, version: data.version };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// try next endpoint
|
|
18
24
|
}
|
|
19
|
-
return { url, reachable: false };
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return { url, reachable: false };
|
|
23
25
|
}
|
|
26
|
+
return { url, reachable: false };
|
|
24
27
|
}
|
|
25
28
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
26
29
|
export const statusRoutes = async (fastify) => {
|
|
27
|
-
fastify.get('/api/status', async (
|
|
30
|
+
fastify.get('/api/status', async () => {
|
|
28
31
|
const config = getConfig();
|
|
29
|
-
// Only insiders get status
|
|
30
|
-
if (request.accessMode !== 'insider') {
|
|
31
|
-
return { error: 'Insider auth required' };
|
|
32
|
-
}
|
|
33
32
|
const [watcher, runner] = await Promise.all([
|
|
34
33
|
config.watcherUrl ? checkService(config.watcherUrl) : null,
|
|
35
34
|
config.runnerUrl ? checkService(config.runnerUrl) : null,
|
|
@@ -51,9 +50,14 @@ export const statusRoutes = async (fastify) => {
|
|
|
51
50
|
name,
|
|
52
51
|
cmd: schema.cmd,
|
|
53
52
|
})),
|
|
54
|
-
|
|
53
|
+
exports: {
|
|
54
|
+
documents: ['pdf', 'docx'],
|
|
55
|
+
directories: ['zip'],
|
|
56
|
+
diagrams: ['svg', 'png'],
|
|
57
|
+
chromeAvailable: Boolean(config.chromePath),
|
|
58
|
+
},
|
|
55
59
|
diagrams: {
|
|
56
|
-
mermaid: true,
|
|
60
|
+
mermaid: true,
|
|
57
61
|
plantuml: {
|
|
58
62
|
localJar: Boolean(config.plantuml.jarPath),
|
|
59
63
|
servers: config.plantuml.servers,
|
|
@@ -25,7 +25,7 @@ vi.mock('../../config/index.js', () => ({
|
|
|
25
25
|
// Must import AFTER mock
|
|
26
26
|
const { statusRoutes } = await import('./status.js');
|
|
27
27
|
describe('GET /api/status', () => {
|
|
28
|
-
it('returns structured status
|
|
28
|
+
it('returns structured status', async () => {
|
|
29
29
|
// Create a minimal Fastify-like test harness
|
|
30
30
|
const routes = {};
|
|
31
31
|
const fakeFastify = {
|
|
@@ -45,18 +45,11 @@ describe('GET /api/status', () => {
|
|
|
45
45
|
expect(status.auth.insiderCount).toBe(2);
|
|
46
46
|
expect(status.auth.keyCount).toBe(1);
|
|
47
47
|
expect(status.events).toHaveLength(2);
|
|
48
|
-
|
|
48
|
+
const exports = status.exports;
|
|
49
|
+
expect(exports.documents).toEqual(['pdf', 'docx']);
|
|
50
|
+
expect(exports.directories).toEqual(['zip']);
|
|
51
|
+
expect(exports.diagrams).toEqual(['svg', 'png']);
|
|
52
|
+
expect(exports.chromeAvailable).toBe(true);
|
|
49
53
|
expect(status.diagrams.mermaid).toBe(true);
|
|
50
54
|
});
|
|
51
|
-
it('rejects non-insider requests', async () => {
|
|
52
|
-
const routes = {};
|
|
53
|
-
const fakeFastify = {
|
|
54
|
-
get: (path, handler) => {
|
|
55
|
-
routes[path] = handler;
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
await statusRoutes(fakeFastify, {});
|
|
59
|
-
const result = await routes['/api/status']({ accessMode: 'outsider' });
|
|
60
|
-
expect(result).toEqual({ error: 'Insider auth required' });
|
|
61
|
-
});
|
|
62
55
|
});
|
package/dist/src/routes/auth.js
CHANGED
|
@@ -73,7 +73,7 @@ export const authRoute = async (fastify) => {
|
|
|
73
73
|
insider.seed = newSeed;
|
|
74
74
|
// Persist to state.json (mutable runtime state)
|
|
75
75
|
setInsiderKey(insider.email, newSeed, timestamp);
|
|
76
|
-
resetConfig(); // Reload to pick up new state
|
|
76
|
+
await resetConfig(); // Reload to pick up new state
|
|
77
77
|
}
|
|
78
78
|
// Set session cookie
|
|
79
79
|
const cookieValue = createSessionCookie(email, sessionSecret, userInfo.picture);
|