@karmaniverous/jeeves-server 3.0.0-0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuildinfo +1 -1
- package/CHANGELOG.md +18 -1
- package/dist/src/config/index.js +11 -2
- package/dist/src/config/loadConfig.test.js +28 -7
- package/dist/src/config/resolve.js +87 -7
- package/dist/src/config/resolve.test.js +37 -7
- package/dist/src/config/schema.js +57 -1
- package/dist/src/routes/api/fileContent.js +5 -1
- package/dist/src/routes/api/middleware.js +2 -0
- package/dist/src/routes/api/sharing.js +1 -1
- package/dist/src/routes/api/status.js +22 -18
- package/dist/src/routes/api/status.test.js +6 -13
- package/dist/src/routes/auth.js +1 -1
- package/dist/src/routes/keys.js +4 -4
- package/dist/src/services/deepShareLinks.js +2 -2
- package/dist/src/services/diagramCache.js +2 -2
- package/dist/src/services/embeddedDiagrams.js +1 -1
- package/dist/src/services/export.js +2 -2
- package/dist/src/util/state.js +2 -2
- package/knip.json +30 -0
- package/package.json +1 -1
- package/src/auth/google.ts +2 -2
- package/src/auth/resolve.ts +1 -1
- package/src/auth/session.ts +1 -1
- package/src/config/index.ts +12 -2
- package/src/config/loadConfig.test.ts +36 -7
- package/src/config/resolve.test.ts +53 -11
- package/src/config/resolve.ts +115 -7
- package/src/config/schema.ts +63 -1
- package/src/routes/api/fileContent.ts +8 -1
- package/src/routes/api/middleware.ts +1 -0
- package/src/routes/api/sharing.ts +1 -1
- package/src/routes/api/status.test.ts +11 -15
- package/src/routes/api/status.ts +21 -18
- package/src/routes/auth.ts +1 -1
- package/src/routes/keys.ts +4 -4
- package/src/services/deepShareLinks.ts +2 -2
- package/src/services/diagramCache.ts +2 -2
- package/src/services/embeddedDiagrams.ts +1 -1
- package/src/services/export.ts +3 -3
- package/src/util/breadcrumbs.ts +1 -1
- package/src/util/state.ts +2 -2
package/dist/src/routes/keys.js
CHANGED
|
@@ -53,7 +53,7 @@ export const keysRoute = async (fastify) => {
|
|
|
53
53
|
const insiderResult = resolveInsiderKeyAuth(config, provided);
|
|
54
54
|
if (insiderResult.valid && insiderResult.email) {
|
|
55
55
|
// Insider key rotation
|
|
56
|
-
return rotateInsiderSeed(insiderResult.email, config);
|
|
56
|
+
return await rotateInsiderSeed(insiderResult.email, config);
|
|
57
57
|
}
|
|
58
58
|
// Machine key rotation is not supported with TS config
|
|
59
59
|
const matched = config.resolvedKeys.find((rk) => timingSafeEqual(provided, computeInsiderKey(rk.seed)));
|
|
@@ -67,7 +67,7 @@ export const keysRoute = async (fastify) => {
|
|
|
67
67
|
// Try session-based auth
|
|
68
68
|
const sessionResult = resolveSessionAuth(config, request);
|
|
69
69
|
if (sessionResult.valid && sessionResult.email) {
|
|
70
|
-
return rotateInsiderSeed(sessionResult.email, config);
|
|
70
|
+
return await rotateInsiderSeed(sessionResult.email, config);
|
|
71
71
|
}
|
|
72
72
|
return reply.code(401).send({ error: 'Invalid insider key' });
|
|
73
73
|
});
|
|
@@ -93,7 +93,7 @@ export const keysRoute = async (fastify) => {
|
|
|
93
93
|
return reply.code(401).send({ error: 'Invalid insider key' });
|
|
94
94
|
});
|
|
95
95
|
};
|
|
96
|
-
function rotateInsiderSeed(email, config) {
|
|
96
|
+
async function rotateInsiderSeed(email, config) {
|
|
97
97
|
const insider = findInsider(config.resolvedInsiders, email);
|
|
98
98
|
if (!insider?.seed)
|
|
99
99
|
return { ok: false, error: 'Insider not found' };
|
|
@@ -106,7 +106,7 @@ function rotateInsiderSeed(email, config) {
|
|
|
106
106
|
at: timestamp,
|
|
107
107
|
});
|
|
108
108
|
setKeyRotationTimestamp(timestamp);
|
|
109
|
-
resetConfig();
|
|
109
|
+
await resetConfig();
|
|
110
110
|
return { ok: true, keyName: insider.email };
|
|
111
111
|
}
|
|
112
112
|
function buildShareResponse(seed, targetPath, expiry) {
|
|
@@ -33,14 +33,14 @@ export function encodeStack(stack) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Compute the remaining depth for a given stack.
|
|
35
35
|
*/
|
|
36
|
-
|
|
36
|
+
function remainingDepth(maxDepth, stack) {
|
|
37
37
|
return maxDepth - (stack.length - 1);
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* Compute a sub-link URL for an outgoing link target.
|
|
41
41
|
* Returns null if the link should be stripped (depth exhausted or type not allowed).
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
function computeSubLink(seed, targetUrlPath, currentStack, maxDepth, dirs, exp, isDirectory) {
|
|
44
44
|
// Check if directories are allowed
|
|
45
45
|
if (isDirectory && !dirs)
|
|
46
46
|
return null;
|
|
@@ -33,7 +33,7 @@ function cacheKey(type, source) {
|
|
|
33
33
|
* Look up a cached diagram. Returns the content as a string or null on miss.
|
|
34
34
|
* @param format - Output format extension (e.g. 'svg', 'png', 'pdf'). Defaults to 'svg'.
|
|
35
35
|
*/
|
|
36
|
-
|
|
36
|
+
function getCachedDiagram(type, source, format = 'svg') {
|
|
37
37
|
if (!cacheDir)
|
|
38
38
|
return null;
|
|
39
39
|
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
@@ -63,7 +63,7 @@ export function getCachedDiagramBuffer(type, source, format) {
|
|
|
63
63
|
* Store a rendered diagram in the cache (string content).
|
|
64
64
|
* @param format - Output format extension. Defaults to 'svg'.
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
function cacheDiagram(type, source, content, format = 'svg') {
|
|
67
67
|
if (!cacheDir)
|
|
68
68
|
return;
|
|
69
69
|
const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
|
|
@@ -40,7 +40,7 @@ function startCleanup() {
|
|
|
40
40
|
/**
|
|
41
41
|
* Compute content hash matching the cache key format.
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
function diagramHash(type, source) {
|
|
44
44
|
return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
|
|
45
45
|
}
|
|
46
46
|
/** Module-level context directory for the current markdown parse. */
|
|
@@ -12,7 +12,7 @@ const MAX_HEIGHT_PX = 768;
|
|
|
12
12
|
/**
|
|
13
13
|
* Export page as PDF.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
async function exportPDF(options) {
|
|
16
16
|
const browser = await launchBrowser();
|
|
17
17
|
try {
|
|
18
18
|
const page = await browser.newPage();
|
|
@@ -33,7 +33,7 @@ export async function exportPDF(options) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Export page as DOCX.
|
|
35
35
|
*/
|
|
36
|
-
|
|
36
|
+
async function exportDOCX(options) {
|
|
37
37
|
const browser = await launchBrowser();
|
|
38
38
|
try {
|
|
39
39
|
const page = await browser.newPage();
|
package/dist/src/util/state.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getConfig } from '../config/index.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Load state from file
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
function loadState() {
|
|
10
10
|
const { stateFile } = getConfig();
|
|
11
11
|
try {
|
|
12
12
|
if (fs.existsSync(stateFile)) {
|
|
@@ -22,7 +22,7 @@ export function loadState() {
|
|
|
22
22
|
/**
|
|
23
23
|
* Save state to file
|
|
24
24
|
*/
|
|
25
|
-
|
|
25
|
+
function saveState(state) {
|
|
26
26
|
const { stateFile } = getConfig();
|
|
27
27
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
28
28
|
}
|
package/knip.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://unpkg.com/knip@latest/schema.json",
|
|
3
|
+
"entry": [
|
|
4
|
+
"src/cli/index.ts"
|
|
5
|
+
],
|
|
6
|
+
"project": [
|
|
7
|
+
"src/**/*.ts"
|
|
8
|
+
],
|
|
9
|
+
"ignoreBinaries": [
|
|
10
|
+
"tsx",
|
|
11
|
+
"rimraf",
|
|
12
|
+
"tsc",
|
|
13
|
+
"cross-env",
|
|
14
|
+
"vitest",
|
|
15
|
+
"auto-changelog",
|
|
16
|
+
"dotenvx",
|
|
17
|
+
"release-it",
|
|
18
|
+
"eslint",
|
|
19
|
+
"knip"
|
|
20
|
+
],
|
|
21
|
+
"ignoreDependencies": [
|
|
22
|
+
"puppeteer",
|
|
23
|
+
"@dotenvx/dotenvx",
|
|
24
|
+
"auto-changelog",
|
|
25
|
+
"cross-env",
|
|
26
|
+
"release-it",
|
|
27
|
+
"rimraf"
|
|
28
|
+
],
|
|
29
|
+
"ignoreExportsUsedInFile": true
|
|
30
|
+
}
|
package/package.json
CHANGED
package/src/auth/google.ts
CHANGED
|
@@ -7,7 +7,7 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
|
7
7
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
8
8
|
const GOOGLE_USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface GoogleTokens {
|
|
11
11
|
access_token: string;
|
|
12
12
|
id_token?: string;
|
|
13
13
|
refresh_token?: string;
|
|
@@ -15,7 +15,7 @@ export interface GoogleTokens {
|
|
|
15
15
|
token_type: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
interface GoogleUserInfo {
|
|
19
19
|
sub: string;
|
|
20
20
|
email: string;
|
|
21
21
|
email_verified: boolean;
|
package/src/auth/resolve.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { formatRelativeTime } from '../util/formatters.js';
|
|
|
19
19
|
import { verifyKey } from './keys.js';
|
|
20
20
|
import { COOKIE_NAME, verifySessionCookie } from './session.js';
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface AuthResult {
|
|
23
23
|
valid: boolean;
|
|
24
24
|
mode?: AccessMode;
|
|
25
25
|
seed?: string;
|
package/src/auth/session.ts
CHANGED
|
@@ -10,7 +10,7 @@ import crypto from 'node:crypto';
|
|
|
10
10
|
const COOKIE_NAME = 'jeeves_session';
|
|
11
11
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
interface SessionPayload {
|
|
14
14
|
email: string;
|
|
15
15
|
picture?: string;
|
|
16
16
|
exp: number;
|
package/src/config/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export async function loadConfig(configPath?: string): Promise<RuntimeConfig> {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
let configInstance: RuntimeConfig | null = null;
|
|
78
|
+
let lastConfigPath: string | undefined;
|
|
78
79
|
|
|
79
80
|
/**
|
|
80
81
|
* Check if the config singleton has been initialized.
|
|
@@ -101,13 +102,22 @@ export function getConfig(): RuntimeConfig {
|
|
|
101
102
|
* @param configPath - Optional explicit path to a config file.
|
|
102
103
|
*/
|
|
103
104
|
export async function initConfig(configPath?: string): Promise<RuntimeConfig> {
|
|
105
|
+
lastConfigPath = configPath;
|
|
104
106
|
configInstance = await loadConfig(configPath);
|
|
105
107
|
return configInstance;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
|
-
*
|
|
111
|
+
* Reload the config singleton from the last-used config path.
|
|
112
|
+
* Call after mutating state that affects resolved config (e.g., key rotation).
|
|
110
113
|
*/
|
|
111
|
-
export function resetConfig(): void {
|
|
114
|
+
export async function resetConfig(): Promise<void> {
|
|
115
|
+
configInstance = await loadConfig(lastConfigPath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clear the config singleton (for testing only).
|
|
120
|
+
*/
|
|
121
|
+
export function clearConfig(): void {
|
|
112
122
|
configInstance = null;
|
|
113
123
|
}
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import { getConfig, initConfig, loadConfig
|
|
7
|
+
import { clearConfig, getConfig, initConfig, loadConfig } from './index.js';
|
|
8
8
|
|
|
9
9
|
const VALID_CONFIG = {
|
|
10
10
|
port: 9999,
|
|
@@ -28,12 +28,12 @@ describe('loadConfig', () => {
|
|
|
28
28
|
|
|
29
29
|
beforeEach(() => {
|
|
30
30
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-config-'));
|
|
31
|
-
|
|
31
|
+
clearConfig();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
afterEach(() => {
|
|
35
35
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
36
|
-
|
|
36
|
+
clearConfig();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('loads a valid JSON config file', async () => {
|
|
@@ -120,6 +120,35 @@ describe('loadConfig', () => {
|
|
|
120
120
|
'c'.repeat(64),
|
|
121
121
|
);
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it('rejects undefined named scope references', async () => {
|
|
125
|
+
const configPath = writeConfig(tmpDir, {
|
|
126
|
+
...VALID_CONFIG,
|
|
127
|
+
scopes: { restricted: { allow: ['/**'], deny: ['/secret'] } },
|
|
128
|
+
insiders: {
|
|
129
|
+
'a@example.com': { scopes: 'restricted' },
|
|
130
|
+
'b@example.com': { scopes: 'missing' },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await expect(loadConfig(configPath)).rejects.toThrow(
|
|
135
|
+
'Scope "missing" is not defined',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not treat path globs as named scope references', async () => {
|
|
140
|
+
const configPath = writeConfig(tmpDir, {
|
|
141
|
+
...VALID_CONFIG,
|
|
142
|
+
insiders: {
|
|
143
|
+
'a@example.com': { scopes: ['/docs/**'] },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const config = await loadConfig(configPath);
|
|
148
|
+
expect(
|
|
149
|
+
config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes,
|
|
150
|
+
).toEqual({ allow: ['/docs/**'], deny: [] });
|
|
151
|
+
});
|
|
123
152
|
});
|
|
124
153
|
|
|
125
154
|
describe('config singleton', () => {
|
|
@@ -127,12 +156,12 @@ describe('config singleton', () => {
|
|
|
127
156
|
|
|
128
157
|
beforeEach(() => {
|
|
129
158
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-config-'));
|
|
130
|
-
|
|
159
|
+
clearConfig();
|
|
131
160
|
});
|
|
132
161
|
|
|
133
162
|
afterEach(() => {
|
|
134
163
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
135
|
-
|
|
164
|
+
clearConfig();
|
|
136
165
|
});
|
|
137
166
|
|
|
138
167
|
it('throws if getConfig called before initConfig', () => {
|
|
@@ -146,10 +175,10 @@ describe('config singleton', () => {
|
|
|
146
175
|
expect(config.port).toBe(9999);
|
|
147
176
|
});
|
|
148
177
|
|
|
149
|
-
it('
|
|
178
|
+
it('clearConfig clears the singleton', async () => {
|
|
150
179
|
const configPath = writeConfig(tmpDir, VALID_CONFIG);
|
|
151
180
|
await initConfig(configPath);
|
|
152
|
-
|
|
181
|
+
clearConfig();
|
|
153
182
|
expect(() => getConfig()).toThrow('Config not initialized');
|
|
154
183
|
});
|
|
155
184
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
normalizeScopes,
|
|
11
11
|
resolveInsiders,
|
|
12
12
|
resolveKeys,
|
|
13
|
+
resolveNamedScopes,
|
|
13
14
|
resolvePlantuml,
|
|
14
15
|
} from './resolve.js';
|
|
15
16
|
import type { JeevesConfig } from './schema.js';
|
|
@@ -49,26 +50,32 @@ describe('normalizeScopes', () => {
|
|
|
49
50
|
|
|
50
51
|
describe('resolveKeys', () => {
|
|
51
52
|
it('handles string key entries', () => {
|
|
52
|
-
const result = resolveKeys({ primary: 'seed123' });
|
|
53
|
+
const result = resolveKeys({ primary: 'seed123' }, {});
|
|
53
54
|
expect(result).toEqual([
|
|
54
55
|
{ name: 'primary', seed: 'seed123', scopes: null },
|
|
55
56
|
]);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
it('handles object key entries with scopes', () => {
|
|
59
|
-
const result = resolveKeys(
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const result = resolveKeys(
|
|
61
|
+
{
|
|
62
|
+
scoped: { key: 'seed456', scopes: ['/docs'] },
|
|
63
|
+
},
|
|
64
|
+
{},
|
|
65
|
+
);
|
|
62
66
|
expect(result[0].name).toBe('scoped');
|
|
63
67
|
expect(result[0].seed).toBe('seed456');
|
|
64
68
|
expect(result[0].scopes).toEqual({ allow: ['/docs'], deny: [] });
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
it('handles mixed entries', () => {
|
|
68
|
-
const result = resolveKeys(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const result = resolveKeys(
|
|
73
|
+
{
|
|
74
|
+
plain: 'abc',
|
|
75
|
+
complex: { key: 'def', scopes: { allow: ['/x'], deny: ['/y'] } },
|
|
76
|
+
},
|
|
77
|
+
{},
|
|
78
|
+
);
|
|
72
79
|
expect(result).toHaveLength(2);
|
|
73
80
|
expect(result[0].scopes).toBe(null);
|
|
74
81
|
expect(result[1].scopes).toEqual({ allow: ['/x'], deny: ['/y'] });
|
|
@@ -88,7 +95,7 @@ describe('resolveInsiders', () => {
|
|
|
88
95
|
|
|
89
96
|
it('normalizes email to lowercase', () => {
|
|
90
97
|
const stateFile = path.join(tmpDir, 'state.json');
|
|
91
|
-
const result = resolveInsiders({ 'Test@Example.COM': {} }, stateFile);
|
|
98
|
+
const result = resolveInsiders({ 'Test@Example.COM': {} }, {}, stateFile);
|
|
92
99
|
expect(result[0].email).toBe('test@example.com');
|
|
93
100
|
});
|
|
94
101
|
|
|
@@ -102,19 +109,53 @@ describe('resolveInsiders', () => {
|
|
|
102
109
|
},
|
|
103
110
|
}),
|
|
104
111
|
);
|
|
105
|
-
const result = resolveInsiders({ 'test@example.com': {} }, stateFile);
|
|
112
|
+
const result = resolveInsiders({ 'test@example.com': {} }, {}, stateFile);
|
|
106
113
|
expect(result[0].seed).toBe('stateseed');
|
|
107
114
|
expect(result[0].keyCreatedAt).toBe('2026-01-01');
|
|
108
115
|
});
|
|
109
116
|
|
|
110
117
|
it('returns empty seed when no state exists', () => {
|
|
111
118
|
const stateFile = path.join(tmpDir, 'state.json');
|
|
112
|
-
const result = resolveInsiders({ 'new@example.com': {} }, stateFile);
|
|
119
|
+
const result = resolveInsiders({ 'new@example.com': {} }, {}, stateFile);
|
|
113
120
|
expect(result[0].seed).toBe('');
|
|
114
121
|
expect(result[0].keyCreatedAt).toBe(null);
|
|
115
122
|
});
|
|
116
123
|
});
|
|
117
124
|
|
|
125
|
+
describe('resolveNamedScopes', () => {
|
|
126
|
+
it('resolves a single named scope', () => {
|
|
127
|
+
const named = { restricted: { allow: ['/**'], deny: ['/secret'] } };
|
|
128
|
+
expect(resolveNamedScopes(named, 'restricted')).toEqual({
|
|
129
|
+
allow: ['/**'],
|
|
130
|
+
deny: ['/secret'],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('unions multiple named scopes and merges overrides', () => {
|
|
135
|
+
const named = {
|
|
136
|
+
restricted: { allow: ['/**'], deny: ['/secret'] },
|
|
137
|
+
noVc: { deny: ['/vc/**'] },
|
|
138
|
+
};
|
|
139
|
+
expect(
|
|
140
|
+
resolveNamedScopes(named, ['restricted', 'noVc'], {
|
|
141
|
+
allow: ['/extra'],
|
|
142
|
+
deny: ['/more'],
|
|
143
|
+
}),
|
|
144
|
+
).toEqual({
|
|
145
|
+
allow: ['/**', '/extra'],
|
|
146
|
+
deny: ['/secret', '/vc/**', '/more'],
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('falls back to legacy inline scope strings', () => {
|
|
151
|
+
const named = { restricted: { allow: ['/**'] } };
|
|
152
|
+
expect(resolveNamedScopes(named, '/docs')).toEqual({
|
|
153
|
+
allow: ['/docs'],
|
|
154
|
+
deny: [],
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
118
159
|
describe('resolvePlantuml', () => {
|
|
119
160
|
it('appends community server as fallback', () => {
|
|
120
161
|
const result = resolvePlantuml();
|
|
@@ -170,6 +211,7 @@ describe('buildRuntimeConfig', () => {
|
|
|
170
211
|
eventLogPurgeMs: 2592000000,
|
|
171
212
|
maxZipSizeMb: 100,
|
|
172
213
|
chromePath: '/usr/bin/chrome',
|
|
214
|
+
scopes: {},
|
|
173
215
|
events: {},
|
|
174
216
|
auth: { modes: ['keys' as const] },
|
|
175
217
|
keys: { primary: 'a'.repeat(64) },
|
package/src/config/resolve.ts
CHANGED
|
@@ -39,17 +39,92 @@ export function normalizeScopes(raw: unknown): NormalizedScopes | null {
|
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Resolve named scope references (string or string[]) against the top-level scopes map,
|
|
44
|
+
* optionally merging explicit allow/deny overrides.
|
|
45
|
+
*
|
|
46
|
+
* Returns null if no scopes are provided.
|
|
47
|
+
* Falls back to normalizeScopes() for legacy inline scope formats.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveNamedScopes(
|
|
50
|
+
named: Record<string, { allow?: string[]; deny?: string[] } | undefined>,
|
|
51
|
+
rawScopes: unknown,
|
|
52
|
+
overrides?: { allow?: string[]; deny?: string[] },
|
|
53
|
+
): NormalizedScopes | null {
|
|
54
|
+
if (rawScopes === undefined || rawScopes === null) {
|
|
55
|
+
if (overrides?.allow?.length || overrides?.deny?.length) {
|
|
56
|
+
return {
|
|
57
|
+
allow: overrides.allow ?? ['/**'],
|
|
58
|
+
deny: overrides.deny ?? [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Determine whether rawScopes is a named reference (identifier(s))
|
|
65
|
+
const isName = (v: string): boolean => /^[A-Za-z0-9_-]+$/.test(v);
|
|
66
|
+
|
|
67
|
+
const refs =
|
|
68
|
+
typeof rawScopes === 'string'
|
|
69
|
+
? isName(rawScopes)
|
|
70
|
+
? [rawScopes]
|
|
71
|
+
: null
|
|
72
|
+
: Array.isArray(rawScopes) &&
|
|
73
|
+
rawScopes.every((v) => typeof v === 'string')
|
|
74
|
+
? rawScopes.filter((v) => isName(v))
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
if (refs && refs.length > 0) {
|
|
78
|
+
const allow: string[] = [];
|
|
79
|
+
const deny: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const ref of refs) {
|
|
82
|
+
const scope = named[ref];
|
|
83
|
+
if (!scope) continue; // schema should have validated
|
|
84
|
+
if (scope.allow) allow.push(...scope.allow);
|
|
85
|
+
if (scope.deny) deny.push(...scope.deny);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (overrides?.allow) allow.push(...overrides.allow);
|
|
89
|
+
if (overrides?.deny) deny.push(...overrides.deny);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
allow: allow.length > 0 ? allow : ['/**'],
|
|
93
|
+
deny,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Legacy inline scopes
|
|
98
|
+
const normalized = normalizeScopes(rawScopes);
|
|
99
|
+
if (!normalized) return null;
|
|
100
|
+
if (overrides?.allow) normalized.allow.push(...overrides.allow);
|
|
101
|
+
if (overrides?.deny) normalized.deny.push(...overrides.deny);
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
42
105
|
/**
|
|
43
106
|
* Resolve raw key entries to ResolvedKey[].
|
|
44
107
|
*/
|
|
45
108
|
export function resolveKeys(
|
|
46
|
-
keys: Record<
|
|
109
|
+
keys: Record<
|
|
110
|
+
string,
|
|
111
|
+
| string
|
|
112
|
+
| { key: string; scopes?: unknown; allow?: string[]; deny?: string[] }
|
|
113
|
+
>,
|
|
114
|
+
namedScopes: Record<string, { allow?: string[]; deny?: string[] }>,
|
|
47
115
|
): ResolvedKey[] {
|
|
48
116
|
return Object.entries(keys).map(([name, entry]) => {
|
|
49
117
|
if (typeof entry === 'string') {
|
|
50
118
|
return { name, seed: entry, scopes: null };
|
|
51
119
|
}
|
|
52
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
name,
|
|
122
|
+
seed: entry.key,
|
|
123
|
+
scopes: resolveNamedScopes(namedScopes, entry.scopes, {
|
|
124
|
+
allow: entry.allow,
|
|
125
|
+
deny: entry.deny,
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
53
128
|
});
|
|
54
129
|
}
|
|
55
130
|
|
|
@@ -57,7 +132,11 @@ export function resolveKeys(
|
|
|
57
132
|
* Resolve insider entries by merging config (identity + scopes) with state (keys).
|
|
58
133
|
*/
|
|
59
134
|
export function resolveInsiders(
|
|
60
|
-
insiders: Record<
|
|
135
|
+
insiders: Record<
|
|
136
|
+
string,
|
|
137
|
+
{ scopes?: unknown; allow?: string[]; deny?: string[] }
|
|
138
|
+
>,
|
|
139
|
+
namedScopes: Record<string, { allow?: string[]; deny?: string[] }>,
|
|
61
140
|
stateFile: string,
|
|
62
141
|
): ResolvedInsider[] {
|
|
63
142
|
let serverState: ServerState = {};
|
|
@@ -73,7 +152,10 @@ export function resolveInsiders(
|
|
|
73
152
|
|
|
74
153
|
return Object.entries(insiders).map(([rawEmail, entry]) => {
|
|
75
154
|
const email = rawEmail.toLowerCase();
|
|
76
|
-
const scopes =
|
|
155
|
+
const scopes = resolveNamedScopes(namedScopes, entry.scopes, {
|
|
156
|
+
allow: entry.allow,
|
|
157
|
+
deny: entry.deny,
|
|
158
|
+
});
|
|
77
159
|
const stateKey = serverState.insiderKeys?.[email];
|
|
78
160
|
return {
|
|
79
161
|
email,
|
|
@@ -135,10 +217,19 @@ export function buildRuntimeConfig(
|
|
|
135
217
|
const stateFile = path.join(rootDir, 'state.json');
|
|
136
218
|
|
|
137
219
|
const resolvedKeys = resolveKeys(
|
|
138
|
-
config.keys as Record<
|
|
220
|
+
config.keys as Record<
|
|
221
|
+
string,
|
|
222
|
+
| string
|
|
223
|
+
| { key: string; scopes?: unknown; allow?: string[]; deny?: string[] }
|
|
224
|
+
>,
|
|
225
|
+
config.scopes as Record<string, { allow?: string[]; deny?: string[] }>,
|
|
139
226
|
);
|
|
140
227
|
const resolvedInsiders = resolveInsiders(
|
|
141
|
-
config.insiders as Record<
|
|
228
|
+
config.insiders as Record<
|
|
229
|
+
string,
|
|
230
|
+
{ scopes?: unknown; allow?: string[]; deny?: string[] }
|
|
231
|
+
>,
|
|
232
|
+
config.scopes as Record<string, { allow?: string[]; deny?: string[] }>,
|
|
142
233
|
stateFile,
|
|
143
234
|
);
|
|
144
235
|
|
|
@@ -152,7 +243,24 @@ export function buildRuntimeConfig(
|
|
|
152
243
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
153
244
|
mermaidCliPath: config.mermaidCliPath,
|
|
154
245
|
plantuml: resolvePlantuml(config.plantuml, rootDir),
|
|
155
|
-
outsiderPolicy:
|
|
246
|
+
outsiderPolicy:
|
|
247
|
+
resolveNamedScopes(
|
|
248
|
+
config.scopes as Record<string, { allow?: string[]; deny?: string[] }>,
|
|
249
|
+
(config.outsiderPolicy as unknown) &&
|
|
250
|
+
typeof config.outsiderPolicy === 'object' &&
|
|
251
|
+
!Array.isArray(config.outsiderPolicy)
|
|
252
|
+
? ((config.outsiderPolicy as { scopes?: unknown }).scopes ??
|
|
253
|
+
config.outsiderPolicy)
|
|
254
|
+
: config.outsiderPolicy,
|
|
255
|
+
(config.outsiderPolicy as unknown) &&
|
|
256
|
+
typeof config.outsiderPolicy === 'object' &&
|
|
257
|
+
!Array.isArray(config.outsiderPolicy)
|
|
258
|
+
? {
|
|
259
|
+
allow: (config.outsiderPolicy as { allow?: string[] }).allow,
|
|
260
|
+
deny: (config.outsiderPolicy as { deny?: string[] }).deny,
|
|
261
|
+
}
|
|
262
|
+
: undefined,
|
|
263
|
+
) ?? null,
|
|
156
264
|
events: config.events,
|
|
157
265
|
authModes: config.auth.modes,
|
|
158
266
|
resolvedKeys,
|