@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/src/config/schema.ts
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
expect(
|
|
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
|
});
|
package/src/routes/api/status.ts
CHANGED
|
@@ -19,30 +19,28 @@ interface ServiceStatus {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async function checkService(url: string): Promise<ServiceStatus> {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
73
|
+
mermaid: true,
|
|
71
74
|
plantuml: {
|
|
72
75
|
localJar: Boolean(config.plantuml.jarPath),
|
|
73
76
|
servers: config.plantuml.servers,
|
package/src/routes/auth.ts
CHANGED
|
@@ -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
|
package/src/routes/keys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
function diagramHash(type: string, source: string): string {
|
|
54
54
|
return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
|
|
55
55
|
}
|
|
56
56
|
|
package/src/services/export.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
export type ExportFormat = 'pdf' | 'docx';
|
|
19
19
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
async function exportDOCX(options: ExportOptions): Promise<Buffer> {
|
|
57
57
|
const browser = await launchBrowser();
|
|
58
58
|
try {
|
|
59
59
|
const page = await browser.newPage();
|
package/src/util/breadcrumbs.ts
CHANGED
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
|
-
|
|
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
|
-
|
|
29
|
+
function saveState(state: ServerState): void {
|
|
30
30
|
const { stateFile } = getConfig();
|
|
31
31
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
32
32
|
}
|