@karmaniverous/jeeves-server 3.1.1 → 3.1.3

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/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.1.1](https://github.com/karmaniverous/jeeves-server/compare/v2.9.3...v3.1.1)
5
+ #### [v3.1.3](https://github.com/karmaniverous/jeeves-server/compare/v2.9.3...v3.1.3)
6
6
 
7
+ - docs: document scope override precedence in OpenClaw skill [`#95`](https://github.com/karmaniverous/jeeves-server/pull/95)
8
+ - feat: explicit scope overrides take precedence over named scopes [`#94`](https://github.com/karmaniverous/jeeves-server/pull/94)
9
+ - fix: parse inline tokens in heading renderer [`#93`](https://github.com/karmaniverous/jeeves-server/pull/93)
7
10
  - fix: prevent full data reload on tab switch [`#91`](https://github.com/karmaniverous/jeeves-server/pull/91)
8
11
  - fix: Rendered tab persists when switching to Raw on watcher-rendered files [`#90`](https://github.com/karmaniverous/jeeves-server/pull/90)
9
12
  - feat: extend /api/status with ?events=N for recent event log [`#89`](https://github.com/karmaniverous/jeeves-server/pull/89)
@@ -34,11 +37,12 @@ All notable changes to this project will be documented in this file. Dates are d
34
37
  - fix: exit edit mode after save [`#63`](https://github.com/karmaniverous/jeeves-server/pull/63)
35
38
  - chore: add default port 1934 [`#62`](https://github.com/karmaniverous/jeeves-server/pull/62)
36
39
  - chore: add default port 1934 [`#61`](https://github.com/karmaniverous/jeeves-server/pull/61)
40
+ - fix: parse inline tokens in heading renderer (code spans, bold, italic) [`#92`](https://github.com/karmaniverous/jeeves-server/issues/92)
41
+ - chore: add docs workflow, gitignore generated docs [`10bffb8`](https://github.com/karmaniverous/jeeves-server/commit/10bffb8b6ce9c45c31e88b07e26c4657cfba0776)
37
42
  - updated docs [`86d88c7`](https://github.com/karmaniverous/jeeves-server/commit/86d88c76b04b32132195370328f434b427b7e23d)
38
43
  - updated docs [`cbd8537`](https://github.com/karmaniverous/jeeves-server/commit/cbd853764a9b5953afb847aa653a5182f74b1cd6)
39
44
  - docs: refresh README and guides for v3 CLI + config [`e437387`](https://github.com/karmaniverous/jeeves-server/commit/e4373875d454518b8f4d8dcf37613ddc36f2aace)
40
45
  - refactor: remove hardcoded filters, fix lazy facet loading [`ffb0f2a`](https://github.com/karmaniverous/jeeves-server/commit/ffb0f2aa4fc763ddce66dd3f666bf5db6cc6dfe8)
41
- - chore: release @karmaniverous/jeeves-server-openclaw v0.1.0-0 [`b6c0f6d`](https://github.com/karmaniverous/jeeves-server/commit/b6c0f6db6ab8bbdcffb40affc00f9f573ca2caa5)
42
46
  - docs: add typedoc config and dependencies [`761bb8c`](https://github.com/karmaniverous/jeeves-server/commit/761bb8cef7c5d84f17c9193d688bd9b0be67a6ca)
43
47
  - feat: two-step facet selection + garbage value filtering [`6c60e97`](https://github.com/karmaniverous/jeeves-server/commit/6c60e9753761e9b2a844bcc9212baadc73841a19)
44
48
  - feat: schema-driven facet rendering by uiHint [`97af834`](https://github.com/karmaniverous/jeeves-server/commit/97af834a29d3d4e95be3c157ecd07855b8aee403)
@@ -47,15 +51,16 @@ All notable changes to this project will be documented in this file. Dates are d
47
51
  - perf: lazy-load facets only when 'Add filter' is clicked [`d0b111c`](https://github.com/karmaniverous/jeeves-server/commit/d0b111c8cd442a47238ad5e0512576221bac1572)
48
52
  - chore: add knip configs, remove dead exports, clean all code quality checks [`2a81072`](https://github.com/karmaniverous/jeeves-server/commit/2a81072cc5341047b2ef40333405a8cc9760dab4)
49
53
  - feat: garbage value diagnostics for inference rule debugging [`06e4f82`](https://github.com/karmaniverous/jeeves-server/commit/06e4f823fa87f991ca81c904d2eb55dc7ce59d26)
50
- - fix: make resetConfig reload runtime config [`79e8602`](https://github.com/karmaniverous/jeeves-server/commit/79e8602b4a8ee13c4de94bb3d262dd8bdb7cd2c8)
51
54
  - chore: release @karmaniverous/jeeves-server v3.1.0 [`0688333`](https://github.com/karmaniverous/jeeves-server/commit/0688333e880847fc4c3beb773cdb0a907a3f82e9)
52
55
  - updated docs [`075a6f3`](https://github.com/karmaniverous/jeeves-server/commit/075a6f36cee6279c83ba564e6efbde99ebac2f37)
56
+ - chore: release @karmaniverous/jeeves-server v3.1.1 [`736ec35`](https://github.com/karmaniverous/jeeves-server/commit/736ec35c550c0f8da5800413562f10643f9da6db)
53
57
  - chore: release @karmaniverous/jeeves-server v3.0.1 [`67cf001`](https://github.com/karmaniverous/jeeves-server/commit/67cf0014073c62b18134da2c1266b505a1228874)
54
58
  - lintfix [`00dc2b2`](https://github.com/karmaniverous/jeeves-server/commit/00dc2b2ec30ea2a4365d93b86c66330b831737a7)
55
59
  - feat: render text/number facets as text inputs, chips for select/multiselect [`282adf8`](https://github.com/karmaniverous/jeeves-server/commit/282adf83c21b84d433ec23be5d1b6b51b5da8145)
56
60
  - docs: add changelogs as children of package guide indexes [`fd16b5b`](https://github.com/karmaniverous/jeeves-server/commit/fd16b5b7f80ad2b58d883fe3b7cf270668026f8e)
57
61
  - lintfix [`38a9376`](https://github.com/karmaniverous/jeeves-server/commit/38a937631ab2ac3c22240857f69b36d5d9570665)
58
62
  - fix: text/number facets skip value cleaning, cast values to String [`c9af039`](https://github.com/karmaniverous/jeeves-server/commit/c9af039c2fbdad59a7e9fdedd75f008ab5c1c148)
63
+ - chore: release @karmaniverous/jeeves-server v3.1.2 [`d15fa4c`](https://github.com/karmaniverous/jeeves-server/commit/d15fa4c5d6ca2a31e28b6509d5566569025110ff)
59
64
  - chore: release @karmaniverous/jeeves-server v3.0.0-1 [`db96bc1`](https://github.com/karmaniverous/jeeves-server/commit/db96bc140b9cbca7ad52ddd562a805ac74ca67aa)
60
65
  - docs: document ?events=N query param on /api/status [`4a25f3b`](https://github.com/karmaniverous/jeeves-server/commit/4a25f3bcf5bf50e688cd7c38e0340a0f24341e96)
61
66
  - fix: restore eager facet loading on modal open [`a625bd0`](https://github.com/karmaniverous/jeeves-server/commit/a625bd08e47ab95cd5a05324a0a888bb0bf6fc4f)
@@ -64,8 +69,13 @@ All notable changes to this project will be documented in this file. Dates are d
64
69
  - chore: release @karmaniverous/jeeves-server-openclaw v0.1.1 [`abfee9f`](https://github.com/karmaniverous/jeeves-server/commit/abfee9f6ee5b2ac1cf6c8e4838edd3e2e8133359)
65
70
  - fix: remove unnecessary auth from /api/status calls (endpoint is public) [`f651fe7`](https://github.com/karmaniverous/jeeves-server/commit/f651fe7bff4320eb28b5039278bcdcd032cac6d5)
66
71
  - chore: release @karmaniverous/jeeves-server-openclaw v0.1.0-1 [`d08e571`](https://github.com/karmaniverous/jeeves-server/commit/d08e571d58bb6387e69441dc06511d92fb743c15)
72
+ - chore: release @karmaniverous/jeeves-server v3.0.0 [`ef408c3`](https://github.com/karmaniverous/jeeves-server/commit/ef408c37ee306417ee24d58b256cba672f7984c1)
73
+ - chore: release @karmaniverous/jeeves-server-openclaw v0.1.0 [`bbeac17`](https://github.com/karmaniverous/jeeves-server/commit/bbeac17333ebad423b0fd8c2bcd310bbb0b63b27)
67
74
  - fix: make +N more chip overflow a clickable link that expands the result [`d56ed6a`](https://github.com/karmaniverous/jeeves-server/commit/d56ed6a69526f96697ec8aa9922e104510b2a9f8)
75
+ - fix: increase facets timeout to 15s, add error logging [`dfda5dd`](https://github.com/karmaniverous/jeeves-server/commit/dfda5dd22b06f9fbabff2572d4a8a2ddf3c55be9)
76
+ - fix: use type=number input for number facets [`c245b6a`](https://github.com/karmaniverous/jeeves-server/commit/c245b6ae5dc9f7d762aebac21a9ca7f342f44024)
68
77
  - fix: prevent full data reload on tab switch (only reload on path change) [`6390b14`](https://github.com/karmaniverous/jeeves-server/commit/6390b1409db92ec001cd2071cea6757a4c8fa081)
78
+ - fix: close SearchableSelect dropdown on outside click (capture phase) [`f85d8dd`](https://github.com/karmaniverous/jeeves-server/commit/f85d8dda1db97016b9f6d8244fac34bdaba2d1b1)
69
79
  - Revert "fix: Rendered tab persists when switching to Raw on watcher-rendered files" [`71b79d4`](https://github.com/karmaniverous/jeeves-server/commit/71b79d4f8a839dcf5d865d7a747bb30e1cb43db3)
70
80
  - feat: search facets, metadata chips, and click-to-filter [`20eeee8`](https://github.com/karmaniverous/jeeves-server/commit/20eeee8b6524d6a29ba99822e06ea57bef994fef)
71
81
  - chore: add client as workspace member, align puppeteer versions [`6e3a40d`](https://github.com/karmaniverous/jeeves-server/commit/6e3a40dfdb91ffe2e39877d1b69b36db3e01f863)
@@ -78,6 +88,7 @@ All notable changes to this project will be documented in this file. Dates are d
78
88
  - chore: SOLID/DRY pass #3 + plugin test coverage [`7dcc4c3`](https://github.com/karmaniverous/jeeves-server/commit/7dcc4c3ad36a45a46dc99c8a1e749dfd2ec01e47)
79
89
  - feat: add CLI commands (start, config validate/show, service) [`0437984`](https://github.com/karmaniverous/jeeves-server/commit/0437984e7f783604f9cbdd333db61f8d1af42961)
80
90
  - fix: resolve knip unused files, dependencies, and exports [`964beba`](https://github.com/karmaniverous/jeeves-server/commit/964beba273f99be8d3302648c2dd442a3e3dcc07)
91
+ - chore: release @karmaniverous/jeeves-server-openclaw v0.1.0-0 [`b6c0f6d`](https://github.com/karmaniverous/jeeves-server/commit/b6c0f6db6ab8bbdcffb40affc00f9f573ca2caa5)
81
92
  - fix: address all gap analysis findings [`1eb02ab`](https://github.com/karmaniverous/jeeves-server/commit/1eb02abf0634be38c687d4927a2360cd27c3aad8)
82
93
  - npm audit fix [`3a144b4`](https://github.com/karmaniverous/jeeves-server/commit/3a144b4b07e1387cf40733486e60aeaf7a34d0a6)
83
94
  - feat: add GET /api/link-info endpoint [`02ece39`](https://github.com/karmaniverous/jeeves-server/commit/02ece3960aa91861455034aa4bfe8850c0b0f363)
@@ -87,6 +98,7 @@ All notable changes to this project will be documented in this file. Dates are d
87
98
  - chore: SOLID/DRY/test coverage pass [`e491bfb`](https://github.com/karmaniverous/jeeves-server/commit/e491bfbbce7fef13252c2488378b85e983a2207a)
88
99
  - refactor: extract buildRuntimeConfig to resolve.ts (DRY) [`87bd749`](https://github.com/karmaniverous/jeeves-server/commit/87bd749c6827e3c95e2ff9996ab1b781cb316932)
89
100
  - fix: address Gemini code review feedback across PRs #65-#76 [`2fef919`](https://github.com/karmaniverous/jeeves-server/commit/2fef9192b00c80605c9cca348ea7feff5af2602a)
101
+ - fix: make resetConfig reload runtime config [`79e8602`](https://github.com/karmaniverous/jeeves-server/commit/79e8602b4a8ee13c4de94bb3d262dd8bdb7cd2c8)
90
102
  - refactor: extract shared renderMarkdownContent pipeline [`244dddf`](https://github.com/karmaniverous/jeeves-server/commit/244dddf83da846d2863e776ae78a029610532d2d)
91
103
  - feat: schema-driven search facet filters (Step 10) [`c305c2a`](https://github.com/karmaniverous/jeeves-server/commit/c305c2ae261514411b6329bdc84052ee5c189759)
92
104
  - feat: add scroll anchoring for async diagram renders [`e4bd972`](https://github.com/karmaniverous/jeeves-server/commit/e4bd97293dbb8a9a63d5cf3bceb6e0cbb7a26916)
@@ -104,11 +116,6 @@ All notable changes to this project will be documented in this file. Dates are d
104
116
  - fix: update linux-compat CI for monorepo paths [`22dd30f`](https://github.com/karmaniverous/jeeves-server/commit/22dd30ffe5543042287a299523e5b1125c86381e)
105
117
  - chore: eliminate all lint warnings [`ed12c86`](https://github.com/karmaniverous/jeeves-server/commit/ed12c868546a3f14f7d809d7378235ac91415be7)
106
118
  - fix: CI rimraf resolution and remove redundant client steps [`757c45c`](https://github.com/karmaniverous/jeeves-server/commit/757c45cf429e7c70358442e353922f7999d74640)
107
- - chore: release @karmaniverous/jeeves-server v3.0.0 [`ef408c3`](https://github.com/karmaniverous/jeeves-server/commit/ef408c37ee306417ee24d58b256cba672f7984c1)
108
- - chore: release @karmaniverous/jeeves-server-openclaw v0.1.0 [`bbeac17`](https://github.com/karmaniverous/jeeves-server/commit/bbeac17333ebad423b0fd8c2bcd310bbb0b63b27)
109
- - fix: increase facets timeout to 15s, add error logging [`dfda5dd`](https://github.com/karmaniverous/jeeves-server/commit/dfda5dd22b06f9fbabff2572d4a8a2ddf3c55be9)
110
- - fix: use type=number input for number facets [`c245b6a`](https://github.com/karmaniverous/jeeves-server/commit/c245b6ae5dc9f7d762aebac21a9ca7f342f44024)
111
- - fix: close SearchableSelect dropdown on outside click (capture phase) [`f85d8dd`](https://github.com/karmaniverous/jeeves-server/commit/f85d8dda1db97016b9f6d8244fac34bdaba2d1b1)
112
119
  - fix: server_browse and server_export route mismatches [`82ef907`](https://github.com/karmaniverous/jeeves-server/commit/82ef907750dbb0d47158254cd21b7a8196d8eaeb)
113
120
  - npm audit fix [`2f3b5c8`](https://github.com/karmaniverous/jeeves-server/commit/2f3b5c89af0bf56b17fd37719f5927812f2c8e24)
114
121
  - publishconfig public access [`8e4358c`](https://github.com/karmaniverous/jeeves-server/commit/8e4358cc43dc2f131840047d34f2644eb6293fe2)
@@ -24,9 +24,21 @@ function pathMatchesPatterns(requestPath, patterns) {
24
24
  }
25
25
  /**
26
26
  * Check whether a request path matches normalized scopes (allow/deny).
27
- * Path must match at least one allow rule AND NOT match any deny rule.
27
+ *
28
+ * Evaluation order (explicit overrides take highest precedence):
29
+ * 1. If explicitDeny matches → DENIED (overrides named allow)
30
+ * 2. If explicitAllow matches → ALLOWED (overrides named deny)
31
+ * 3. Path must match at least one allow rule AND NOT match any deny rule.
28
32
  */
29
33
  function pathMatchesScopes(requestPath, scopes) {
34
+ // Explicit overrides take precedence over named scope patterns
35
+ if (scopes.explicitDeny.length > 0 &&
36
+ pathMatchesPatterns(requestPath, scopes.explicitDeny))
37
+ return false;
38
+ if (scopes.explicitAllow.length > 0 &&
39
+ pathMatchesPatterns(requestPath, scopes.explicitAllow))
40
+ return true;
41
+ // Standard evaluation: allow AND NOT deny
30
42
  if (!pathMatchesPatterns(requestPath, scopes.allow))
31
43
  return false;
32
44
  if (scopes.deny.length > 0 && pathMatchesPatterns(requestPath, scopes.deny))
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for scope override precedence in key verification.
3
+ *
4
+ * Verifies the three-tier evaluation order:
5
+ * 1. explicitDeny → DENIED (overrides named allow)
6
+ * 2. explicitAllow → ALLOWED (overrides named deny)
7
+ * 3. Standard allow AND NOT deny
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import { _pathMatchesScopes as pathMatchesScopes } from './keys.js';
11
+ describe('pathMatchesScopes', () => {
12
+ it('allows a path matching allow and not deny', () => {
13
+ expect(pathMatchesScopes('/d/projects/foo', {
14
+ allow: ['/d/projects/**'],
15
+ deny: [],
16
+ explicitAllow: [],
17
+ explicitDeny: [],
18
+ })).toBe(true);
19
+ });
20
+ it('denies a path not matching any allow', () => {
21
+ expect(pathMatchesScopes('/d/secrets/foo', {
22
+ allow: ['/d/projects/**'],
23
+ deny: [],
24
+ explicitAllow: [],
25
+ explicitDeny: [],
26
+ })).toBe(false);
27
+ });
28
+ it('denies a path matching a deny pattern', () => {
29
+ expect(pathMatchesScopes('/d/projects/secret/foo', {
30
+ allow: ['/d/projects/**'],
31
+ deny: ['/d/projects/secret/**'],
32
+ explicitAllow: [],
33
+ explicitDeny: [],
34
+ })).toBe(false);
35
+ });
36
+ it('explicit allow overrides named deny', () => {
37
+ expect(pathMatchesScopes('/j/domains/projects/jill/doc.md', {
38
+ allow: ['/j/domains/projects/**'],
39
+ deny: ['/j/domains/projects/jill/**'],
40
+ explicitAllow: ['/j/domains/projects/jill/**'],
41
+ explicitDeny: [],
42
+ })).toBe(true);
43
+ });
44
+ it('explicit deny overrides named allow', () => {
45
+ expect(pathMatchesScopes('/d/repos/secret/code.ts', {
46
+ allow: ['/d/repos/**'],
47
+ deny: [],
48
+ explicitAllow: [],
49
+ explicitDeny: ['/d/repos/secret/**'],
50
+ })).toBe(false);
51
+ });
52
+ it('explicit deny takes precedence over explicit allow', () => {
53
+ expect(pathMatchesScopes('/d/repos/secret/code.ts', {
54
+ allow: ['/d/repos/**'],
55
+ deny: [],
56
+ explicitAllow: ['/d/repos/secret/**'],
57
+ explicitDeny: ['/d/repos/secret/**'],
58
+ })).toBe(false);
59
+ });
60
+ it('path not in any scope is denied even with explicit allow on different path', () => {
61
+ expect(pathMatchesScopes('/totally/elsewhere', {
62
+ allow: ['/d/projects/**'],
63
+ deny: [],
64
+ explicitAllow: ['/j/domains/projects/jill/**'],
65
+ explicitDeny: [],
66
+ })).toBe(false);
67
+ });
68
+ it('Robert/Jill scenario: projects + no-private + explicit jill allow', () => {
69
+ // Named scopes: projects (allow /j/domains/projects/**) + no-private (deny /j/domains/projects/jill/**)
70
+ // Explicit: allow /j/domains/projects/jill/**
71
+ const scopes = {
72
+ allow: ['/j/domains/projects/**'],
73
+ deny: ['/j/domains/projects/jill/**'],
74
+ explicitAllow: ['/j/domains/projects/jill/**'],
75
+ explicitDeny: [],
76
+ };
77
+ // Jill's project should be accessible (explicit allow overrides named deny)
78
+ expect(pathMatchesScopes('/j/domains/projects/jill/thesis.md', scopes)).toBe(true);
79
+ // Other projects should work normally
80
+ expect(pathMatchesScopes('/j/domains/projects/jeeves-server/readme.md', scopes)).toBe(true);
81
+ // Other private projects (if they existed) would still be denied
82
+ // (only jill is explicitly allowed)
83
+ });
84
+ });
@@ -117,7 +117,12 @@ describe('loadConfig', () => {
117
117
  },
118
118
  });
119
119
  const config = await loadConfig(configPath);
120
- expect(config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes).toEqual({ allow: ['/docs/**'], deny: [] });
120
+ expect(config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes).toEqual({
121
+ allow: ['/docs/**'],
122
+ deny: [],
123
+ explicitAllow: [],
124
+ explicitDeny: [],
125
+ });
121
126
  });
122
127
  });
123
128
  describe('config singleton', () => {
@@ -18,14 +18,21 @@ export function normalizeScopes(raw) {
18
18
  if (raw === undefined || raw === null)
19
19
  return null;
20
20
  if (typeof raw === 'string')
21
- return { allow: [raw], deny: [] };
21
+ return { allow: [raw], deny: [], explicitAllow: [], explicitDeny: [] };
22
22
  if (Array.isArray(raw))
23
- return { allow: raw, deny: [] };
23
+ return {
24
+ allow: raw,
25
+ deny: [],
26
+ explicitAllow: [],
27
+ explicitDeny: [],
28
+ };
24
29
  if (typeof raw === 'object') {
25
30
  const obj = raw;
26
31
  return {
27
32
  allow: obj.allow ?? ['/**'],
28
33
  deny: obj.deny ?? [],
34
+ explicitAllow: [],
35
+ explicitDeny: [],
29
36
  };
30
37
  }
31
38
  return null;
@@ -43,6 +50,8 @@ export function resolveNamedScopes(named, rawScopes, overrides) {
43
50
  return {
44
51
  allow: overrides.allow ?? ['/**'],
45
52
  deny: overrides.deny ?? [],
53
+ explicitAllow: overrides.allow ?? [],
54
+ explicitDeny: overrides.deny ?? [],
46
55
  };
47
56
  }
48
57
  return null;
@@ -76,6 +85,8 @@ export function resolveNamedScopes(named, rawScopes, overrides) {
76
85
  return {
77
86
  allow: allow.length > 0 ? allow : ['/**'],
78
87
  deny,
88
+ explicitAllow: overrides?.allow ?? [],
89
+ explicitDeny: overrides?.deny ?? [],
79
90
  };
80
91
  }
81
92
  // Legacy inline scopes
@@ -86,6 +97,8 @@ export function resolveNamedScopes(named, rawScopes, overrides) {
86
97
  normalized.allow.push(...overrides.allow);
87
98
  if (overrides?.deny)
88
99
  normalized.deny.push(...overrides.deny);
100
+ normalized.explicitAllow = overrides?.allow ?? [];
101
+ normalized.explicitDeny = overrides?.deny ?? [];
89
102
  return normalized;
90
103
  }
91
104
  /**
@@ -11,23 +11,36 @@ describe('normalizeScopes', () => {
11
11
  expect(normalizeScopes(null)).toBe(null);
12
12
  });
13
13
  it('wraps a string in allow array', () => {
14
- expect(normalizeScopes('/docs')).toEqual({ allow: ['/docs'], deny: [] });
14
+ expect(normalizeScopes('/docs')).toEqual({
15
+ allow: ['/docs'],
16
+ deny: [],
17
+ explicitAllow: [],
18
+ explicitDeny: [],
19
+ });
15
20
  });
16
21
  it('wraps an array as allow', () => {
17
22
  expect(normalizeScopes(['/a', '/b'])).toEqual({
18
23
  allow: ['/a', '/b'],
19
24
  deny: [],
25
+ explicitAllow: [],
26
+ explicitDeny: [],
20
27
  });
21
28
  });
22
29
  it('fills defaults for partial object', () => {
23
30
  expect(normalizeScopes({ deny: ['/secret'] })).toEqual({
24
31
  allow: ['/**'],
25
32
  deny: ['/secret'],
33
+ explicitAllow: [],
34
+ explicitDeny: [],
26
35
  });
27
36
  });
28
37
  it('passes through complete object', () => {
29
38
  const scopes = { allow: ['/a'], deny: ['/b'] };
30
- expect(normalizeScopes(scopes)).toEqual(scopes);
39
+ expect(normalizeScopes(scopes)).toEqual({
40
+ ...scopes,
41
+ explicitAllow: [],
42
+ explicitDeny: [],
43
+ });
31
44
  });
32
45
  });
33
46
  describe('resolveKeys', () => {
@@ -43,7 +56,12 @@ describe('resolveKeys', () => {
43
56
  }, {});
44
57
  expect(result[0].name).toBe('scoped');
45
58
  expect(result[0].seed).toBe('seed456');
46
- expect(result[0].scopes).toEqual({ allow: ['/docs'], deny: [] });
59
+ expect(result[0].scopes).toEqual({
60
+ allow: ['/docs'],
61
+ deny: [],
62
+ explicitAllow: [],
63
+ explicitDeny: [],
64
+ });
47
65
  });
48
66
  it('handles mixed entries', () => {
49
67
  const result = resolveKeys({
@@ -52,7 +70,12 @@ describe('resolveKeys', () => {
52
70
  }, {});
53
71
  expect(result).toHaveLength(2);
54
72
  expect(result[0].scopes).toBe(null);
55
- expect(result[1].scopes).toEqual({ allow: ['/x'], deny: ['/y'] });
73
+ expect(result[1].scopes).toEqual({
74
+ allow: ['/x'],
75
+ deny: ['/y'],
76
+ explicitAllow: [],
77
+ explicitDeny: [],
78
+ });
56
79
  });
57
80
  });
58
81
  describe('resolveInsiders', () => {
@@ -92,6 +115,8 @@ describe('resolveNamedScopes', () => {
92
115
  expect(resolveNamedScopes(named, 'restricted')).toEqual({
93
116
  allow: ['/**'],
94
117
  deny: ['/secret'],
118
+ explicitAllow: [],
119
+ explicitDeny: [],
95
120
  });
96
121
  });
97
122
  it('unions multiple named scopes and merges overrides', () => {
@@ -105,6 +130,8 @@ describe('resolveNamedScopes', () => {
105
130
  })).toEqual({
106
131
  allow: ['/**', '/extra'],
107
132
  deny: ['/secret', '/vc/**', '/more'],
133
+ explicitAllow: ['/extra'],
134
+ explicitDeny: ['/more'],
108
135
  });
109
136
  });
110
137
  it('falls back to legacy inline scope strings', () => {
@@ -112,6 +139,8 @@ describe('resolveNamedScopes', () => {
112
139
  expect(resolveNamedScopes(named, '/docs')).toEqual({
113
140
  allow: ['/docs'],
114
141
  deny: [],
142
+ explicitAllow: [],
143
+ explicitDeny: [],
115
144
  });
116
145
  });
117
146
  });
@@ -83,6 +83,10 @@ export function parseMarkdown(markdown, options = {}) {
83
83
  const text = typeof args === 'object' ? args.text : args;
84
84
  const raw = typeof args === 'object' && args.raw ? args.raw : text;
85
85
  const level = typeof args === 'object' ? args.depth : 1;
86
+ // Parse inline tokens to render code spans, bold, italic, links, etc.
87
+ const renderedText = typeof args === 'object' && args.tokens
88
+ ? this.parser.parseInline(args.tokens)
89
+ : text;
86
90
  const slug = raw
87
91
  .toLowerCase()
88
92
  .replace(/<[^>]+>/g, '')
@@ -90,8 +94,12 @@ export function parseMarkdown(markdown, options = {}) {
90
94
  .replace(/\s+/g, '-')
91
95
  .replace(/-+/g, '-')
92
96
  .replace(/^-|-$/g, '');
93
- headings.push({ level, text: text.replace(/<[^>]+>/g, ''), slug });
94
- return `<h${String(level)} id="${slug}">${text} <a href="#${slug}" class="anchor">#</a></h${String(level)}>\n`;
97
+ headings.push({
98
+ level,
99
+ text: renderedText.replace(/<[^>]+>/g, ''),
100
+ slug,
101
+ });
102
+ return `<h${String(level)} id="${slug}">${renderedText} <a href="#${slug}" class="anchor">#</a></h${String(level)}>\n`;
95
103
  };
96
104
  // Rewrite relative image src to /path/ URLs
97
105
  if (options.basePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-server",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Secure file browser, markdown viewer, and webhook gateway with PDF/DOCX export and expiring share links",
5
5
  "keywords": [
6
6
  "fastify",
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tests for scope override precedence in key verification.
3
+ *
4
+ * Verifies the three-tier evaluation order:
5
+ * 1. explicitDeny → DENIED (overrides named allow)
6
+ * 2. explicitAllow → ALLOWED (overrides named deny)
7
+ * 3. Standard allow AND NOT deny
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { _pathMatchesScopes as pathMatchesScopes } from './keys.js';
13
+
14
+ describe('pathMatchesScopes', () => {
15
+ it('allows a path matching allow and not deny', () => {
16
+ expect(
17
+ pathMatchesScopes('/d/projects/foo', {
18
+ allow: ['/d/projects/**'],
19
+ deny: [],
20
+ explicitAllow: [],
21
+ explicitDeny: [],
22
+ }),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it('denies a path not matching any allow', () => {
27
+ expect(
28
+ pathMatchesScopes('/d/secrets/foo', {
29
+ allow: ['/d/projects/**'],
30
+ deny: [],
31
+ explicitAllow: [],
32
+ explicitDeny: [],
33
+ }),
34
+ ).toBe(false);
35
+ });
36
+
37
+ it('denies a path matching a deny pattern', () => {
38
+ expect(
39
+ pathMatchesScopes('/d/projects/secret/foo', {
40
+ allow: ['/d/projects/**'],
41
+ deny: ['/d/projects/secret/**'],
42
+ explicitAllow: [],
43
+ explicitDeny: [],
44
+ }),
45
+ ).toBe(false);
46
+ });
47
+
48
+ it('explicit allow overrides named deny', () => {
49
+ expect(
50
+ pathMatchesScopes('/j/domains/projects/jill/doc.md', {
51
+ allow: ['/j/domains/projects/**'],
52
+ deny: ['/j/domains/projects/jill/**'],
53
+ explicitAllow: ['/j/domains/projects/jill/**'],
54
+ explicitDeny: [],
55
+ }),
56
+ ).toBe(true);
57
+ });
58
+
59
+ it('explicit deny overrides named allow', () => {
60
+ expect(
61
+ pathMatchesScopes('/d/repos/secret/code.ts', {
62
+ allow: ['/d/repos/**'],
63
+ deny: [],
64
+ explicitAllow: [],
65
+ explicitDeny: ['/d/repos/secret/**'],
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it('explicit deny takes precedence over explicit allow', () => {
71
+ expect(
72
+ pathMatchesScopes('/d/repos/secret/code.ts', {
73
+ allow: ['/d/repos/**'],
74
+ deny: [],
75
+ explicitAllow: ['/d/repos/secret/**'],
76
+ explicitDeny: ['/d/repos/secret/**'],
77
+ }),
78
+ ).toBe(false);
79
+ });
80
+
81
+ it('path not in any scope is denied even with explicit allow on different path', () => {
82
+ expect(
83
+ pathMatchesScopes('/totally/elsewhere', {
84
+ allow: ['/d/projects/**'],
85
+ deny: [],
86
+ explicitAllow: ['/j/domains/projects/jill/**'],
87
+ explicitDeny: [],
88
+ }),
89
+ ).toBe(false);
90
+ });
91
+
92
+ it('Robert/Jill scenario: projects + no-private + explicit jill allow', () => {
93
+ // Named scopes: projects (allow /j/domains/projects/**) + no-private (deny /j/domains/projects/jill/**)
94
+ // Explicit: allow /j/domains/projects/jill/**
95
+ const scopes = {
96
+ allow: ['/j/domains/projects/**'],
97
+ deny: ['/j/domains/projects/jill/**'],
98
+ explicitAllow: ['/j/domains/projects/jill/**'],
99
+ explicitDeny: [],
100
+ };
101
+
102
+ // Jill's project should be accessible (explicit allow overrides named deny)
103
+ expect(
104
+ pathMatchesScopes('/j/domains/projects/jill/thesis.md', scopes),
105
+ ).toBe(true);
106
+
107
+ // Other projects should work normally
108
+ expect(
109
+ pathMatchesScopes('/j/domains/projects/jeeves-server/readme.md', scopes),
110
+ ).toBe(true);
111
+
112
+ // Other private projects (if they existed) would still be denied
113
+ // (only jill is explicitly allowed)
114
+ });
115
+ });
package/src/auth/keys.ts CHANGED
@@ -44,12 +44,29 @@ function pathMatchesPatterns(requestPath: string, patterns: string[]): boolean {
44
44
 
45
45
  /**
46
46
  * Check whether a request path matches normalized scopes (allow/deny).
47
- * Path must match at least one allow rule AND NOT match any deny rule.
47
+ *
48
+ * Evaluation order (explicit overrides take highest precedence):
49
+ * 1. If explicitDeny matches → DENIED (overrides named allow)
50
+ * 2. If explicitAllow matches → ALLOWED (overrides named deny)
51
+ * 3. Path must match at least one allow rule AND NOT match any deny rule.
48
52
  */
49
53
  function pathMatchesScopes(
50
54
  requestPath: string,
51
55
  scopes: NormalizedScopes,
52
56
  ): boolean {
57
+ // Explicit overrides take precedence over named scope patterns
58
+ if (
59
+ scopes.explicitDeny.length > 0 &&
60
+ pathMatchesPatterns(requestPath, scopes.explicitDeny)
61
+ )
62
+ return false;
63
+ if (
64
+ scopes.explicitAllow.length > 0 &&
65
+ pathMatchesPatterns(requestPath, scopes.explicitAllow)
66
+ )
67
+ return true;
68
+
69
+ // Standard evaluation: allow AND NOT deny
53
70
  if (!pathMatchesPatterns(requestPath, scopes.allow)) return false;
54
71
  if (scopes.deny.length > 0 && pathMatchesPatterns(requestPath, scopes.deny))
55
72
  return false;
@@ -147,7 +147,12 @@ describe('loadConfig', () => {
147
147
  const config = await loadConfig(configPath);
148
148
  expect(
149
149
  config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes,
150
- ).toEqual({ allow: ['/docs/**'], deny: [] });
150
+ ).toEqual({
151
+ allow: ['/docs/**'],
152
+ deny: [],
153
+ explicitAllow: [],
154
+ explicitDeny: [],
155
+ });
151
156
  });
152
157
  });
153
158
 
@@ -25,13 +25,20 @@ describe('normalizeScopes', () => {
25
25
  });
26
26
 
27
27
  it('wraps a string in allow array', () => {
28
- expect(normalizeScopes('/docs')).toEqual({ allow: ['/docs'], deny: [] });
28
+ expect(normalizeScopes('/docs')).toEqual({
29
+ allow: ['/docs'],
30
+ deny: [],
31
+ explicitAllow: [],
32
+ explicitDeny: [],
33
+ });
29
34
  });
30
35
 
31
36
  it('wraps an array as allow', () => {
32
37
  expect(normalizeScopes(['/a', '/b'])).toEqual({
33
38
  allow: ['/a', '/b'],
34
39
  deny: [],
40
+ explicitAllow: [],
41
+ explicitDeny: [],
35
42
  });
36
43
  });
37
44
 
@@ -39,12 +46,18 @@ describe('normalizeScopes', () => {
39
46
  expect(normalizeScopes({ deny: ['/secret'] })).toEqual({
40
47
  allow: ['/**'],
41
48
  deny: ['/secret'],
49
+ explicitAllow: [],
50
+ explicitDeny: [],
42
51
  });
43
52
  });
44
53
 
45
54
  it('passes through complete object', () => {
46
55
  const scopes = { allow: ['/a'], deny: ['/b'] };
47
- expect(normalizeScopes(scopes)).toEqual(scopes);
56
+ expect(normalizeScopes(scopes)).toEqual({
57
+ ...scopes,
58
+ explicitAllow: [],
59
+ explicitDeny: [],
60
+ });
48
61
  });
49
62
  });
50
63
 
@@ -65,7 +78,12 @@ describe('resolveKeys', () => {
65
78
  );
66
79
  expect(result[0].name).toBe('scoped');
67
80
  expect(result[0].seed).toBe('seed456');
68
- expect(result[0].scopes).toEqual({ allow: ['/docs'], deny: [] });
81
+ expect(result[0].scopes).toEqual({
82
+ allow: ['/docs'],
83
+ deny: [],
84
+ explicitAllow: [],
85
+ explicitDeny: [],
86
+ });
69
87
  });
70
88
 
71
89
  it('handles mixed entries', () => {
@@ -78,7 +96,12 @@ describe('resolveKeys', () => {
78
96
  );
79
97
  expect(result).toHaveLength(2);
80
98
  expect(result[0].scopes).toBe(null);
81
- expect(result[1].scopes).toEqual({ allow: ['/x'], deny: ['/y'] });
99
+ expect(result[1].scopes).toEqual({
100
+ allow: ['/x'],
101
+ deny: ['/y'],
102
+ explicitAllow: [],
103
+ explicitDeny: [],
104
+ });
82
105
  });
83
106
  });
84
107
 
@@ -128,6 +151,8 @@ describe('resolveNamedScopes', () => {
128
151
  expect(resolveNamedScopes(named, 'restricted')).toEqual({
129
152
  allow: ['/**'],
130
153
  deny: ['/secret'],
154
+ explicitAllow: [],
155
+ explicitDeny: [],
131
156
  });
132
157
  });
133
158
 
@@ -144,6 +169,8 @@ describe('resolveNamedScopes', () => {
144
169
  ).toEqual({
145
170
  allow: ['/**', '/extra'],
146
171
  deny: ['/secret', '/vc/**', '/more'],
172
+ explicitAllow: ['/extra'],
173
+ explicitDeny: ['/more'],
147
174
  });
148
175
  });
149
176
 
@@ -152,6 +179,8 @@ describe('resolveNamedScopes', () => {
152
179
  expect(resolveNamedScopes(named, '/docs')).toEqual({
153
180
  allow: ['/docs'],
154
181
  deny: [],
182
+ explicitAllow: [],
183
+ explicitDeny: [],
155
184
  });
156
185
  });
157
186
  });