@opengeni/api-router 0.11.1 → 0.11.8

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AddDocumentRequest,
3
+ CreateKnowledgeDropRequest,
3
4
  CreateKnowledgeMemoryRequest,
4
5
  CreateDocumentBaseRequest,
5
6
  Document,
@@ -7,11 +8,14 @@ import {
7
8
  DocumentSearchRequest,
8
9
  KnowledgeMemory,
9
10
  KnowledgeMemorySearchRequest,
11
+ MoveDocumentRequest,
10
12
  UpdateKnowledgeMemoryRequest,
11
13
  WorkspaceMemorySearchRequest,
12
14
  WorkspaceMemorySearchResponse,
13
15
  } from "@opengeni/contracts";
14
16
  import {
17
+ completeFileUpload,
18
+ createFileUpload,
15
19
  createKnowledgeMemory,
16
20
  getKnowledgeMemory,
17
21
  listKnowledgeMemories,
@@ -23,10 +27,12 @@ import {
23
27
  addDocumentToBase,
24
28
  createDocumentBase,
25
29
  deleteDocumentFromBase,
30
+ ensureDefaultBase,
26
31
  getDocument,
27
32
  getDocumentBase,
28
33
  listDocumentBases,
29
34
  listDocuments,
35
+ moveDocumentToBase,
30
36
  queueDocumentForReindex,
31
37
  searchDocuments,
32
38
  } from "@opengeni/documents";
@@ -37,6 +43,7 @@ import { requireAccessGrant } from "@opengeni/core";
37
43
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
38
44
  import type { ApiRouteDeps } from "@opengeni/core";
39
45
  import { buildDocumentsMcpServer } from "../mcp/documents";
46
+ import { sanitizeFilename } from "./files";
40
47
 
41
48
  export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
42
49
  const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
@@ -90,6 +97,8 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
90
97
  accountId: grant.accountId,
91
98
  workspaceId,
92
99
  baseId: c.req.param("baseId"),
100
+ createdBy: grant.subjectId,
101
+ access: { viewerSubjectId: grant.subjectId },
93
102
  });
94
103
  const wasCreated =
95
104
  document.status === "queued" && document.chunkCount === 0 && document.error === null;
@@ -122,11 +131,13 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
122
131
 
123
132
  app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
124
133
  const workspaceId = c.req.param("workspaceId");
125
- await requireAccessGrant(c, deps, workspaceId, "documents:search");
134
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
126
135
  return c.json(
127
- (await listDocuments(db, workspaceId, c.req.param("baseId"))).map((document) =>
128
- Document.parse(document),
129
- ),
136
+ (
137
+ await listDocuments(db, workspaceId, c.req.param("baseId"), {
138
+ viewerSubjectId: grant.subjectId,
139
+ })
140
+ ).map((document) => Document.parse(document)),
130
141
  );
131
142
  });
132
143
 
@@ -141,9 +152,13 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
141
152
  workspaceId,
142
153
  baseId: c.req.param("baseId"),
143
154
  documentId: c.req.param("documentId"),
155
+ access: { viewerSubjectId: grant.subjectId },
144
156
  });
145
157
  return c.body(null, 204);
146
158
  } catch (error) {
159
+ if (error instanceof HTTPException) {
160
+ throw error;
161
+ }
147
162
  throw documentHttpException(error);
148
163
  }
149
164
  },
@@ -164,7 +179,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
164
179
  quantity: 0,
165
180
  });
166
181
  try {
167
- const document = await getDocument(db, workspaceId, c.req.param("documentId"));
182
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
183
+ viewerSubjectId: grant.subjectId,
184
+ });
168
185
  if (!document) {
169
186
  throw new HTTPException(404, { message: "document not found" });
170
187
  }
@@ -174,7 +191,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
174
191
  if (document.baseId !== c.req.param("baseId")) {
175
192
  throw new HTTPException(404, { message: "document not found" });
176
193
  }
177
- const queued = await queueDocumentForReindex(db, workspaceId, document.id);
194
+ const queued = await queueDocumentForReindex(db, workspaceId, document.id, {
195
+ viewerSubjectId: grant.subjectId,
196
+ });
178
197
  const indexed =
