@karmaniverous/jeeves-server 3.0.0-0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +18 -1
  3. package/dist/src/config/index.js +11 -2
  4. package/dist/src/config/loadConfig.test.js +28 -7
  5. package/dist/src/config/resolve.js +87 -7
  6. package/dist/src/config/resolve.test.js +37 -7
  7. package/dist/src/config/schema.js +57 -1
  8. package/dist/src/routes/api/fileContent.js +5 -1
  9. package/dist/src/routes/api/middleware.js +2 -0
  10. package/dist/src/routes/api/sharing.js +1 -1
  11. package/dist/src/routes/api/status.js +22 -18
  12. package/dist/src/routes/api/status.test.js +6 -13
  13. package/dist/src/routes/auth.js +1 -1
  14. package/dist/src/routes/keys.js +4 -4
  15. package/dist/src/services/deepShareLinks.js +2 -2
  16. package/dist/src/services/diagramCache.js +2 -2
  17. package/dist/src/services/embeddedDiagrams.js +1 -1
  18. package/dist/src/services/export.js +2 -2
  19. package/dist/src/util/state.js +2 -2
  20. package/knip.json +30 -0
  21. package/package.json +1 -1
  22. package/src/auth/google.ts +2 -2
  23. package/src/auth/resolve.ts +1 -1
  24. package/src/auth/session.ts +1 -1
  25. package/src/config/index.ts +12 -2
  26. package/src/config/loadConfig.test.ts +36 -7
  27. package/src/config/resolve.test.ts +53 -11
  28. package/src/config/resolve.ts +115 -7
  29. package/src/config/schema.ts +63 -1
  30. package/src/routes/api/fileContent.ts +8 -1
  31. package/src/routes/api/middleware.ts +1 -0
  32. package/src/routes/api/sharing.ts +1 -1
  33. package/src/routes/api/status.test.ts +11 -15
  34. package/src/routes/api/status.ts +21 -18
  35. package/src/routes/auth.ts +1 -1
  36. package/src/routes/keys.ts +4 -4
  37. package/src/services/deepShareLinks.ts +2 -2
  38. package/src/services/diagramCache.ts +2 -2
  39. package/src/services/embeddedDiagrams.ts +1 -1
  40. package/src/services/export.ts +3 -3
  41. package/src/util/breadcrumbs.ts +1 -1
  42. package/src/util/state.ts +2 -2
@@ -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
- export function remainingDepth(maxDepth, stack) {
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
- export function computeSubLink(seed, targetUrlPath, currentStack, maxDepth, dirs, exp, isDirectory) {
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
- export function getCachedDiagram(type, source, format = 'svg') {
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
- export function cacheDiagram(type, source, content, format = 'svg') {
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
- export function diagramHash(type, source) {
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
- export async function exportPDF(options) {
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
- export async function exportDOCX(options) {
36
+ async function exportDOCX(options) {
37
37
  const browser = await launchBrowser();
38
38
  try {
39
39
  const page = await browser.newPage();
@@ -6,7 +6,7 @@ import { getConfig } from '../config/index.js';
6
6
  /**
7
7
  * Load state from file
8
8
  */
9
- export function loadState() {
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
- export function saveState(state) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-server",
3
- "version": "3.0.0-0",
3
+ "version": "3.0.0",
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",
@@ -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
- export interface GoogleTokens {
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
- export interface GoogleUserInfo {
18
+ interface GoogleUserInfo {
19
19
  sub: string;
20
20
  email: string;
21
21
  email_verified: boolean;
@@ -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
- export interface AuthResult {
22
+ interface AuthResult {
23
23
  valid: boolean;
24
24
  mode?: AccessMode;
25
25
  seed?: string;
@@ -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
- export interface SessionPayload {
13
+ interface SessionPayload {
14
14
  email: string;
15
15
  picture?: string;
16
16
  exp: number;
@@ -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
- * Reset the config singleton (for testing).
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, resetConfig } from './index.js';
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
- resetConfig();
31
+ clearConfig();
32
32
  });
33
33
 
34
34
  afterEach(() => {
35
35
  fs.rmSync(tmpDir, { recursive: true, force: true });
36
- resetConfig();
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
- resetConfig();
159
+ clearConfig();
131
160
  });
132
161
 
133
162
  afterEach(() => {
134
163
  fs.rmSync(tmpDir, { recursive: true, force: true });
135
- resetConfig();
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('resetConfig clears the singleton', async () => {
178
+ it('clearConfig clears the singleton', async () => {
150
179
  const configPath = writeConfig(tmpDir, VALID_CONFIG);
151
180
  await initConfig(configPath);
152
- resetConfig();
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
- scoped: { key: 'seed456', scopes: ['/docs'] },
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
- plain: 'abc',
70
- complex: { key: 'def', scopes: { allow: ['/x'], deny: ['/y'] } },
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) },
@@ -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<string, string | { key: string; scopes?: unknown }>,
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 { name, seed: entry.key, scopes: normalizeScopes(entry.scopes) };
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<string, { scopes?: unknown }>,
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 = normalizeScopes(entry.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<string, string | { key: string; scopes?: unknown }>,
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<string, { scopes?: unknown }>,
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: normalizeScopes(config.outsiderPolicy) ?? null,
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,