@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.
Files changed (42) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +18 -1
  3. package/dist/src/config/index.js +11 -2
  4. package/dist/src/config/loadConfig.test.js +28 -7
  5. package/dist/src/config/resolve.js +87 -7
  6. package/dist/src/config/resolve.test.js +37 -7
  7. package/dist/src/config/schema.js +57 -1
  8. package/dist/src/routes/api/fileContent.js +5 -1
  9. package/dist/src/routes/api/middleware.js +2 -0
  10. package/dist/src/routes/api/sharing.js +1 -1
  11. package/dist/src/routes/api/status.js +22 -18
  12. package/dist/src/routes/api/status.test.js +6 -13
  13. package/dist/src/routes/auth.js +1 -1
  14. package/dist/src/routes/keys.js +4 -4
  15. package/dist/src/services/deepShareLinks.js +2 -2
  16. package/dist/src/services/diagramCache.js +2 -2
  17. package/dist/src/services/embeddedDiagrams.js +1 -1
  18. package/dist/src/services/export.js +2 -2
  19. package/dist/src/util/state.js +2 -2
  20. package/knip.json +30 -0
  21. package/package.json +1 -1
  22. package/src/auth/google.ts +2 -2
  23. package/src/auth/resolve.ts +1 -1
  24. package/src/auth/session.ts +1 -1
  25. package/src/config/index.ts +12 -2
  26. package/src/config/loadConfig.test.ts +36 -7
  27. package/src/config/resolve.test.ts +53 -11
  28. package/src/config/resolve.ts +115 -7
  29. package/src/config/schema.ts +63 -1
  30. package/src/routes/api/fileContent.ts +8 -1
  31. package/src/routes/api/middleware.ts +1 -0
  32. package/src/routes/api/sharing.ts +1 -1
  33. package/src/routes/api/status.test.ts +11 -15
  34. package/src/routes/api/status.ts +21 -18
  35. package/src/routes/auth.ts +1 -1
  36. package/src/routes/keys.ts +4 -4
  37. package/src/services/deepShareLinks.ts +2 -2
  38. package/src/services/diagramCache.ts +2 -2
  39. package/src/services/embeddedDiagrams.ts +1 -1
  40. package/src/services/export.ts +3 -3
  41. package/src/util/breadcrumbs.ts +1 -1
  42. 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-0](https://github.com/karmaniverous/jeeves-server/compare/v2.9.3...v3.0.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)
@@ -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
- * Reset the config singleton (for testing).
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, resetConfig } from './index.js';
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
- resetConfig();
25
+ clearConfig();
26
26
  });
27
27
  afterEach(() => {
28
28
  fs.rmSync(tmpDir, { recursive: true, force: true });
29
- resetConfig();
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
- resetConfig();
127
+ clearConfig();
107
128
  });
108
129
  afterEach(() => {
109
130
  fs.rmSync(tmpDir, { recursive: true, force: true });
110
- resetConfig();
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('resetConfig clears the singleton', async () => {
142
+ it('clearConfig clears the singleton', async () => {
122
143
  const configPath = writeConfig(tmpDir, VALID_CONFIG);
123
144
  await initConfig(configPath);
124
- resetConfig();
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 { name, seed: entry.key, scopes: normalizeScopes(entry.scopes) };
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 = normalizeScopes(entry.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: normalizeScopes(config.outsiderPolicy) ?? null,
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: scopesObjectSchema.optional(),
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({ path: fsPath }),
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
- try {
12
- const res = await fetch(`${url}/status`, {
13
- signal: AbortSignal.timeout(3000),
14
- });
15
- if (res.ok) {
16
- const data = (await res.json());
17
- return { url, reachable: true, version: data.version };
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 (request) => {
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
- exportFormats: ['pdf', 'docx', 'zip'],
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, // bundled via @mermaid-js/mermaid-cli
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 for insider requests', async () => {
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
- expect(status.exportFormats).toEqual(['pdf', 'docx', 'zip']);
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
  });
@@ -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);