@karmaniverous/jeeves-server 3.4.2 → 3.5.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 +38 -1
- package/README.md +18 -17
- package/client/package.json +19 -19
- package/client/src/components/SearchModal.tsx +11 -1
- package/client/src/components/layout/Header.tsx +3 -3
- package/client/src/lib/api.ts +10 -5
- package/dist/client/assets/CodeEditor-Brh86AGF.js +1 -0
- package/dist/client/assets/CodeViewer-Cegj3cEn.js +1 -0
- package/dist/client/assets/dist-2YqVIvgv.js +2 -0
- package/dist/client/assets/dist-5vamY028.js +1 -0
- package/dist/client/assets/dist-6_auAGci.js +1 -0
- package/dist/client/assets/dist-B0kq1DQG.js +1 -0
- package/dist/client/assets/dist-B2SZD_eN.js +1 -0
- package/dist/client/assets/dist-B2t4dYA2.js +1 -0
- package/dist/client/assets/dist-B5gFYAn7.js +1 -0
- package/dist/client/assets/dist-BPy6CnYN.js +1 -0
- package/dist/client/assets/dist-CL6VCrQn.js +9 -0
- package/dist/client/assets/dist-CWsHar9N.js +1 -0
- package/dist/client/assets/dist-CnFc5Ssx.js +1 -0
- package/dist/client/assets/dist-DSgLBuTS.js +1 -0
- package/dist/client/assets/dist-DUcac0X_.js +7 -0
- package/dist/client/assets/dist-DcTcc-BG.js +6 -0
- package/dist/client/assets/dist-DvfTyWk_.js +1 -0
- package/dist/client/assets/dist-Dz1Ulpqa.js +1 -0
- package/dist/client/assets/dist-Kr-mUYW1.js +5 -0
- package/dist/client/assets/dist-OX4k3MMG.js +2 -0
- package/dist/client/assets/dist-qiU0qoeK.js +1 -0
- package/dist/client/assets/dist-ui4J6fvl.js +23 -0
- package/dist/client/assets/index-Dk_myGs4.css +2 -0
- package/dist/client/assets/index-DrBXupPz.js +62 -0
- package/dist/client/assets/theme-CPpIxvB0.js +2 -0
- package/dist/client/index.html +3 -2
- package/dist/src/cli/commands/config.test.js +5 -40
- package/dist/src/cli/index.js +9 -15
- package/dist/src/cli/start-server.js +16 -0
- package/dist/src/config/index.js +48 -37
- package/dist/src/config/loadConfig.test.js +27 -25
- package/dist/src/config/migration.js +60 -0
- package/dist/src/config/schema.js +4 -3
- package/dist/src/descriptor.js +46 -0
- package/dist/src/routes/api/diagramExport.js +101 -0
- package/dist/src/routes/api/diagramExport.test.js +134 -0
- package/dist/src/routes/api/events.js +13 -0
- package/dist/src/routes/api/export.js +6 -82
- package/dist/src/routes/api/index.js +4 -0
- package/dist/src/routes/api/search.js +9 -50
- package/dist/src/routes/api/sharing.js +40 -23
- package/dist/src/routes/api/sharing.test.js +52 -0
- package/dist/src/routes/auth.js +1 -1
- package/dist/src/routes/config.js +8 -2
- package/dist/src/routes/keys.js +4 -4
- package/dist/src/routes/path/index.js +1 -1
- package/dist/src/routes/status.js +15 -16
- package/dist/src/routes/status.test.js +13 -8
- package/dist/src/server.js +21 -16
- package/dist/src/services/markdown.js +2 -1
- package/dist/src/services/markdown.test.js +22 -0
- package/dist/src/util/packageVersion.js +7 -16
- package/dist/src/util/packageVersion.test.js +7 -0
- package/guides/api-integration.md +4 -0
- package/guides/deployment.md +11 -10
- package/guides/event-gateway.md +4 -0
- package/guides/exports.md +4 -0
- package/guides/index.md +1 -1
- package/guides/setup.md +17 -16
- package/guides/sharing.md +4 -0
- package/package.json +3 -3
- package/scripts/download-plantuml.js +0 -1
- package/src/cli/commands/config.test.ts +5 -45
- package/src/cli/index.ts +9 -16
- package/src/cli/start-server.ts +21 -0
- package/src/config/index.ts +56 -43
- package/src/config/loadConfig.test.ts +27 -29
- package/src/config/migration.ts +76 -0
- package/src/config/schema.ts +5 -4
- package/src/descriptor.ts +55 -0
- package/src/routes/api/diagramExport.test.ts +200 -0
- package/src/routes/api/diagramExport.ts +170 -0
- package/src/routes/api/events.ts +22 -0
- package/src/routes/api/export.ts +6 -131
- package/src/routes/api/index.ts +4 -0
- package/src/routes/api/search.ts +9 -63
- package/src/routes/api/sharing.test.ts +66 -0
- package/src/routes/api/sharing.ts +47 -23
- package/src/routes/auth.ts +1 -1
- package/src/routes/config.ts +15 -2
- package/src/routes/keys.ts +4 -4
- package/src/routes/path/index.ts +1 -1
- package/src/routes/status.test.ts +14 -8
- package/src/routes/status.ts +56 -62
- package/src/server.ts +29 -17
- package/src/services/markdown.test.ts +26 -0
- package/src/services/markdown.ts +2 -1
- package/src/util/packageVersion.test.ts +9 -0
- package/src/util/packageVersion.ts +11 -18
- package/src/util/platform.ts +1 -1
- package/dist/client/assets/CodeEditor-DQZZL5Rq.js +0 -1
- package/dist/client/assets/CodeViewer-ofJVD1Vn.js +0 -1
- package/dist/client/assets/index--MBieNJA.js +0 -1
- package/dist/client/assets/index-BENeXQI_.js +0 -1
- package/dist/client/assets/index-BbBpoOxz.js +0 -1
- package/dist/client/assets/index-BdV9g5AM.js +0 -6
- package/dist/client/assets/index-BjAilRri.js +0 -2
- package/dist/client/assets/index-BqbhWo2I.js +0 -3
- package/dist/client/assets/index-CVbycZ0H.js +0 -1
- package/dist/client/assets/index-Cs5oz2oJ.js +0 -5
- package/dist/client/assets/index-D-RC7ZS6.css +0 -1
- package/dist/client/assets/index-D8KZVveX.js +0 -1
- package/dist/client/assets/index-DC4HMHxY.js +0 -13
- package/dist/client/assets/index-DcY2RXqX.js +0 -1
- package/dist/client/assets/index-Duy-tZYV.js +0 -1
- package/dist/client/assets/index-Dw7rDFmE.js +0 -7
- package/dist/client/assets/index-FlCUvrjv.js +0 -2
- package/dist/client/assets/index-K6OVmfhg.js +0 -1
- package/dist/client/assets/index-MLwyFRN0.js +0 -1
- package/dist/client/assets/index-OpqBpSjn.js +0 -1
- package/dist/client/assets/index-SsHei0HE.js +0 -1
- package/dist/client/assets/index-jSGuHSeS.js +0 -62
- package/dist/client/assets/index-uQa2yckk.js +0 -1
- package/dist/client/assets/index-udkXoIER.js +0 -1
- package/dist/src/cli/commands/config.js +0 -105
- package/dist/src/cli/commands/service.js +0 -93
- package/dist/src/cli/commands/start.js +0 -24
- package/src/cli/commands/config.ts +0 -117
- package/src/cli/commands/service.ts +0 -129
- package/src/cli/commands/start.ts +0 -27
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram export routes — standalone Mermaid and PlantUML file rendering.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from export.ts to maintain single responsibility and stay within
|
|
5
|
+
* the 300 LOC limit. Both handlers share a common cache-then-render pattern
|
|
6
|
+
* via `serveDiagramExport()`.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { getConfig } from '../../config/index.js';
|
|
13
|
+
import { cacheDiagramBuffer, getCachedDiagramBuffer, } from '../../services/diagramCache.js';
|
|
14
|
+
import { renderMermaidToFile } from '../../services/mermaid.js';
|
|
15
|
+
import { getPlantUmlFormats, renderPlantUmlToBuffer, } from '../../services/plantuml.js';
|
|
16
|
+
import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
|
|
17
|
+
import { getRoots, urlPathToFs } from '../../util/platform.js';
|
|
18
|
+
/** Resolve a URL path to a validated filesystem path, or send an error reply. */
|
|
19
|
+
function resolveExportPath(reqPath, roots, reply) {
|
|
20
|
+
if (!reqPath) {
|
|
21
|
+
void reply.code(400).send({ error: 'Path required' });
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const fsPath = urlPathToFs(reqPath, roots);
|
|
25
|
+
if (!fsPath) {
|
|
26
|
+
void reply.code(404).send({ error: 'Invalid path' });
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return path.resolve(fsPath);
|
|
30
|
+
}
|
|
31
|
+
/** Send a diagram buffer with appropriate content-type and disposition. */
|
|
32
|
+
function sendDiagram(reply, buffer, format, downloadName) {
|
|
33
|
+
void reply
|
|
34
|
+
.header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
|
|
35
|
+
.header('Content-Disposition', `attachment; filename="${downloadName}"`)
|
|
36
|
+
.send(buffer);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Serve a diagram export with cache-first semantics.
|
|
40
|
+
*
|
|
41
|
+
* Checks the diagram cache, renders on miss, caches the result, and sends
|
|
42
|
+
* the response. This eliminates the duplicated cache-then-render pattern
|
|
43
|
+
* between the Mermaid and PlantUML handlers.
|
|
44
|
+
*/
|
|
45
|
+
async function serveDiagramExport(reply, engine, resolved, ext, format, render) {
|
|
46
|
+
const source = fs.readFileSync(resolved, 'utf8');
|
|
47
|
+
const baseName = path.basename(resolved, ext);
|
|
48
|
+
const downloadName = `${baseName}.${format}`;
|
|
49
|
+
const cached = getCachedDiagramBuffer(engine, source, format);
|
|
50
|
+
if (cached) {
|
|
51
|
+
sendDiagram(reply, cached, format, downloadName);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const buffer = await render(resolved, format);
|
|
55
|
+
if (!buffer) {
|
|
56
|
+
void reply.code(500).send({
|
|
57
|
+
error: `${engine.charAt(0).toUpperCase() + engine.slice(1)} render failed`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
cacheDiagramBuffer(engine, source, buffer, format);
|
|
62
|
+
sendDiagram(reply, buffer, format, downloadName);
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
65
|
+
export const diagramExportRoutes = async (fastify) => {
|
|
66
|
+
const roots = getRoots(getConfig().roots);
|
|
67
|
+
// GET /api/mermaid-export/*
|
|
68
|
+
fastify.get('/api/mermaid-export/*', async (request, reply) => {
|
|
69
|
+
const resolved = resolveExportPath(request.params['*'], roots, reply);
|
|
70
|
+
if (!resolved)
|
|
71
|
+
return;
|
|
72
|
+
if (!fs.existsSync(resolved) ||
|
|
73
|
+
!resolved.toLowerCase().endsWith('.mmd')) {
|
|
74
|
+
return reply.code(404).send({ error: 'Mermaid file not found' });
|
|
75
|
+
}
|
|
76
|
+
const mermaidFormats = ['svg', 'png', 'pdf'];
|
|
77
|
+
const format = mermaidFormats.includes(request.query.format ?? '')
|
|
78
|
+
? request.query.format
|
|
79
|
+
: 'svg';
|
|
80
|
+
await serveDiagramExport(reply, 'mermaid', resolved, '.mmd', format, async (filePath, fmt) => {
|
|
81
|
+
const outFile = await renderMermaidToFile(filePath, fmt);
|
|
82
|
+
return outFile ? fs.readFileSync(outFile) : null;
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// GET /api/plantuml-export/*
|
|
86
|
+
fastify.get('/api/plantuml-export/*', async (request, reply) => {
|
|
87
|
+
const resolved = resolveExportPath(request.params['*'], roots, reply);
|
|
88
|
+
if (!resolved)
|
|
89
|
+
return;
|
|
90
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
91
|
+
if (!fs.existsSync(resolved) ||
|
|
92
|
+
!['.puml', '.plantuml', '.pu'].includes(ext)) {
|
|
93
|
+
return reply.code(404).send({ error: 'PlantUML file not found' });
|
|
94
|
+
}
|
|
95
|
+
const supported = getPlantUmlFormats();
|
|
96
|
+
const format = supported.includes(request.query.format ?? '')
|
|
97
|
+
? request.query.format
|
|
98
|
+
: 'svg';
|
|
99
|
+
await serveDiagramExport(reply, 'plantuml', resolved, ext, format, renderPlantUmlToBuffer);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for diagram export route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Validates the cache-first rendering pipeline shared by Mermaid and PlantUML
|
|
5
|
+
* handlers, and the path resolution + error handling for both routes.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
// Mock config before importing the module under test
|
|
14
|
+
const tmpDir = path.join(tmpdir(), `diagramExport-test-${String(Date.now())}`);
|
|
15
|
+
vi.mock('../../config/index.js', () => ({
|
|
16
|
+
getConfig: () => ({
|
|
17
|
+
roots: {},
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('../../services/diagramCache.js', () => {
|
|
21
|
+
let cache = {};
|
|
22
|
+
return {
|
|
23
|
+
getCachedDiagramBuffer: (engine, source, format) => {
|
|
24
|
+
const key = `${engine}:${source}:${format}`;
|
|
25
|
+
return cache[key] ?? null;
|
|
26
|
+
},
|
|
27
|
+
cacheDiagramBuffer: (engine, source, buffer, format) => {
|
|
28
|
+
cache[`${engine}:${source}:${format}`] = buffer;
|
|
29
|
+
},
|
|
30
|
+
_reset: () => {
|
|
31
|
+
cache = {};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
vi.mock('../../services/mermaid.js', () => ({
|
|
36
|
+
renderMermaidToFile: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('../../services/plantuml.js', () => ({
|
|
39
|
+
getPlantUmlFormats: () => ['svg', 'png'],
|
|
40
|
+
renderPlantUmlToBuffer: vi.fn(),
|
|
41
|
+
}));
|
|
42
|
+
const { diagramExportRoutes } = await import('./diagramExport.js');
|
|
43
|
+
const { renderMermaidToFile } = await import('../../services/mermaid.js');
|
|
44
|
+
const { renderPlantUmlToBuffer } = await import('../../services/plantuml.js');
|
|
45
|
+
/** Minimal Fastify reply mock that captures sent data. */
|
|
46
|
+
function createReplyMock() {
|
|
47
|
+
const headers = {};
|
|
48
|
+
let sentData = null;
|
|
49
|
+
let statusCode = 200;
|
|
50
|
+
const reply = {
|
|
51
|
+
code: (c) => {
|
|
52
|
+
statusCode = c;
|
|
53
|
+
return reply;
|
|
54
|
+
},
|
|
55
|
+
header: (key, value) => {
|
|
56
|
+
headers[key.toLowerCase()] = value;
|
|
57
|
+
return reply;
|
|
58
|
+
},
|
|
59
|
+
send: (data) => {
|
|
60
|
+
sentData = data;
|
|
61
|
+
return reply;
|
|
62
|
+
},
|
|
63
|
+
get statusCode() {
|
|
64
|
+
return statusCode;
|
|
65
|
+
},
|
|
66
|
+
get headers() {
|
|
67
|
+
return headers;
|
|
68
|
+
},
|
|
69
|
+
get sentData() {
|
|
70
|
+
return sentData;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return reply;
|
|
74
|
+
}
|
|
75
|
+
describe('diagramExportRoutes', () => {
|
|
76
|
+
const routes = {};
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
79
|
+
const fakeFastify = {
|
|
80
|
+
get: (routePath, handler) => {
|
|
81
|
+
routes[routePath] = handler;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
await diagramExportRoutes(fakeFastify, {});
|
|
85
|
+
});
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
});
|
|
90
|
+
it('returns 400 when no path is provided for mermaid export', async () => {
|
|
91
|
+
const reply = createReplyMock();
|
|
92
|
+
await routes['/api/mermaid-export/*']({ params: { '*': '' }, query: {} }, reply);
|
|
93
|
+
expect(reply.statusCode).toBe(400);
|
|
94
|
+
});
|
|
95
|
+
it('returns 404 for non-existent mermaid file', async () => {
|
|
96
|
+
const reply = createReplyMock();
|
|
97
|
+
await routes['/api/mermaid-export/*']({ params: { '*': 'nonexistent.mmd' }, query: {} }, reply);
|
|
98
|
+
expect(reply.statusCode).toBe(404);
|
|
99
|
+
});
|
|
100
|
+
it('returns 400 when no path is provided for plantuml export', async () => {
|
|
101
|
+
const reply = createReplyMock();
|
|
102
|
+
await routes['/api/plantuml-export/*']({ params: { '*': '' }, query: {} }, reply);
|
|
103
|
+
expect(reply.statusCode).toBe(400);
|
|
104
|
+
});
|
|
105
|
+
it('returns 404 for non-existent plantuml file', async () => {
|
|
106
|
+
const reply = createReplyMock();
|
|
107
|
+
await routes['/api/plantuml-export/*']({ params: { '*': 'nonexistent.puml' }, query: {} }, reply);
|
|
108
|
+
expect(reply.statusCode).toBe(404);
|
|
109
|
+
});
|
|
110
|
+
it('sends 500 when mermaid render returns null', async () => {
|
|
111
|
+
// Create a real .mmd file
|
|
112
|
+
const mmdPath = path.join(tmpDir, 'test.mmd');
|
|
113
|
+
fs.writeFileSync(mmdPath, 'graph TD; A-->B');
|
|
114
|
+
// Mock urlPathToFs to resolve our temp path
|
|
115
|
+
const platformMod = await import('../../util/platform.js');
|
|
116
|
+
vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(mmdPath);
|
|
117
|
+
vi.mocked(renderMermaidToFile).mockResolvedValue(null);
|
|
118
|
+
const reply = createReplyMock();
|
|
119
|
+
await routes['/api/mermaid-export/*']({ params: { '*': 'test.mmd' }, query: { format: 'svg' } }, reply);
|
|
120
|
+
expect(reply.statusCode).toBe(500);
|
|
121
|
+
expect(reply.sentData.error).toContain('render failed');
|
|
122
|
+
});
|
|
123
|
+
it('sends 500 when plantuml render returns null', async () => {
|
|
124
|
+
const pumlPath = path.join(tmpDir, 'test.puml');
|
|
125
|
+
fs.writeFileSync(pumlPath, '@startuml\nA -> B\n@enduml');
|
|
126
|
+
const platformMod = await import('../../util/platform.js');
|
|
127
|
+
vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(pumlPath);
|
|
128
|
+
vi.mocked(renderPlantUmlToBuffer).mockResolvedValue(null);
|
|
129
|
+
const reply = createReplyMock();
|
|
130
|
+
await routes['/api/plantuml-export/*']({ params: { '*': 'test.puml' }, query: { format: 'svg' } }, reply);
|
|
131
|
+
expect(reply.statusCode).toBe(500);
|
|
132
|
+
expect(reply.sentData.error).toContain('render failed');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/events — returns recent event log entries.
|
|
3
|
+
*/
|
|
4
|
+
import { getRecentEvents } from '../../services/eventLog.js';
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
6
|
+
export const eventsRoutes = async (fastify) => {
|
|
7
|
+
fastify.get('/events',
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
9
|
+
async (request) => {
|
|
10
|
+
const limit = Math.min(Math.max(parseInt(request.query.limit ?? '20', 10) || 20, 1), 100);
|
|
11
|
+
return getRecentEvents(limit);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Document and directory export routes.
|
|
3
3
|
*
|
|
4
|
-
* Handles: /api/export
|
|
4
|
+
* Handles: /api/export/* (PDF/DOCX/ZIP), /api/export-cache/* (cache clearing).
|
|
5
|
+
* Diagram export routes (Mermaid/PlantUML) are in diagramExport.ts.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
5
8
|
*/
|
|
6
9
|
import fs from 'node:fs';
|
|
7
10
|
import path from 'node:path';
|
|
8
11
|
import archiver from 'archiver';
|
|
9
12
|
import { getConfig } from '../../config/index.js';
|
|
10
|
-
import { cacheDiagramBuffer, getCachedDiagramBuffer, } from '../../services/diagramCache.js';
|
|
11
13
|
import { appendEvent } from '../../services/eventQueue.js';
|
|
12
14
|
import { exportPage } from '../../services/export.js';
|
|
13
15
|
import { cacheExport, clearDiagramCacheForFile, clearExportCache, clearStandaloneDiagramCache, getCachedExport, } from '../../services/exportCache.js';
|
|
14
|
-
import { renderMermaidToFile } from '../../services/mermaid.js';
|
|
15
|
-
import { getPlantUmlFormats, renderPlantUmlToBuffer, } from '../../services/plantuml.js';
|
|
16
|
-
import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
|
|
17
16
|
import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
|
|
18
17
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
19
18
|
export const exportRoutes = async (fastify) => {
|
|
@@ -78,7 +77,7 @@ export const exportRoutes = async (fastify) => {
|
|
|
78
77
|
return reply
|
|
79
78
|
.code(500)
|
|
80
79
|
.send({ error: 'Export unavailable — no internal key configured' });
|
|
81
|
-
const exportUrl = `http://
|
|
80
|
+
const exportUrl = `http://127.0.0.1:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
|
|
82
81
|
const fileName = path.basename(resolved);
|
|
83
82
|
const baseName = fileName.replace(/\.md$/i, '');
|
|
84
83
|
try {
|
|
@@ -140,79 +139,4 @@ export const exportRoutes = async (fastify) => {
|
|
|
140
139
|
},
|
|
141
140
|
});
|
|
142
141
|
});
|
|
143
|
-
// GET /api/mermaid-export/*
|
|
144
|
-
fastify.get('/api/mermaid-export/*', async (request, reply) => {
|
|
145
|
-
const reqPath = request.params['*'];
|
|
146
|
-
if (!reqPath)
|
|
147
|
-
return reply.code(400).send({ error: 'Path required' });
|
|
148
|
-
const mmdFsPath = urlPathToFs(reqPath, roots);
|
|
149
|
-
if (!mmdFsPath)
|
|
150
|
-
return reply.code(404).send({ error: 'Invalid path' });
|
|
151
|
-
const resolved = path.resolve(mmdFsPath);
|
|
152
|
-
if (!fs.existsSync(resolved) ||
|
|
153
|
-
!resolved.toLowerCase().endsWith('.mmd')) {
|
|
154
|
-
return reply.code(404).send({ error: 'Mermaid file not found' });
|
|
155
|
-
}
|
|
156
|
-
const mermaidFormats = ['svg', 'png', 'pdf'];
|
|
157
|
-
const format = mermaidFormats.includes(request.query.format ?? '')
|
|
158
|
-
? request.query.format
|
|
159
|
-
: 'svg';
|
|
160
|
-
const source = fs.readFileSync(resolved, 'utf8');
|
|
161
|
-
// Check cache
|
|
162
|
-
const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
|
|
163
|
-
if (cachedBuffer) {
|
|
164
|
-
const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
|
|
165
|
-
return reply
|
|
166
|
-
.header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
|
|
167
|
-
.header('Content-Disposition', `attachment; filename="${downloadName}"`)
|
|
168
|
-
.send(cachedBuffer);
|
|
169
|
-
}
|
|
170
|
-
const outFile = await renderMermaidToFile(resolved, format);
|
|
171
|
-
if (!outFile)
|
|
172
|
-
return reply.code(500).send({ error: 'Mermaid render failed' });
|
|
173
|
-
const content = fs.readFileSync(outFile);
|
|
174
|
-
cacheDiagramBuffer('mermaid', source, content, format);
|
|
175
|
-
const downloadName = path.basename(outFile);
|
|
176
|
-
return reply
|
|
177
|
-
.header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
|
|
178
|
-
.header('Content-Disposition', `attachment; filename="${downloadName}"`)
|
|
179
|
-
.send(content);
|
|
180
|
-
});
|
|
181
|
-
// GET /api/plantuml-export/*
|
|
182
|
-
fastify.get('/api/plantuml-export/*', async (request, reply) => {
|
|
183
|
-
const reqPath = request.params['*'];
|
|
184
|
-
if (!reqPath)
|
|
185
|
-
return reply.code(400).send({ error: 'Path required' });
|
|
186
|
-
const pumlFsPath = urlPathToFs(reqPath, roots);
|
|
187
|
-
if (!pumlFsPath)
|
|
188
|
-
return reply.code(404).send({ error: 'Invalid path' });
|
|
189
|
-
const resolved = path.resolve(pumlFsPath);
|
|
190
|
-
const ext = path.extname(resolved).toLowerCase();
|
|
191
|
-
if (!fs.existsSync(resolved) ||
|
|
192
|
-
!['.puml', '.plantuml', '.pu'].includes(ext)) {
|
|
193
|
-
return reply.code(404).send({ error: 'PlantUML file not found' });
|
|
194
|
-
}
|
|
195
|
-
const supported = getPlantUmlFormats();
|
|
196
|
-
const format = supported.includes(request.query.format ?? '')
|
|
197
|
-
? request.query.format
|
|
198
|
-
: 'svg';
|
|
199
|
-
const source = fs.readFileSync(resolved, 'utf8');
|
|
200
|
-
const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
|
|
201
|
-
if (cachedBuffer) {
|
|
202
|
-
const baseName = path.basename(resolved, ext);
|
|
203
|
-
return reply
|
|
204
|
-
.header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
|
|
205
|
-
.header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
|
|
206
|
-
.send(cachedBuffer);
|
|
207
|
-
}
|
|
208
|
-
const buffer = await renderPlantUmlToBuffer(resolved, format);
|
|
209
|
-
if (!buffer)
|
|
210
|
-
return reply.code(500).send({ error: 'PlantUML render failed' });
|
|
211
|
-
cacheDiagramBuffer('plantuml', source, buffer, format);
|
|
212
|
-
const baseName = path.basename(resolved, ext);
|
|
213
|
-
return reply
|
|
214
|
-
.header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
|
|
215
|
-
.header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
|
|
216
|
-
.send(buffer);
|
|
217
|
-
});
|
|
218
142
|
};
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* API route registrar — composes all API sub-plugins.
|
|
3
3
|
*/
|
|
4
4
|
import { authStatusRoutes } from './auth-status.js';
|
|
5
|
+
import { diagramExportRoutes } from './diagramExport.js';
|
|
5
6
|
import { diagramsRoutes } from './diagrams.js';
|
|
6
7
|
import { directoryRoutes } from './directory.js';
|
|
7
8
|
import { drivesRoutes } from './drives.js';
|
|
9
|
+
import { eventsRoutes } from './events.js';
|
|
8
10
|
import { exportRoutes } from './export.js';
|
|
9
11
|
import { fileContentRoutes } from './fileContent.js';
|
|
10
12
|
import { linkInfoRoutes } from './linkInfo.js';
|
|
@@ -23,9 +25,11 @@ export const apiRoute = async (fastify) => {
|
|
|
23
25
|
await fastify.register(linkInfoRoutes);
|
|
24
26
|
await fastify.register(rawRoutes);
|
|
25
27
|
await fastify.register(exportRoutes);
|
|
28
|
+
await fastify.register(diagramExportRoutes);
|
|
26
29
|
await fastify.register(diagramsRoutes);
|
|
27
30
|
await fastify.register(runnerRoutes);
|
|
28
31
|
await fastify.register(searchRoutes);
|
|
29
32
|
await fastify.register(sharingRoutes);
|
|
30
33
|
await fastify.register(authStatusRoutes);
|
|
34
|
+
await fastify.register(eventsRoutes);
|
|
31
35
|
};
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Search API route — proxies to jeeves-watcher for semantic search.
|
|
3
3
|
*
|
|
4
|
-
* Handles: POST /api/search
|
|
4
|
+
* Handles: POST /api/search, GET /api/search/facets.
|
|
5
5
|
* Insider-only. Results filtered by insider's scope.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
6
8
|
*/
|
|
7
9
|
import { stat } from 'node:fs/promises';
|
|
8
|
-
import path from 'node:path';
|
|
9
10
|
import picomatch from 'picomatch';
|
|
10
11
|
import { getConfig } from '../../config/index.js';
|
|
12
|
+
import { fsPathToUrl, getRoots, urlPathToFs } from '../../util/platform.js';
|
|
11
13
|
/** Check if a path passes the insider's scope rules. */
|
|
12
14
|
function pathAllowedByScope(urlPath, scopes) {
|
|
13
15
|
if (!scopes)
|
|
@@ -23,48 +25,6 @@ function pathAllowedByScope(urlPath, scopes) {
|
|
|
23
25
|
}
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Resolve a browse path back to a filesystem path using roots config.
|
|
28
|
-
* Inverse of fsPathToBrowsePath.
|
|
29
|
-
*/
|
|
30
|
-
function browsePathToFsPath(browsePath, roots) {
|
|
31
|
-
const parts = browsePath.split('/');
|
|
32
|
-
const label = parts[0];
|
|
33
|
-
const rest = parts.slice(1).join('/');
|
|
34
|
-
// Check if label matches a root
|
|
35
|
-
if (roots[label]) {
|
|
36
|
-
return path.join(roots[label], rest);
|
|
37
|
-
}
|
|
38
|
-
// Windows drive letter: j/foo/bar → J:\foo\bar
|
|
39
|
-
if (/^[a-zA-Z]$/.test(label)) {
|
|
40
|
-
return `${label.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Convert an absolute filesystem path to a browse URL path.
|
|
46
|
-
* Maps drive letters and root mounts back to the URL scheme.
|
|
47
|
-
*/
|
|
48
|
-
function fsPathToBrowsePath(fsPath, roots) {
|
|
49
|
-
const normalized = fsPath.replace(/\\/g, '/');
|
|
50
|
-
// Windows drive letter: j:/foo/bar → j/foo/bar
|
|
51
|
-
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
|
52
|
-
if (driveMatch) {
|
|
53
|
-
return `${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
|
54
|
-
}
|
|
55
|
-
// Linux roots: find matching root prefix
|
|
56
|
-
for (const [label, rootPath] of Object.entries(roots)) {
|
|
57
|
-
const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
58
|
-
if (normalized.startsWith(normalizedRoot + '/')) {
|
|
59
|
-
const relative = normalized.slice(normalizedRoot.length + 1);
|
|
60
|
-
return `${label}/${relative}`;
|
|
61
|
-
}
|
|
62
|
-
if (normalized === normalizedRoot) {
|
|
63
|
-
return label;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
28
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
69
29
|
export const searchRoutes = async (fastify) => {
|
|
70
30
|
fastify.post('/api/search', async (request, reply) => {
|
|
@@ -81,7 +41,7 @@ export const searchRoutes = async (fastify) => {
|
|
|
81
41
|
return reply.code(400).send({ error: 'query is required' });
|
|
82
42
|
}
|
|
83
43
|
const insiderScopes = request.insiderScopes;
|
|
84
|
-
const roots = config.roots
|
|
44
|
+
const roots = getRoots(config.roots);
|
|
85
45
|
// Over-fetch to account for scope filtering
|
|
86
46
|
const fetchLimit = Math.min(limit * 5, 200);
|
|
87
47
|
try {
|
|
@@ -103,11 +63,10 @@ export const searchRoutes = async (fastify) => {
|
|
|
103
63
|
const fp = r.payload.file_path;
|
|
104
64
|
if (!fp)
|
|
105
65
|
continue;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
66
|
+
const urlPath = fsPathToUrl(fp, roots);
|
|
67
|
+
// fsPathToUrl returns "/drive/path"; strip leading slash for browsePath
|
|
68
|
+
const browsePath = urlPath.replace(/^\//, '');
|
|
109
69
|
// Check insider scope
|
|
110
|
-
const urlPath = `/${browsePath}`;
|
|
111
70
|
if (!pathAllowedByScope(urlPath, insiderScopes ?? null))
|
|
112
71
|
continue;
|
|
113
72
|
permitted.push({ ...r, browsePath });
|
|
@@ -173,7 +132,7 @@ export const searchRoutes = async (fastify) => {
|
|
|
173
132
|
// Sort chunks within each group by index, and fetch mtime
|
|
174
133
|
await Promise.all(grouped.map(async (g) => {
|
|
175
134
|
g.chunks.sort((a, b) => a.index - b.index);
|
|
176
|
-
const fsPath =
|
|
135
|
+
const fsPath = urlPathToFs(g.browsePath, roots);
|
|
177
136
|
if (fsPath) {
|
|
178
137
|
try {
|
|
179
138
|
const s = await stat(fsPath);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Sharing API routes.
|
|
3
3
|
*
|
|
4
4
|
* Handles: /api/share, /api/util/share-for, /api/readme-link, /api/rotate-key
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
5
7
|
*/
|
|
6
8
|
import crypto from 'node:crypto';
|
|
7
9
|
import fs from 'node:fs';
|
|
@@ -14,18 +16,32 @@ import { encodeStack } from '../../services/deepShareLinks.js';
|
|
|
14
16
|
import { computeDeepShareKey, computeOutsiderKeyWithExpiry, computePathKey, } from '../../util/crypto.js';
|
|
15
17
|
import { fsPathToUrl, getRoots } from '../../util/platform.js';
|
|
16
18
|
import { setInsiderKey } from '../../util/state.js';
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const serverRoot = path.resolve(__dirname, '..', '..', '..');
|
|
21
|
+
/** Build a deep-share browse URL from its constituent parts. */
|
|
22
|
+
function buildDeepShareUrl(targetPath, key, params) {
|
|
23
|
+
let url = `/browse${targetPath}?key=${key}&d=${String(params.depth)}&dirs=${params.dirs ? '1' : '0'}&s=${params.stack}`;
|
|
24
|
+
if (params.exp)
|
|
25
|
+
url += `&exp=${params.exp}`;
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
17
28
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
18
29
|
export const sharingRoutes = async (fastify) => {
|
|
19
30
|
const roots = getRoots(getConfig().roots);
|
|
31
|
+
/** Resolve the _internal key seed, or send a 503 error. */
|
|
32
|
+
function getInternalSeed(reply) {
|
|
33
|
+
const internalKey = getConfig().resolvedKeys.find((k) => k.name === '_internal');
|
|
34
|
+
if (!internalKey?.seed) {
|
|
35
|
+
void reply.code(503).send({ error: 'No _internal key configured' });
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return internalKey.seed;
|
|
39
|
+
}
|
|
20
40
|
// GET /api/readme-link
|
|
21
41
|
fastify.get('/api/readme-link', async (_request, reply) => {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return reply.code(503).send({ error: 'No _internal key configured' });
|
|
26
|
-
const seed = internalKey.seed;
|
|
27
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
const serverRoot = path.resolve(__dirname, '..', '..', '..');
|
|
42
|
+
const seed = getInternalSeed(reply);
|
|
43
|
+
if (!seed)
|
|
44
|
+
return;
|
|
29
45
|
const readmePath = path.join(serverRoot, 'README.md');
|
|
30
46
|
if (!fs.existsSync(readmePath))
|
|
31
47
|
return reply.code(404).send({ error: 'README.md not found' });
|
|
@@ -33,21 +49,18 @@ export const sharingRoutes = async (fastify) => {
|
|
|
33
49
|
const stack = encodeStack([urlPath]);
|
|
34
50
|
const deepParams = { depth: 2, dirs: false, stack, exp: undefined };
|
|
35
51
|
const key = computeDeepShareKey(seed, urlPath, deepParams);
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
return reply.send({
|
|
53
|
+
url: buildDeepShareUrl(urlPath, key, { depth: 2, dirs: false, stack }),
|
|
54
|
+
});
|
|
38
55
|
});
|
|
39
56
|
// GET /api/content-link/:file — share link for content/*.md (terms, privacy)
|
|
40
57
|
fastify.get('/api/content-link/:file', async (request, reply) => {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return reply.code(503).send({ error: 'No _internal key configured' });
|
|
58
|
+
const seed = getInternalSeed(reply);
|
|
59
|
+
if (!seed)
|
|
60
|
+
return;
|
|
45
61
|
const { file } = request.params;
|
|
46
62
|
if (!/^[\w-]+$/.test(file))
|
|
47
63
|
return reply.code(400).send({ error: 'Invalid file name' });
|
|
48
|
-
const seed = internalKey.seed;
|
|
49
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
-
const serverRoot = path.resolve(__dirname, '..', '..', '..');
|
|
51
64
|
const contentPath = path.join(serverRoot, 'content', `${file}.md`);
|
|
52
65
|
if (!fs.existsSync(contentPath))
|
|
53
66
|
return reply.code(404).send({ error: `${file}.md not found` });
|
|
@@ -55,8 +68,9 @@ export const sharingRoutes = async (fastify) => {
|
|
|
55
68
|
const stack = encodeStack([urlPath]);
|
|
56
69
|
const deepParams = { depth: 0, dirs: false, stack, exp: undefined };
|
|
57
70
|
const key = computeDeepShareKey(seed, urlPath, deepParams);
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
return reply.send({
|
|
72
|
+
url: buildDeepShareUrl(urlPath, key, { depth: 0, dirs: false, stack }),
|
|
73
|
+
});
|
|
60
74
|
});
|
|
61
75
|
// POST /api/share
|
|
62
76
|
fastify.post('/api/share', async (request, reply) => {
|
|
@@ -77,9 +91,12 @@ export const sharingRoutes = async (fastify) => {
|
|
|
77
91
|
exp: expiry,
|
|
78
92
|
};
|
|
79
93
|
outsiderKey = computeDeepShareKey(seed, targetPath, deepParams);
|
|
80
|
-
shareUrl =
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
shareUrl = buildDeepShareUrl(targetPath, outsiderKey, {
|
|
95
|
+
depth: depth ?? 0,
|
|
96
|
+
dirs: dirs ?? false,
|
|
97
|
+
stack,
|
|
98
|
+
exp: expiry,
|
|
99
|
+
});
|
|
83
100
|
}
|
|
84
101
|
else if (expiry) {
|
|
85
102
|
outsiderKey = computeOutsiderKeyWithExpiry(seed, targetPath, expiry);
|
|
@@ -98,7 +115,7 @@ export const sharingRoutes = async (fastify) => {
|
|
|
98
115
|
});
|
|
99
116
|
});
|
|
100
117
|
// POST /api/rotate-key
|
|
101
|
-
fastify.post('/api/rotate-key',
|
|
118
|
+
fastify.post('/api/rotate-key', (request, reply) => {
|
|
102
119
|
const insiderEmail = request.insiderEmail;
|
|
103
120
|
if (!insiderEmail)
|
|
104
121
|
return reply.code(403).send({ error: 'Insider auth required' });
|
|
@@ -109,7 +126,7 @@ export const sharingRoutes = async (fastify) => {
|
|
|
109
126
|
const newSeed = crypto.randomBytes(32).toString('hex');
|
|
110
127
|
const now = new Date().toISOString();
|
|
111
128
|
setInsiderKey(insider.email, newSeed, now);
|
|
112
|
-
|
|
129
|
+
resetConfig();
|
|
113
130
|
return reply.send({ ok: true, keyCreatedAt: now });
|
|
114
131
|
});
|
|
115
132
|
// POST /api/util/share-for
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for sharing route helpers.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
// buildDeepShareUrl is not exported from the module, so we test it via
|
|
8
|
+
// the module's internal behavior. Since it's a pure function, we extract
|
|
9
|
+
// and test the URL construction logic directly.
|
|
10
|
+
/** Inline copy of buildDeepShareUrl for unit testing. */
|
|
11
|
+
function buildDeepShareUrl(targetPath, key, params) {
|
|
12
|
+
let url = `/browse${targetPath}?key=${key}&d=${String(params.depth)}&dirs=${params.dirs ? '1' : '0'}&s=${params.stack}`;
|
|
13
|
+
if (params.exp)
|
|
14
|
+
url += `&exp=${params.exp}`;
|
|
15
|
+
return url;
|
|
16
|
+
}
|
|
17
|
+
describe('buildDeepShareUrl', () => {
|
|
18
|
+
it('builds a basic deep share URL without expiry', () => {
|
|
19
|
+
const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
|
|
20
|
+
depth: 2,
|
|
21
|
+
dirs: false,
|
|
22
|
+
stack: 'encoded-stack',
|
|
23
|
+
});
|
|
24
|
+
expect(url).toBe('/browse/j/docs/readme.md?key=abc123&d=2&dirs=0&s=encoded-stack');
|
|
25
|
+
});
|
|
26
|
+
it('includes expiry when provided', () => {
|
|
27
|
+
const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
|
|
28
|
+
depth: 1,
|
|
29
|
+
dirs: true,
|
|
30
|
+
stack: 'stk',
|
|
31
|
+
exp: '1700000000000',
|
|
32
|
+
});
|
|
33
|
+
expect(url).toBe('/browse/j/docs/readme.md?key=abc123&d=1&dirs=1&s=stk&exp=1700000000000');
|
|
34
|
+
});
|
|
35
|
+
it('handles zero depth', () => {
|
|
36
|
+
const url = buildDeepShareUrl('/j/file.md', 'key', {
|
|
37
|
+
depth: 0,
|
|
38
|
+
dirs: false,
|
|
39
|
+
stack: 's',
|
|
40
|
+
});
|
|
41
|
+
expect(url).toContain('d=0');
|
|
42
|
+
expect(url).toContain('dirs=0');
|
|
43
|
+
});
|
|
44
|
+
it('encodes dirs=1 when dirs is true', () => {
|
|
45
|
+
const url = buildDeepShareUrl('/j/dir', 'key', {
|
|
46
|
+
depth: 3,
|
|
47
|
+
dirs: true,
|
|
48
|
+
stack: 'stk',
|
|
49
|
+
});
|
|
50
|
+
expect(url).toContain('dirs=1');
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/src/routes/auth.js
CHANGED
|
@@ -73,7 +73,7 @@ export const authRoute = async (fastify) => {
|
|
|
73
73
|
insider.seed = newSeed;
|
|
74
74
|
// Persist to state.json (mutable runtime state)
|
|
75
75
|
setInsiderKey(insider.email, newSeed, timestamp);
|
|
76
|
-
|
|
76
|
+
resetConfig(); // Reload to pick up new state
|
|
77
77
|
}
|
|
78
78
|
// Set session cookie
|
|
79
79
|
const cookieValue = createSessionCookie(email, sessionSecret, userInfo.picture);
|