@karmaniverous/jeeves-server 3.1.2 → 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,10 @@
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.2](https://github.com/karmaniverous/jeeves-server/compare/v2.9.3...v3.1.2)
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)
7
9
  - fix: parse inline tokens in heading renderer [`#93`](https://github.com/karmaniverous/jeeves-server/pull/93)
8
10
  - fix: prevent full data reload on tab switch [`#91`](https://github.com/karmaniverous/jeeves-server/pull/91)
9
11
  - fix: Rendered tab persists when switching to Raw on watcher-rendered files [`#90`](https://github.com/karmaniverous/jeeves-server/pull/90)
@@ -49,7 +51,6 @@ All notable changes to this project will be documented in this file. Dates are d
49
51
  - perf: lazy-load facets only when 'Add filter' is clicked [`d0b111c`](https://github.com/karmaniverous/jeeves-server/commit/d0b111c8cd442a47238ad5e0512576221bac1572)
50
52
  - chore: add knip configs, remove dead exports, clean all code quality checks [`2a81072`](https://github.com/karmaniverous/jeeves-server/commit/2a81072cc5341047b2ef40333405a8cc9760dab4)
51
53
  - feat: garbage value diagnostics for inference rule debugging [`06e4f82`](https://github.com/karmaniverous/jeeves-server/commit/06e4f823fa87f991ca81c904d2eb55dc7ce59d26)
52
- - fix: make resetConfig reload runtime config [`79e8602`](https://github.com/karmaniverous/jeeves-server/commit/79e8602b4a8ee13c4de94bb3d262dd8bdb7cd2c8)
53
54
  - chore: release @karmaniverous/jeeves-server v3.1.0 [`0688333`](https://github.com/karmaniverous/jeeves-server/commit/0688333e880847fc4c3beb773cdb0a907a3f82e9)
54
55
  - updated docs [`075a6f3`](https://github.com/karmaniverous/jeeves-server/commit/075a6f36cee6279c83ba564e6efbde99ebac2f37)
55
56
  - chore: release @karmaniverous/jeeves-server v3.1.1 [`736ec35`](https://github.com/karmaniverous/jeeves-server/commit/736ec35c550c0f8da5800413562f10643f9da6db)
@@ -59,6 +60,7 @@ All notable changes to this project will be documented in this file. Dates are d
59
60
  - docs: add changelogs as children of package guide indexes [`fd16b5b`](https://github.com/karmaniverous/jeeves-server/commit/fd16b5b7f80ad2b58d883fe3b7cf270668026f8e)
60
61
  - lintfix [`38a9376`](https://github.com/karmaniverous/jeeves-server/commit/38a937631ab2ac3c22240857f69b36d5d9570665)
61
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)
62
64
  - chore: release @karmaniverous/jeeves-server v3.0.0-1 [`db96bc1`](https://github.com/karmaniverous/jeeves-server/commit/db96bc140b9cbca7ad52ddd562a805ac74ca67aa)
63
65
  - docs: document ?events=N query param on /api/status [`4a25f3b`](https://github.com/karmaniverous/jeeves-server/commit/4a25f3bcf5bf50e688cd7c38e0340a0f24341e96)
64
66
  - fix: restore eager facet loading on modal open [`a625bd0`](https://github.com/karmaniverous/jeeves-server/commit/a625bd08e47ab95cd5a05324a0a888bb0bf6fc4f)
@@ -96,6 +98,7 @@ All notable changes to this project will be documented in this file. Dates are d
96
98
  - chore: SOLID/DRY/test coverage pass [`e491bfb`](https://github.com/karmaniverous/jeeves-server/commit/e491bfbbce7fef13252c2488378b85e983a2207a)
97
99
  - refactor: extract buildRuntimeConfig to resolve.ts (DRY) [`87bd749`](https://github.com/karmaniverous/jeeves-server/commit/87bd749c6827e3c95e2ff9996ab1b781cb316932)
98
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)
99
102
  - refactor: extract shared renderMarkdownContent pipeline [`244dddf`](https://github.com/karmaniverous/jeeves-server/commit/244dddf83da846d2863e776ae78a029610532d2d)
100
103
  - feat: schema-driven search facet filters (Step 10) [`c305c2a`](https://github.com/karmaniverous/jeeves-server/commit/c305c2ae261514411b6329bdc84052ee5c189759)
101
104
  - feat: add scroll anchoring for async diagram renders [`e4bd972`](https://github.com/karmaniverous/jeeves-server/commit/e4bd97293dbb8a9a63d5cf3bceb6e0cbb7a26916)
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-server",
3
- "version": "3.1.2",
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
  });
@@ -27,13 +27,22 @@ import type {
27
27
  */
28
28
  export function normalizeScopes(raw: unknown): NormalizedScopes | null {
29
29
  if (raw === undefined || raw === null) return null;
30
- if (typeof raw === 'string') return { allow: [raw], deny: [] };
31
- if (Array.isArray(raw)) return { allow: raw as string[], deny: [] };
30
+ if (typeof raw === 'string')
31
+ return { allow: [raw], deny: [], explicitAllow: [], explicitDeny: [] };
32
+ if (Array.isArray(raw))
33
+ return {
34
+ allow: raw as string[],
35
+ deny: [],
36
+ explicitAllow: [],
37
+ explicitDeny: [],
38
+ };
32
39
  if (typeof raw === 'object') {
33
40
  const obj = raw as { allow?: string[]; deny?: string[] };
34
41
  return {
35
42
  allow: obj.allow ?? ['/**'],
36
43
  deny: obj.deny ?? [],
44
+ explicitAllow: [],
45
+ explicitDeny: [],
37
46
  };
38
47
  }
39
48
  return null;
@@ -56,6 +65,8 @@ export function resolveNamedScopes(
56
65
  return {
57
66
  allow: overrides.allow ?? ['/**'],
58
67
  deny: overrides.deny ?? [],
68
+ explicitAllow: overrides.allow ?? [],
69
+ explicitDeny: overrides.deny ?? [],
59
70
  };
60
71
  }
61
72
  return null;
@@ -91,6 +102,8 @@ export function resolveNamedScopes(
91
102
  return {
92
103
  allow: allow.length > 0 ? allow : ['/**'],
93
104
  deny,
105
+ explicitAllow: overrides?.allow ?? [],
106
+ explicitDeny: overrides?.deny ?? [],
94
107
  };
95
108
  }
96
109
 
@@ -99,6 +112,8 @@ export function resolveNamedScopes(
99
112
  if (!normalized) return null;
100
113
  if (overrides?.allow) normalized.allow.push(...overrides.allow);
101
114
  if (overrides?.deny) normalized.deny.push(...overrides.deny);
115
+ normalized.explicitAllow = overrides?.allow ?? [];
116
+ normalized.explicitDeny = overrides?.deny ?? [];
102
117
  return normalized;
103
118
  }
104
119
 
@@ -11,10 +11,19 @@ export type { AuthMode, JeevesConfig };
11
11
  /**
12
12
  * Normalized scopes — always resolved to \{ allow, deny \} form.
13
13
  * null = unrestricted access.
14
+ *
15
+ * Explicit overrides take precedence over named scope patterns:
16
+ * 1. If explicitDeny matches → DENIED (overrides named allow)
17
+ * 2. If explicitAllow matches → ALLOWED (overrides named deny)
18
+ * 3. Otherwise: normal allow AND NOT deny evaluation
14
19
  */
15
20
  export interface NormalizedScopes {
16
21
  allow: string[];
17
22
  deny: string[];
23
+ /** Explicit allow patterns from the insider/key entry — override named scope denies. */
24
+ explicitAllow: string[];
25
+ /** Explicit deny patterns from the insider/key entry — override named scope allows. */
26
+ explicitDeny: string[];
18
27
  }
19
28
 
20
29
  /**