@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
@@ -57,6 +57,10 @@ export const scopesSchema = z.union([
57
57
  /** Insider entry (identity + scopes only; keys are in state.json) */
58
58
  export const insiderEntrySchema = z.object({
59
59
  scopes: scopesSchema.optional(),
60
+ /** Extra allow patterns merged on top of named scope references */
61
+ allow: z.array(z.string()).optional(),
62
+ /** Extra deny patterns merged on top of named scope references */
63
+ deny: z.array(z.string()).optional(),
60
64
  });
61
65
 
62
66
  /** Key entry â€" plain string (seed, no scopes) or object with key + optional scopes */
@@ -65,15 +69,41 @@ export const keyEntrySchema = z.union([
65
69
  z.object({
66
70
  key: z.string().min(1),
67
71
  scopes: scopesSchema.optional(),
72
+ /** Extra allow patterns merged on top of named scope references */
73
+ allow: z.array(z.string()).optional(),
74
+ /** Extra deny patterns merged on top of named scope references */
75
+ deny: z.array(z.string()).optional(),
68
76
  }),
69
77
  ]);
70
78
 
79
+ /** Helper: collect named scope references from a scopes field value.
80
+ *
81
+ * Backward-compat note: `scopes` has historically accepted path globs like "/**" or ["/a","/b"].
82
+ * We only treat values as *named scope references* if they look like identifiers
83
+ * (e.g. "restricted", "no-vc") rather than path globs.
84
+ */
85
+ function getScopeRefs(scopes: unknown): string[] {
86
+ const isName = (v: string): boolean => /^[A-Za-z0-9_-]+$/.test(v);
87
+
88
+ if (typeof scopes === 'string') return isName(scopes) ? [scopes] : [];
89
+
90
+ if (Array.isArray(scopes) && scopes.length > 0) {
91
+ const strs = scopes.filter((v) => typeof v === 'string');
92
+ if (strs.length !== scopes.length) return [];
93
+ return strs.filter((v) => isName(v));
94
+ }
95
+
96
+ return [];
97
+ }
98
+
71
99
  /** Top-level Jeeves Server configuration */
72
100
  export const jeevesConfigSchema = z
73
101
  .object({
74
102
  port: z.number().int().positive().default(1934),
75
103
  chromePath: z.string().min(1),
76
104
  auth: authSchema,
105
+ /** Named scope definitions, referenced by insiders/keys/outsiderPolicy */
106
+ scopes: z.record(z.string(), scopesObjectSchema).default({}),
77
107
  insiders: z.record(z.email(), insiderEntrySchema).default({}),
78
108
  keys: z.record(z.string(), keyEntrySchema).default({}),
79
109
  events: z.record(z.string(), eventConfigSchema).default({}),
@@ -125,7 +155,7 @@ export const jeevesConfigSchema = z
125
155
  * Uses the same allow/deny model as insider scopes.
126
156
  * If omitted, all paths are shareable with outsiders.
127
157
  */
128
- outsiderPolicy: scopesObjectSchema.optional(),
158
+ outsiderPolicy: scopesSchema.optional(),
129
159
  })
130
160
  .superRefine((config, ctx) => {
131
161
  // Google auth mode requires google config + sessionSecret
@@ -170,6 +200,38 @@ export const jeevesConfigSchema = z
170
200
  });
171
201
  }
172
202
  }
203
+
204
+ // Validate all scope name references resolve to the top-level scopes map
205
+ const scopeNames = new Set(Object.keys(config.scopes));
206
+ const validateRefs = (
207
+ refs: string[],
208
+ refPath: (string | number)[],
209
+ ): void => {
210
+ for (const ref of refs) {
211
+ if (!scopeNames.has(ref)) {
212
+ ctx.addIssue({
213
+ code: 'custom',
214
+ message: `Scope "${ref}" is not defined in the top-level scopes map`,
215
+ path: refPath,
216
+ });
217
+ }
218
+ }
219
+ };
220
+
221
+ for (const [email, entry] of Object.entries(config.insiders)) {
222
+ const refs = getScopeRefs(entry.scopes);
223
+ if (refs.length > 0) validateRefs(refs, ['insiders', email, 'scopes']);
224
+ }
225
+
226
+ for (const [name, entry] of Object.entries(config.keys)) {
227
+ if (typeof entry === 'object') {
228
+ const refs = getScopeRefs(entry.scopes);
229
+ if (refs.length > 0) validateRefs(refs, ['keys', name, 'scopes']);
230
+ }
231
+ }
232
+
233
+ const outsiderRefs = getScopeRefs(config.outsiderPolicy);
234
+ if (outsiderRefs.length > 0) validateRefs(outsiderRefs, ['outsiderPolicy']);
173
235
  });