179
198
  (await documentIndexer.indexDocument({
180
199
  accountId: grant.accountId,
@@ -206,7 +225,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
206
225
 
207
226
  app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
208
227
  const workspaceId = c.req.param("workspaceId");
209
- await requireAccessGrant(c, deps, workspaceId, "documents:search");
228
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
210
229
  const payload = DocumentSearchRequest.parse(await c.req.json());
211
230
  const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
212
231
  if (!base) {
@@ -223,6 +242,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
223
242
  mode: payload.mode,
224
243
  sourceKinds: payload.sourceKinds,
225
244
  aclTags: payload.aclTags,
245
+ access: { viewerSubjectId: grant.subjectId },
226
246
  },
227
247
  getDocumentServices(),
228
248
  ),
@@ -231,7 +251,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
231
251
 
232
252
  app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
233
253
  const workspaceId = c.req.param("workspaceId");
234
- await requireAccessGrant(c, deps, workspaceId, "documents:search");
254
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
235
255
  const payload = DocumentSearchRequest.parse(await c.req.json());
236
256
  return c.json({
237
257
  results: await searchDocuments(
@@ -244,12 +264,162 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
244
264
  mode: payload.mode,
245
265
  sourceKinds: payload.sourceKinds,
246
266
  aclTags: payload.aclTags,
267
+ access: { viewerSubjectId: grant.subjectId },
247
268
  },
248
269
  getDocumentServices(),
249
270
  ),
250
271
  });
251
272
  });
252
273
 
274
+ // Knowledge drop: raw text or an uploaded file, no metadata required. Lands
275
+ // in the workspace Default base with curationStatus 'pending'; indexing then
276
+ // applies the configured curation provider, or leaves it as 'none' when
277
+ // curation is disabled.
278
+ app.post("/v1/workspaces/:workspaceId/knowledge/drops", async (c) => {
279
+ const workspaceId = c.req.param("workspaceId");
280
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
281
+ if (!objectStorage) {
282
+ throw new HTTPException(503, { message: "object storage is not configured" });
283
+ }
284
+ await requireLimit(deps, {
285
+ accountId: grant.accountId,
286
+ workspaceId,
287
+ action: "document:index",
288
+ quantity: 0,
289
+ });
290
+ const payload = CreateKnowledgeDropRequest.parse(await c.req.json());
291
+ try {
292
+ let fileId: string;
293
+ if (payload.text !== undefined) {
294
+ const bytes = new TextEncoder().encode(payload.text);
295
+ await requireLimit(deps, {
296
+ accountId: grant.accountId,
297
+ workspaceId,
298
+ action: "file:upload",
299
+ quantity: bytes.length,
300
+ });
301
+ if (bytes.length > objectStorage.maxSinglePutSizeBytes) {
302
+ throw new HTTPException(413, {
303
+ message: `drop exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`,
304
+ });
305
+ }
306
+ const filename = dropFilename(payload.filename ?? payload.title);
307
+ const newFileId = crypto.randomUUID();
308
+ const safeFilename = sanitizeFilename(filename);
309
+ const objectKey = `workspaces/${workspaceId}/files/${newFileId}/original/${safeFilename}`;
310
+ const upload = await createFileUpload(db, {
311
+ accountId: grant.accountId,
312
+ workspaceId,
313
+ fileId: newFileId,
314
+ filename,
315
+ safeFilename,
316
+ contentType: "text/plain; charset=utf-8",
317
+ sizeBytes: bytes.length,
318
+ sha256: null,
319
+ bucket: objectStorage.bucket,
320
+ objectKey,
321
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000),
322
+ });
323
+ await objectStorage.putObject({
324
+ key: objectKey,
325
+ contentType: "text/plain; charset=utf-8",
326
+ body: bytes,
327
+ });
328
+ const file = await completeFileUpload(db, workspaceId, upload.uploadId);
329
+ await recordWorkspaceUsage(deps, {
330
+ accountId: grant.accountId,
331
+ workspaceId,
332
+ subjectId: grant.subjectId,
333
+ eventType: "file.uploaded",
334
+ quantity: file.sizeBytes,
335
+ unit: "byte",
336
+ sourceResourceType: "file",
337
+ sourceResourceId: file.id,
338
+ idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`,
339
+ });
340
+ fileId = file.id;
341
+ } else {
342
+ fileId = payload.fileId as string;
343
+ }
344
+ const defaultBase = await ensureDefaultBase(db, {
345
+ accountId: grant.accountId,
346
+ workspaceId,
347
+ });
348
+ const document = await addDocumentToBase(db, {
349
+ fileId,
350
+ ...(payload.title ? { title: payload.title } : {}),
351
+ ...(payload.visibility ? { visibility: payload.visibility } : {}),
352
+ ...(payload.agentAccess !== undefined ? { agentAccess: payload.agentAccess } : {}),
353
+ accountId: grant.accountId,
354
+ workspaceId,
355
+ baseId: defaultBase.id,
356
+ createdBy: grant.subjectId,
357
+ curationStatus: "pending",
358
+ access: { viewerSubjectId: grant.subjectId },
359
+ });
360
+ const wasCreated =
361
+ document.status === "queued" && document.chunkCount === 0 && document.error === null;
362
+ const indexed =
363
+ document.status === "ready"
364
+ ? document
365
+ : ((await documentIndexer.indexDocument({
366
+ accountId: grant.accountId,
367
+ workspaceId,
368
+ documentId: document.id,
369
+ })) ?? document);
370
+ if (indexed.status === "ready") {
371
+ await recordWorkspaceUsage(deps, {
372
+ accountId: grant.accountId,
373
+ workspaceId,
374
+ subjectId: grant.subjectId,
375
+ eventType: "document.indexed",
376
+ quantity: indexed.chunkCount,
377
+ unit: "chunk",
378
+ sourceResourceType: "document",
379
+ sourceResourceId: indexed.id,
380
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`,
381
+ });
382
+ }
383
+ return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
384
+ } catch (error) {
385
+ if (error instanceof HTTPException) {
386
+ throw error;
387
+ }
388
+ throw documentHttpException(error);
389
+ }
390
+ });
391
+
392
+ // Apply a curation suggestion (no body target) or move to an explicit base.
393
+ app.post("/v1/workspaces/:workspaceId/documents/:documentId/move", async (c) => {
394
+ const workspaceId = c.req.param("workspaceId");
395
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
396
+ const payload = MoveDocumentRequest.parse(await c.req.json().catch(() => ({})));
397
+ try {
398
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
399
+ viewerSubjectId: grant.subjectId,
400
+ });
401
+ if (!document) {
402
+ throw new HTTPException(404, { message: "document not found" });
403
+ }
404
+ return c.json(
405
+ Document.parse(
406
+ await moveDocumentToBase(db, {
407
+ accountId: grant.accountId,
408
+ workspaceId,
409
+ documentId: document.id,
410
+ targetBaseId: payload.targetBaseId ?? null,
411
+ access: { viewerSubjectId: grant.subjectId },
412
+ }),
413
+ ),
414
+ );
415
+ } catch (error) {
416
+ if (error instanceof HTTPException) {
417
+ throw error;
418
+ }
419
+ throw documentHttpException(error);
420
+ }
421
+ });
422
+
253
423
  app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
