@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/.tsbuildinfo +1 -1
- package/CHANGELOG.md +5 -2
- package/dist/src/auth/keys.js +13 -1
- package/dist/src/auth/keys.test.js +84 -0
- package/dist/src/config/loadConfig.test.js +6 -1
- package/dist/src/config/resolve.js +15 -2
- package/dist/src/config/resolve.test.js +33 -4
- package/package.json +1 -1
- package/src/auth/keys.test.ts +115 -0
- package/src/auth/keys.ts +18 -1
- package/src/config/loadConfig.test.ts +6 -1
- package/src/config/resolve.test.ts +33 -4
- package/src/config/resolve.ts +17 -2
- package/src/config/types.ts +9 -0
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.
|
|
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)
|
package/dist/src/auth/keys.js
CHANGED
|
@@ -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
|
-
*
|
|
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({
|
|
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 {
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
@@ -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
|
-
*
|
|
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({
|
|
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
});
|
package/src/config/resolve.ts
CHANGED
|
@@ -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')
|
|
31
|
-
|
|
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
|
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
/**
|