174
236
 
175
237
  /** Inferred config type */
@@ -266,7 +266,14 @@ async function tryWatcherRender(
266
266
  const res = await fetch(`${config.watcherUrl}/render`, {
267
267
  method: 'POST',
268
268
  headers: { 'Content-Type': 'application/json' },
269
- body: JSON.stringify({ path: fsPath }),
269
+ body: JSON.stringify({
270
+ path: fsPath
271
+ .replace(/\\/g, '/')
272
+ .replace(
273
+ /^([A-Z]):/,
274
+ (_: string, d: string) => d.toLowerCase() + ':',
275
+ ),
276
+ }),
270
277
  signal: AbortSignal.timeout(5000),
271
278
  });
272
279
 
@@ -24,6 +24,7 @@ export function addAuthMiddleware(fastify: FastifyInstance): void {
24
24
  if (request.url.startsWith('/api/content-link/')) return;
25
25
  if (request.url.startsWith('/api/auth/status')) return;
26
26
  if (request.url.startsWith('/api/diagram/')) return;
27
+ if (request.url.startsWith('/api/status')) return;
27
28
 
28
29
  const config = getConfig();
29
30
 
@@ -132,7 +132,7 @@ export const sharingRoutes: FastifyPluginAsync = async (fastify) => {
132
132
  const newSeed = crypto.randomBytes(32).toString('hex');
133
133
  const now = new Date().toISOString();
134
134
  setInsiderKey(insider.email, newSeed, now);
135
- resetConfig();
135
+ await resetConfig();
136
136
 
137
137
  return reply.send({ ok: true, keyCreatedAt: now });
138
138
  });
@@ -29,7 +29,7 @@ vi.mock('../../config/index.js', () => ({
29
29
  const { statusRoutes } = await import('./status.js');
30
30
 
31
31
  describe('GET /api/status', () => {
32
- it('returns structured status for insider requests', async () => {
32
+ it('returns structured status', async () => {
33
33
  // Create a minimal Fastify-like test harness
34
34
  const routes: Record<string, (req: unknown) => Promise<unknown>> = {};
35
35
  const fakeFastify = {
@@ -53,20 +53,16 @@ describe('GET /api/status', () => {
53
53
  expect((status.auth as { insiderCount: number }).insiderCount).toBe(2);
54
54
  expect((status.auth as { keyCount: number }).keyCount).toBe(1);
55
55
  expect(status.events).toHaveLength(2);
56
- expect(status.exportFormats).toEqual(['pdf', 'docx', 'zip']);
57
- expect((status.diagrams as { mermaid: boolean }).mermaid).toBe(true);
58
- });
59
-
60
- it('rejects non-insider requests', async () => {
61
- const routes: Record<string, (req: unknown) => Promise<unknown>> = {};
62
- const fakeFastify = {
63
- get: (path: string, handler: (req: unknown) => Promise<unknown>) => {
64
- routes[path] = handler;
65
- },
56
+ const exports = status.exports as {
57
+ documents: string[];
58
+ directories: string[];
59
+ diagrams: string[];
60
+ chromeAvailable: boolean;
66
61
  };
67
-
68
- await statusRoutes(fakeFastify as never, {});
69
- const result = await routes['/api/status']({ accessMode: 'outsider' });
70
- expect(result).toEqual({ error: 'Insider auth required' });
62
+ expect(exports.documents).toEqual(['pdf', 'docx']);
63
+ expect(exports.directories).toEqual(['zip']);
64
+ expect(exports.diagrams).toEqual(['svg', 'png']);
65
+ expect(exports.chromeAvailable).toBe(true);
66
+ expect((status.diagrams as { mermaid: boolean }).mermaid).toBe(true);
71
67
  });
72
68
  });
@@ -19,30 +19,28 @@ interface ServiceStatus {
19
19
  }
20
20
 
21
21
  async function checkService(url: string): Promise<ServiceStatus> {
22
- try {
23
- const res = await fetch(`${url}/status`, {
24
- signal: AbortSignal.timeout(3000),
25
- });
26
- if (res.ok) {
27
- const data = (await res.json()) as { version?: string };
28
- return { url, reachable: true, version: data.version };
22
+ // Try /status first (watcher), then /health (runner)
23
+ for (const endpoint of ['/status', '/health']) {
24
+ try {
25
+ const res = await fetch(`${url}${endpoint}`, {
26
+ signal: AbortSignal.timeout(3000),
27
+ });
28
+ if (res.ok) {
29
+ const data = (await res.json()) as { version?: string };
30
+ return { url, reachable: true, version: data.version };
31
+ }
32
+ } catch {
33
+ // try next endpoint
29
34
  }
30
- return { url, reachable: false };
31
- } catch {
32
- return { url, reachable: false };
33
35
  }
36
+ return { url, reachable: false };
34
37
  }
35
38
 
36
39
  // eslint-disable-next-line @typescript-eslint/require-await
37
40
  export const statusRoutes: FastifyPluginAsync = async (fastify) => {
38
- fastify.get('/api/status', async (request) => {
41
+ fastify.get('/api/status', async () => {
39
42
  const config = getConfig();
40
43
 
41
- // Only insiders get status
42
- if (request.accessMode !== 'insider') {
43
- return { error: 'Insider auth required' };
44
- }
45
-
46
44
  const [watcher, runner] = await Promise.all([
47
45
  config.watcherUrl ? checkService(config.watcherUrl) : null,
48
46
  config.runnerUrl ? checkService(config.runnerUrl) : null,
@@ -65,9 +63,14 @@ export const statusRoutes: FastifyPluginAsync = async (fastify) => {
65
63
  name,
66
64
  cmd: schema.cmd,
67
65
  })),
68
- exportFormats: ['pdf', 'docx', 'zip'],
66
+ exports: {
67
+ documents: ['pdf', 'docx'],
68
+ directories: ['zip'],
69
+ diagrams: ['svg', 'png'],
70
+ chromeAvailable: Boolean(config.chromePath),
71
+ },
69
72
  diagrams: {
70
- mermaid: true, // bundled via @mermaid-js/mermaid-cli
73
+ mermaid: true,
71
74
  plantuml: {
72
75
  localJar: Boolean(config.plantuml.jarPath),
73
76
  servers: config.plantuml.servers,
@@ -108,7 +108,7 @@ export const authRoute: FastifyPluginAsync = async (fastify) => {
108
108
 
109
109
  // Persist to state.json (mutable runtime state)
110
110
  setInsiderKey(insider.email, newSeed, timestamp);
111
- resetConfig(); // Reload to pick up new state
111
+ await resetConfig(); // Reload to pick up new state
112
112
  }
113
113
 
114
114
  // Set session cookie
@@ -86,7 +86,7 @@ export const keysRoute: FastifyPluginAsync = async (fastify) => {
86
86
  const insiderResult = resolveInsiderKeyAuth(config, provided);
87
87
  if (insiderResult.valid && insiderResult.email) {
88
88
  // Insider key rotation
89
- return rotateInsiderSeed(insiderResult.email, config);
89
+ return await rotateInsiderSeed(insiderResult.email, config);
90
90
  }
91
91
 
92
92
  // Machine key rotation is not supported with TS config
@@ -105,7 +105,7 @@ export const keysRoute: FastifyPluginAsync = async (fastify) => {
105
105
  // Try session-based auth
106
106
  const sessionResult = resolveSessionAuth(config, request);
107
107
  if (sessionResult.valid && sessionResult.email) {
108
- return rotateInsiderSeed(sessionResult.email, config);
108
+ return await rotateInsiderSeed(sessionResult.email, config);
109
109
  }
110
110
 
111
111
  return reply.code(401).send({ error: 'Invalid insider key' });
@@ -149,7 +149,7 @@ export const keysRoute: FastifyPluginAsync = async (fastify) => {
149
149
  );
150
150
  };
151
151
 
152
- function rotateInsiderSeed(
152
+ async function rotateInsiderSeed(
153
153
  email: string,
154
154
  config: ReturnType<typeof getConfig>,
155
155
  ) {
@@ -166,7 +166,7 @@ function rotateInsiderSeed(
166
166
  at: timestamp,
167
167
  });
168
168
  setKeyRotationTimestamp(timestamp);
169
- resetConfig();
169
+ await resetConfig();
170
170
 
171
171
  return { ok: true, keyName: insider.email };
172
172
  }
@@ -35,7 +35,7 @@ export function encodeStack(stack: string[]): string {
35
35
  /**
36
36
  * Compute the remaining depth for a given stack.
37
37
  */
38
- export function remainingDepth(maxDepth: number, stack: string[]): number {
38
+ function remainingDepth(maxDepth: number, stack: string[]): number {
39
39
  return maxDepth - (stack.length - 1);
40
40
  }
41
41
 
@@ -43,7 +43,7 @@ export function remainingDepth(maxDepth: number, stack: string[]): number {
43
43
  * Compute a sub-link URL for an outgoing link target.
44
44
  * Returns null if the link should be stripped (depth exhausted or type not allowed).
45
45
  */
46
- export function computeSubLink(
46
+ function computeSubLink(
47
47
  seed: string,
48
48
  targetUrlPath: string,
49
49
  currentStack: string[],
@@ -39,7 +39,7 @@ function cacheKey(type: string, source: string): string {
39
39
  * Look up a cached diagram. Returns the content as a string or null on miss.
40
40
  * @param format - Output format extension (e.g. 'svg', 'png', 'pdf'). Defaults to 'svg'.
41
41
  */
42
- export function getCachedDiagram(
42
+ function getCachedDiagram(
43
43
  type: string,
44
44
  source: string,
45
45
  format: string = 'svg',
@@ -75,7 +75,7 @@ export function getCachedDiagramBuffer(
75
75
  * Store a rendered diagram in the cache (string content).
76
76
  * @param format - Output format extension. Defaults to 'svg'.
77
77
  */
78
- export function cacheDiagram(
78
+ function cacheDiagram(
79
79
  type: string,
80
80
  source: string,
81
81
  content: string,
@@ -50,7 +50,7 @@ function startCleanup(): void {
50
50
  /**
51
51
  * Compute content hash matching the cache key format.
52
52
  */
53
- export function diagramHash(type: string, source: string): string {
53
+ function diagramHash(type: string, source: string): string {
54
54
  return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
55
55
  }
56
56
 
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  export type ExportFormat = 'pdf' | 'docx';
19
19
 
20
- export interface ExportOptions {
20
+ interface ExportOptions {
21
21
  url: string;
22
22
  fileName: string;
23
23
  format: ExportFormat;
@@ -30,7 +30,7 @@ const MAX_HEIGHT_PX = 768;
30
30
  /**
31
31
  * Export page as PDF.
32
32
  */
33
- export async function exportPDF(options: ExportOptions): Promise<Buffer> {
33
+ async function exportPDF(options: ExportOptions): Promise<Buffer> {
34
34
  const browser = await launchBrowser();
35
35
  try {
36
36
  const page = await browser.newPage();
@@ -53,7 +53,7 @@ export async function exportPDF(options: ExportOptions): Promise<Buffer> {
53
53
  /**
54
54
  * Export page as DOCX.
55
55
  */
56
- export async function exportDOCX(options: ExportOptions): Promise<Buffer> {
56
+ async function exportDOCX(options: ExportOptions): Promise<Buffer> {
57
57
  const browser = await launchBrowser();
58
58
  try {
59
59
  const page = await browser.newPage();
@@ -2,7 +2,7 @@
2
2
  * Breadcrumb filtering utilities.
3
3
  */
4
4
 
5
- export interface Breadcrumb {
5
+ interface Breadcrumb {
6
6
  label: string;
7
7
  path: string;
8
8
  }
package/src/util/state.ts CHANGED
@@ -10,7 +10,7 @@ import type { ServerState } from '../config/types.js';
10
10
  /**
11
11
  * Load state from file
12
12
  */
13
- export function loadState(): ServerState {
13
+ function loadState(): ServerState {
14
14
  const { stateFile } = getConfig();
15
15
  try {
16
16
  if (fs.existsSync(stateFile)) {
@@ -26,7 +26,7 @@ export function loadState(): ServerState {
26
26
  /**
27
27
  * Save state to file
28
28
  */
29
- export function saveState(state: ServerState): void {
29
+ function saveState(state: ServerState): void {
30
30
  const { stateFile } = getConfig();
31
31
  fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
32
32
  }