@gmickel/gno 0.6.0 → 0.7.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/README.md +9 -1
- package/assets/screenshots/claudecodeskill.jpg +0 -0
- package/assets/screenshots/cli.jpg +0 -0
- package/assets/screenshots/mcp.jpg +0 -0
- package/assets/screenshots/webui-ask-answer.jpg +0 -0
- package/assets/screenshots/webui-home.jpg +0 -0
- package/package.json +4 -4
- package/src/cli/commands/ask.ts +41 -3
- package/src/cli/commands/collection/add.ts +27 -66
- package/src/cli/commands/collection/remove.ts +20 -39
- package/src/cli/commands/embed.ts +75 -20
- package/src/cli/commands/models/index.ts +1 -1
- package/src/cli/commands/models/pull.ts +0 -17
- package/src/cli/commands/query.ts +41 -3
- package/src/cli/context.ts +10 -0
- package/src/cli/program.ts +2 -1
- package/src/cli/progress.ts +88 -0
- package/src/cli/run.ts +1 -0
- package/src/collection/add.ts +113 -0
- package/src/collection/index.ts +17 -0
- package/src/collection/remove.ts +65 -0
- package/src/collection/types.ts +70 -0
- package/src/llm/cache.ts +187 -37
- package/src/llm/errors.ts +27 -4
- package/src/llm/lockfile.ts +216 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +54 -12
- package/src/llm/policy.ts +84 -0
- package/src/mcp/tools/query.ts +20 -3
- package/src/mcp/tools/vsearch.ts +12 -1
- package/src/serve/config-sync.ts +139 -0
- package/src/serve/context.ts +36 -3
- package/src/serve/jobs.ts +172 -0
- package/src/serve/routes/api.ts +432 -0
- package/src/serve/security.ts +84 -0
- package/src/serve/server.ts +126 -15
package/src/serve/routes/api.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { modelsPull } from '../../cli/commands/models/pull';
|
|
9
|
+
import { addCollection, removeCollection } from '../../collection';
|
|
9
10
|
import type { Config, ModelPreset } from '../../config/types';
|
|
11
|
+
import { defaultSyncService, type SyncResult } from '../../ingestion';
|
|
10
12
|
import { getModelConfig, getPreset, listPresets } from '../../llm/registry';
|
|
11
13
|
import {
|
|
12
14
|
generateGroundedAnswer,
|
|
@@ -16,12 +18,14 @@ import { searchHybrid } from '../../pipeline/hybrid';
|
|
|
16
18
|
import { searchBm25 } from '../../pipeline/search';
|
|
17
19
|
import type { AskResult, Citation, SearchOptions } from '../../pipeline/types';
|
|
18
20
|
import type { SqliteAdapter } from '../../store/sqlite/adapter';
|
|
21
|
+
import { applyConfigChange } from '../config-sync';
|
|
19
22
|
import {
|
|
20
23
|
downloadState,
|
|
21
24
|
reloadServerContext,
|
|
22
25
|
resetDownloadState,
|
|
23
26
|
type ServerContext,
|
|
24
27
|
} from '../context';
|
|
28
|
+
import { getJobStatus, startJob } from '../jobs';
|
|
25
29
|
|
|
26
30
|
/** Mutable context holder for hot-reloading presets */
|
|
27
31
|
export interface ContextHolder {
|
|
@@ -66,6 +70,27 @@ export interface AskRequestBody {
|
|
|
66
70
|
maxAnswerTokens?: number;
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
export interface CreateCollectionRequestBody {
|
|
74
|
+
path: string;
|
|
75
|
+
name?: string;
|
|
76
|
+
pattern?: string;
|
|
77
|
+
include?: string;
|
|
78
|
+
exclude?: string;
|
|
79
|
+
gitPull?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SyncRequestBody {
|
|
83
|
+
collection?: string;
|
|
84
|
+
gitPull?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CreateDocRequestBody {
|
|
88
|
+
collection: string;
|
|
89
|
+
relPath: string;
|
|
90
|
+
content: string;
|
|
91
|
+
overwrite?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
95
|
// Helpers
|
|
71
96
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -140,6 +165,219 @@ export async function handleCollections(
|
|
|
140
165
|
);
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/**
|
|
169
|
+
* POST /api/collections
|
|
170
|
+
* Create a new collection and start sync job.
|
|
171
|
+
*/
|
|
172
|
+
export async function handleCreateCollection(
|
|
173
|
+
ctxHolder: ContextHolder,
|
|
174
|
+
store: SqliteAdapter,
|
|
175
|
+
req: Request
|
|
176
|
+
): Promise<Response> {
|
|
177
|
+
let body: CreateCollectionRequestBody;
|
|
178
|
+
try {
|
|
179
|
+
body = (await req.json()) as CreateCollectionRequestBody;
|
|
180
|
+
} catch {
|
|
181
|
+
return errorResponse('VALIDATION', 'Invalid JSON body');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate required fields
|
|
185
|
+
if (!body.path || typeof body.path !== 'string') {
|
|
186
|
+
return errorResponse('VALIDATION', 'Missing or invalid path');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate optional fields have correct types
|
|
190
|
+
if (body.name !== undefined && typeof body.name !== 'string') {
|
|
191
|
+
return errorResponse('VALIDATION', 'name must be a string');
|
|
192
|
+
}
|
|
193
|
+
if (body.pattern !== undefined && typeof body.pattern !== 'string') {
|
|
194
|
+
return errorResponse('VALIDATION', 'pattern must be a string');
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
body.include !== undefined &&
|
|
198
|
+
typeof body.include !== 'string' &&
|
|
199
|
+
!Array.isArray(body.include)
|
|
200
|
+
) {
|
|
201
|
+
return errorResponse('VALIDATION', 'include must be a string or array');
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
body.exclude !== undefined &&
|
|
205
|
+
typeof body.exclude !== 'string' &&
|
|
206
|
+
!Array.isArray(body.exclude)
|
|
207
|
+
) {
|
|
208
|
+
return errorResponse('VALIDATION', 'exclude must be a string or array');
|
|
209
|
+
}
|
|
210
|
+
if (body.gitPull !== undefined && typeof body.gitPull !== 'boolean') {
|
|
211
|
+
return errorResponse('VALIDATION', 'gitPull must be a boolean');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Derive name from path if not provided
|
|
215
|
+
const { basename } = await import('node:path'); // no bun equivalent
|
|
216
|
+
const name = body.name || basename(body.path);
|
|
217
|
+
|
|
218
|
+
// Persist config and sync to DB (mutation happens inside with fresh config)
|
|
219
|
+
const syncResult = await applyConfigChange(ctxHolder, store, async (cfg) => {
|
|
220
|
+
const addResult = await addCollection(cfg, {
|
|
221
|
+
path: body.path,
|
|
222
|
+
name,
|
|
223
|
+
pattern: body.pattern,
|
|
224
|
+
include: body.include,
|
|
225
|
+
exclude: body.exclude,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!addResult.ok) {
|
|
229
|
+
return { ok: false, error: addResult.message, code: addResult.code };
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, config: addResult.config };
|
|
232
|
+
});
|
|
233
|
+
if (!syncResult.ok) {
|
|
234
|
+
// Map mutation error codes to HTTP status codes
|
|
235
|
+
const statusMap: Record<string, number> = {
|
|
236
|
+
DUPLICATE: 409,
|
|
237
|
+
PATH_NOT_FOUND: 400,
|
|
238
|
+
};
|
|
239
|
+
const status = statusMap[syncResult.code] ?? 500;
|
|
240
|
+
return errorResponse(syncResult.code, syncResult.error, status);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Find the newly added collection from config
|
|
244
|
+
const collection = syncResult.config.collections.find((c) => c.name === name);
|
|
245
|
+
if (!collection) {
|
|
246
|
+
return errorResponse('RUNTIME', 'Collection not found after add', 500);
|
|
247
|
+
}
|
|
248
|
+
const jobResult = startJob('add', async (): Promise<SyncResult> => {
|
|
249
|
+
const result = await defaultSyncService.syncCollection(collection, store, {
|
|
250
|
+
gitPull: body.gitPull,
|
|
251
|
+
runUpdateCmd: true,
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
collections: [result],
|
|
255
|
+
totalDurationMs: result.durationMs,
|
|
256
|
+
totalFilesProcessed: result.filesProcessed,
|
|
257
|
+
totalFilesAdded: result.filesAdded,
|
|
258
|
+
totalFilesUpdated: result.filesUpdated,
|
|
259
|
+
totalFilesErrored: result.filesErrored,
|
|
260
|
+
totalFilesSkipped: result.filesSkipped,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!jobResult.ok) {
|
|
265
|
+
return errorResponse('CONFLICT', jobResult.error, 409);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return jsonResponse(
|
|
269
|
+
{
|
|
270
|
+
jobId: jobResult.jobId,
|
|
271
|
+
collection: collection.name,
|
|
272
|
+
},
|
|
273
|
+
202
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* DELETE /api/collections/:name
|
|
279
|
+
* Remove a collection from config.
|
|
280
|
+
* Note: Does NOT remove indexed documents - they remain in DB until re-sync
|
|
281
|
+
* or manual cleanup. This preserves data for potential recovery.
|
|
282
|
+
*/
|
|
283
|
+
export async function handleDeleteCollection(
|
|
284
|
+
ctxHolder: ContextHolder,
|
|
285
|
+
store: SqliteAdapter,
|
|
286
|
+
name: string
|
|
287
|
+
): Promise<Response> {
|
|
288
|
+
// Persist config and sync to DB (mutation happens inside with fresh config)
|
|
289
|
+
const syncResult = await applyConfigChange(ctxHolder, store, (cfg) => {
|
|
290
|
+
const removeResult = removeCollection(cfg, { name });
|
|
291
|
+
|
|
292
|
+
if (!removeResult.ok) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
error: removeResult.message,
|
|
296
|
+
code: removeResult.code,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, config: removeResult.config };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!syncResult.ok) {
|
|
303
|
+
// Map mutation error codes to HTTP status codes
|
|
304
|
+
const statusMap: Record<string, number> = {
|
|
305
|
+
NOT_FOUND: 404,
|
|
306
|
+
HAS_REFERENCES: 400,
|
|
307
|
+
};
|
|
308
|
+
const status = statusMap[syncResult.code] ?? 500;
|
|
309
|
+
return errorResponse(syncResult.code, syncResult.error, status);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return jsonResponse({
|
|
313
|
+
success: true,
|
|
314
|
+
collection: name,
|
|
315
|
+
note: 'Collection removed from config. Indexed documents remain in DB.',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* POST /api/sync
|
|
321
|
+
* Trigger re-index of all or specific collection.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleSync(
|
|
324
|
+
ctxHolder: ContextHolder,
|
|
325
|
+
store: SqliteAdapter,
|
|
326
|
+
req: Request
|
|
327
|
+
): Promise<Response> {
|
|
328
|
+
let body: SyncRequestBody = {};
|
|
329
|
+
try {
|
|
330
|
+
const text = await req.text();
|
|
331
|
+
if (text) {
|
|
332
|
+
body = JSON.parse(text) as SyncRequestBody;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
return errorResponse('VALIDATION', 'Invalid JSON body');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Validate optional fields
|
|
339
|
+
if (body.collection !== undefined && typeof body.collection !== 'string') {
|
|
340
|
+
return errorResponse('VALIDATION', 'collection must be a string');
|
|
341
|
+
}
|
|
342
|
+
if (body.gitPull !== undefined && typeof body.gitPull !== 'boolean') {
|
|
343
|
+
return errorResponse('VALIDATION', 'gitPull must be a boolean');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Get collections to sync (case-insensitive matching)
|
|
347
|
+
const collectionName = body.collection?.toLowerCase();
|
|
348
|
+
const collections = collectionName
|
|
349
|
+
? ctxHolder.config.collections.filter(
|
|
350
|
+
(c) => c.name.toLowerCase() === collectionName
|
|
351
|
+
)
|
|
352
|
+
: ctxHolder.config.collections;
|
|
353
|
+
|
|
354
|
+
if (body.collection && collections.length === 0) {
|
|
355
|
+
return errorResponse(
|
|
356
|
+
'NOT_FOUND',
|
|
357
|
+
`Collection not found: ${body.collection}`,
|
|
358
|
+
404
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (collections.length === 0) {
|
|
363
|
+
return errorResponse('VALIDATION', 'No collections to sync');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Start background sync job
|
|
367
|
+
const jobResult = startJob('sync', async (): Promise<SyncResult> => {
|
|
368
|
+
return await defaultSyncService.syncAll(collections, store, {
|
|
369
|
+
gitPull: body.gitPull,
|
|
370
|
+
runUpdateCmd: true,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!jobResult.ok) {
|
|
375
|
+
return errorResponse('CONFLICT', jobResult.error, 409);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return jsonResponse({ jobId: jobResult.jobId }, 202);
|
|
379
|
+
}
|
|
380
|
+
|
|
143
381
|
/**
|
|
144
382
|
* GET /api/docs
|
|
145
383
|
* Query params: collection, limit (default 20), offset (default 0)
|
|
@@ -249,6 +487,184 @@ export async function handleDoc(
|
|
|
249
487
|
});
|
|
250
488
|
}
|
|
251
489
|
|
|
490
|
+
/**
|
|
491
|
+
* POST /api/docs/:id/deactivate
|
|
492
|
+
* Deactivate a document (soft delete - does not remove file from disk).
|
|
493
|
+
*/
|
|
494
|
+
export async function handleDeactivateDoc(
|
|
495
|
+
store: SqliteAdapter,
|
|
496
|
+
docId: string
|
|
497
|
+
): Promise<Response> {
|
|
498
|
+
// Get document to verify it exists and get collection/relPath
|
|
499
|
+
const docResult = await store.getDocumentByDocid(docId);
|
|
500
|
+
if (!docResult.ok) {
|
|
501
|
+
return errorResponse('RUNTIME', docResult.error.message, 500);
|
|
502
|
+
}
|
|
503
|
+
if (!docResult.value) {
|
|
504
|
+
return errorResponse('NOT_FOUND', 'Document not found', 404);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const doc = docResult.value;
|
|
508
|
+
|
|
509
|
+
// Mark as inactive
|
|
510
|
+
const result = await store.markInactive(doc.collection, [doc.relPath]);
|
|
511
|
+
if (!result.ok) {
|
|
512
|
+
return errorResponse('RUNTIME', result.error.message, 500);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return jsonResponse({
|
|
516
|
+
success: true,
|
|
517
|
+
docId: doc.docid,
|
|
518
|
+
path: doc.uri,
|
|
519
|
+
warning: 'File still exists on disk. Will be re-indexed unless excluded.',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Validate relative path for document creation.
|
|
525
|
+
* Returns error message if invalid, null if valid.
|
|
526
|
+
*/
|
|
527
|
+
async function validateRelPath(
|
|
528
|
+
relPath: string
|
|
529
|
+
): Promise<{ error: string } | { fullPath: string; normalizedPath: string }> {
|
|
530
|
+
const { normalize, isAbsolute } = await import('node:path');
|
|
531
|
+
if (isAbsolute(relPath)) {
|
|
532
|
+
return { error: 'relPath must be relative' };
|
|
533
|
+
}
|
|
534
|
+
if (relPath.includes('\0')) {
|
|
535
|
+
return { error: 'relPath contains invalid characters' };
|
|
536
|
+
}
|
|
537
|
+
const normalizedPath = normalize(relPath);
|
|
538
|
+
if (normalizedPath.startsWith('..')) {
|
|
539
|
+
return { error: 'relPath cannot escape collection root' };
|
|
540
|
+
}
|
|
541
|
+
return { fullPath: '', normalizedPath };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* POST /api/docs
|
|
546
|
+
* Create a new document in a collection.
|
|
547
|
+
* Returns 202 with jobId for async sync.
|
|
548
|
+
*/
|
|
549
|
+
export async function handleCreateDoc(
|
|
550
|
+
ctxHolder: ContextHolder,
|
|
551
|
+
store: SqliteAdapter,
|
|
552
|
+
req: Request
|
|
553
|
+
): Promise<Response> {
|
|
554
|
+
let body: CreateDocRequestBody;
|
|
555
|
+
try {
|
|
556
|
+
body = (await req.json()) as CreateDocRequestBody;
|
|
557
|
+
} catch {
|
|
558
|
+
return errorResponse('VALIDATION', 'Invalid JSON body');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Validate required fields with type checks
|
|
562
|
+
if (!body.collection || typeof body.collection !== 'string') {
|
|
563
|
+
return errorResponse('VALIDATION', 'Missing or invalid collection');
|
|
564
|
+
}
|
|
565
|
+
if (!body.relPath || typeof body.relPath !== 'string') {
|
|
566
|
+
return errorResponse('VALIDATION', 'Missing or invalid relPath');
|
|
567
|
+
}
|
|
568
|
+
if (!body.content || typeof body.content !== 'string') {
|
|
569
|
+
return errorResponse('VALIDATION', 'Missing or invalid content');
|
|
570
|
+
}
|
|
571
|
+
if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') {
|
|
572
|
+
return errorResponse('VALIDATION', 'overwrite must be a boolean');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Find collection (case-insensitive)
|
|
576
|
+
const collectionName = body.collection.toLowerCase();
|
|
577
|
+
const collection = ctxHolder.config.collections.find(
|
|
578
|
+
(c) => c.name.toLowerCase() === collectionName
|
|
579
|
+
);
|
|
580
|
+
if (!collection) {
|
|
581
|
+
return errorResponse(
|
|
582
|
+
'NOT_FOUND',
|
|
583
|
+
`Collection not found: ${body.collection}`,
|
|
584
|
+
404
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Validate relPath - no path traversal
|
|
589
|
+
const pathValidation = await validateRelPath(body.relPath);
|
|
590
|
+
if ('error' in pathValidation) {
|
|
591
|
+
return errorResponse('VALIDATION', pathValidation.error);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const { join, dirname } = await import('node:path'); // no bun equivalent
|
|
595
|
+
const fullPath = join(collection.path, pathValidation.normalizedPath);
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
// Check if file already exists
|
|
599
|
+
const file = Bun.file(fullPath);
|
|
600
|
+
if ((await file.exists()) && !body.overwrite) {
|
|
601
|
+
return errorResponse(
|
|
602
|
+
'CONFLICT',
|
|
603
|
+
'File already exists. Set overwrite=true to replace.',
|
|
604
|
+
409
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Ensure parent directory exists
|
|
609
|
+
const parentDir = dirname(fullPath);
|
|
610
|
+
const { mkdir, rename, unlink } = await import('node:fs/promises'); // structure ops need fs
|
|
611
|
+
await mkdir(parentDir, { recursive: true });
|
|
612
|
+
|
|
613
|
+
// Write file atomically (temp file + rename)
|
|
614
|
+
const tempPath = `${fullPath}.tmp.${crypto.randomUUID()}`;
|
|
615
|
+
await Bun.write(tempPath, body.content);
|
|
616
|
+
try {
|
|
617
|
+
await rename(tempPath, fullPath);
|
|
618
|
+
} catch (renameError) {
|
|
619
|
+
// Clean up temp file on failure
|
|
620
|
+
await unlink(tempPath).catch(() => {
|
|
621
|
+
/* ignore cleanup errors */
|
|
622
|
+
});
|
|
623
|
+
throw renameError;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Build proper file:// URI using node:url
|
|
627
|
+
const { pathToFileURL } = await import('node:url');
|
|
628
|
+
const fileUri = pathToFileURL(fullPath).href;
|
|
629
|
+
|
|
630
|
+
// Run sync via job system (non-blocking)
|
|
631
|
+
const jobResult = startJob('sync', async (): Promise<SyncResult> => {
|
|
632
|
+
const result = await defaultSyncService.syncCollection(
|
|
633
|
+
collection,
|
|
634
|
+
store,
|
|
635
|
+
{ runUpdateCmd: false }
|
|
636
|
+
);
|
|
637
|
+
return {
|
|
638
|
+
collections: [result],
|
|
639
|
+
totalDurationMs: result.durationMs,
|
|
640
|
+
totalFilesProcessed: result.filesProcessed,
|
|
641
|
+
totalFilesAdded: result.filesAdded,
|
|
642
|
+
totalFilesUpdated: result.filesUpdated,
|
|
643
|
+
totalFilesErrored: result.filesErrored,
|
|
644
|
+
totalFilesSkipped: result.filesSkipped,
|
|
645
|
+
};
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return jsonResponse(
|
|
649
|
+
{
|
|
650
|
+
uri: fileUri,
|
|
651
|
+
path: fullPath,
|
|
652
|
+
jobId: jobResult.ok ? jobResult.jobId : null,
|
|
653
|
+
note: jobResult.ok
|
|
654
|
+
? 'File created. Sync job started - poll /api/jobs/:id for status.'
|
|
655
|
+
: 'File created. Sync skipped (another job running).',
|
|
656
|
+
},
|
|
657
|
+
202
|
|
658
|
+
);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
return errorResponse(
|
|
661
|
+
'RUNTIME',
|
|
662
|
+
`Failed to create document: ${e instanceof Error ? e.message : String(e)}`,
|
|
663
|
+
500
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
252
668
|
/**
|
|
253
669
|
* POST /api/search
|
|
254
670
|
* Body: { query, mode?, limit?, minScore?, collection? }
|
|
@@ -682,6 +1098,22 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
|
|
|
682
1098
|
});
|
|
683
1099
|
}
|
|
684
1100
|
|
|
1101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1102
|
+
// Jobs
|
|
1103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* GET /api/jobs/:id
|
|
1107
|
+
* Poll job status for async operations.
|
|
1108
|
+
*/
|
|
1109
|
+
export function handleJob(jobId: string): Response {
|
|
1110
|
+
const status = getJobStatus(jobId);
|
|
1111
|
+
if (!status) {
|
|
1112
|
+
return errorResponse('NOT_FOUND', 'Job not found or expired', 404);
|
|
1113
|
+
}
|
|
1114
|
+
return jsonResponse(status);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
685
1117
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
686
1118
|
// Router
|
|
687
1119
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF protection and security utilities for the API server.
|
|
3
|
+
*
|
|
4
|
+
* Security model:
|
|
5
|
+
* - This is a loopback-only server (127.0.0.1)
|
|
6
|
+
* - CSRF protection uses Origin header validation
|
|
7
|
+
* - Requests WITHOUT Origin header are allowed (same-origin browser requests, curl)
|
|
8
|
+
* - Setting GNO_API_TOKEN enables token auth as an alternative to Origin validation
|
|
9
|
+
* but does NOT require it for non-browser clients (they just omit Origin)
|
|
10
|
+
*
|
|
11
|
+
* @module src/serve/security
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate Origin header for CSRF protection.
|
|
16
|
+
* Same-origin requests (no Origin header) are allowed - this includes:
|
|
17
|
+
* - Same-origin browser fetch/XHR (browser omits Origin for same-origin)
|
|
18
|
+
* - curl and other non-browser clients (no Origin header by default)
|
|
19
|
+
* Cross-origin browser requests must originate from localhost/127.0.0.1.
|
|
20
|
+
*/
|
|
21
|
+
export function validateOrigin(req: Request, port: number): boolean {
|
|
22
|
+
const origin = req.headers.get('Origin');
|
|
23
|
+
// Same-origin requests (browser fetch, curl) have no Origin header
|
|
24
|
+
if (!origin) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// For port 0 (ephemeral), we can't validate specific port - only allow if
|
|
29
|
+
// the port in the request matches the expected allowed origins pattern
|
|
30
|
+
const allowed =
|
|
31
|
+
port === 0
|
|
32
|
+
? [] // Can't know actual port, reject cross-origin for ephemeral
|
|
33
|
+
: [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
|
|
34
|
+
|
|
35
|
+
return allowed.includes(origin);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate API token for non-browser clients (e.g., Raycast).
|
|
40
|
+
* Token auth is optional - only enabled if GNO_API_TOKEN env var is set.
|
|
41
|
+
* When enabled, requests with valid token bypass Origin validation.
|
|
42
|
+
* When disabled (no env var), this always returns false.
|
|
43
|
+
*/
|
|
44
|
+
export function validateToken(req: Request): boolean {
|
|
45
|
+
const expectedToken = process.env.GNO_API_TOKEN;
|
|
46
|
+
// Token auth disabled if env var not set
|
|
47
|
+
if (!expectedToken) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const token = req.headers.get('X-GNO-Token');
|
|
52
|
+
return token === expectedToken;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if request is allowed (CSRF protection).
|
|
57
|
+
* GET/HEAD/OPTIONS are always allowed (safe methods).
|
|
58
|
+
* POST/PUT/DELETE require either:
|
|
59
|
+
* - Valid Origin header (localhost/127.0.0.1 on same port), OR
|
|
60
|
+
* - No Origin header (same-origin browser or non-browser client), OR
|
|
61
|
+
* - Valid X-GNO-Token header (if GNO_API_TOKEN env is set)
|
|
62
|
+
*/
|
|
63
|
+
export function isRequestAllowed(req: Request, port: number): boolean {
|
|
64
|
+
const method = req.method.toUpperCase();
|
|
65
|
+
|
|
66
|
+
// Safe methods - no CSRF protection needed
|
|
67
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Unsafe methods - require valid Origin or token
|
|
72
|
+
return validateToken(req) || validateOrigin(req, port);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create 403 Forbidden response for CSRF violations.
|
|
77
|
+
* Uses same error envelope as other API errors for consistency.
|
|
78
|
+
*/
|
|
79
|
+
export function forbiddenResponse(): Response {
|
|
80
|
+
return Response.json(
|
|
81
|
+
{ error: { code: 'CSRF_VIOLATION', message: 'Forbidden' } },
|
|
82
|
+
{ status: 403 }
|
|
83
|
+
);
|
|
84
|
+
}
|