254
424
  const workspaceId = c.req.param("workspaceId");
255
425
  await requireAccessGrant(c, deps, workspaceId, "documents:search");
@@ -390,18 +560,30 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
390
560
  grant.accountId,
391
561
  workspaceId,
392
562
  getDocumentServices(),
393
- { createdBySessionId: sessionId },
563
+ { createdBySessionId: sessionId, viewerSubjectId: grant.subjectId },
394
564
  );
395
565
  await server.connect(transport);
396
566
  return await transport.handleRequest(c.req.raw);
397
567
  });
398
568
  }
399
569
 
570
+ /** Derive a .txt filename for a raw-text drop from its optional title/filename. */
571
+ function dropFilename(preferred: string | undefined): string {
572
+ const stem = (preferred ?? "").trim() || `note-${new Date().toISOString().slice(0, 10)}`;
573
+ return /\.[A-Za-z0-9]{1,8}$/.test(stem) ? stem : `${stem}.txt`;
574
+ }
575
+
400
576
  function documentHttpException(error: unknown): HTTPException {
401
577
  const message = error instanceof Error ? error.message : String(error);
402
578
  if (message.includes("not found")) {
403
579
  return new HTTPException(404, { message });
404
580
  }
581
+ if (message.includes("already exists")) {
582
+ return new HTTPException(409, { message });
583
+ }
584
+ if (message.includes("no suggested base")) {
585
+ return new HTTPException(422, { message });
586
+ }
405
587
  if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
406
588
  return new HTTPException(422, { message });
407
589
  }
@@ -351,7 +351,7 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
351
351
  });
352
352
  }
353
353
 
354
- function sanitizeFilename(filename: string): string {
354
+ export function sanitizeFilename(filename: string): string {
355
355
  const trimmed = filename.trim().replace(/[/\\]/g, "_");
356
356
  const safe = trimmed
357
357
  .replace(/[^A-Za-z0-9._ -]+/g, "_")