@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.
@@ -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
+ }