@opengeni/api-router 2.13.0-canary.0 → 2.13.0-canary.2

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.
Files changed (52) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-BJWHVMOL.js → chunk-K2NU4KRK.js} +3165 -3662
  3. package/dist/{chunk-BJWHVMOL.js.map → chunk-K2NU4KRK.js.map} +1 -1
  4. package/dist/index.js +5 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/integrations/github-lens-connect.d.ts +2 -2
  7. package/dist/integrations/slack-interactions.d.ts +1 -1
  8. package/dist/mcp/documents.d.ts +5 -13
  9. package/dist/mcp/files.d.ts +2 -2
  10. package/dist/mcp/first-party-tool-permissions.d.ts +36 -0
  11. package/dist/mcp/knowledge-entries.d.ts +5 -0
  12. package/dist/mcp/server.d.ts +4 -4
  13. package/dist/routes/host-mcp-resolvers.d.ts +3 -0
  14. package/dist/routes/knowledge.d.ts +3 -0
  15. package/dist/sandbox-file-artifacts.d.ts +6 -2
  16. package/dist/workspace-state-projection.d.ts +3 -5
  17. package/dist/workspace-tool-gateway.d.ts +2 -1
  18. package/package.json +19 -19
  19. package/src/access-grant-rls.ts +13 -1
  20. package/src/app.ts +4 -11
  21. package/src/codemode.ts +4 -8
  22. package/src/index.ts +4 -3
  23. package/src/integrations/atlassian.ts +31 -28
  24. package/src/integrations/google-drive.ts +33 -30
  25. package/src/integrations/marketplace-plugin.ts +1 -1
  26. package/src/integrations/provider-oauth.ts +11 -2
  27. package/src/mcp/documents.ts +57 -428
  28. package/src/mcp/files.ts +76 -58
  29. package/src/mcp/first-party-tool-permissions.ts +9 -0
  30. package/src/mcp/knowledge-entries.ts +276 -0
  31. package/src/mcp/server.ts +7 -361
  32. package/src/routes/company-brain.ts +58 -107
  33. package/src/routes/documents.ts +78 -294
  34. package/src/routes/files.ts +68 -3
  35. package/src/routes/host-mcp-resolvers.ts +41 -0
  36. package/src/routes/knowledge.ts +386 -0
  37. package/src/routes/organization-memberships.ts +33 -0
  38. package/src/routes/scheduled-tasks.ts +9 -1
  39. package/src/routes/sessions.ts +20 -3
  40. package/src/routes/skill-content.ts +8 -2
  41. package/src/routes/video-generation.ts +7 -5
  42. package/src/routes/workspace-learning.ts +12 -82
  43. package/src/routes/workspace-state.ts +34 -25
  44. package/src/routes/workspaces.ts +20 -10
  45. package/src/sandbox-file-artifacts.ts +54 -2
  46. package/src/scheduled-task-deletion.ts +3 -2
  47. package/src/workspace-state-projection.ts +18 -148
  48. package/src/workspace-tool-gateway.ts +14 -25
  49. package/dist/mcp/company-brain-governed-writes.d.ts +0 -26
  50. package/dist/mcp/remember.d.ts +0 -28
  51. package/src/mcp/company-brain-governed-writes.ts +0 -271
  52. package/src/mcp/remember.ts +0 -191
@@ -1,485 +1,114 @@
1
+ import { searchKnowledgeEntries } from "@opengeni/core";
2
+ import { KnowledgeEntryListRequest } from "@opengeni/contracts";
3
+ import type { DocumentServices } from "@opengeni/documents";
1
4
  import {
2
- KNOWLEDGE_BROWSE_CURSOR_MAX_CHARS,
3
- KnowledgeBrowseResponse,
4
- KnowledgeGetResponse,
5
- KnowledgeRecordId,
6
- KnowledgeSearchResponse,
7
- ListIndexedDocumentsResponse,
8
- } from "@opengeni/contracts";
9
- import {
10
- browseEffectiveKnowledge,
11
- getEffectiveKnowledgeRecord,
12
- getDocumentChunk,
13
- listDocumentBases,
14
- listEffectiveIndexedDocuments,
15
- resolveEffectiveDocumentAccess,
16
- searchEffectiveKnowledge,
17
- searchEffectiveDocuments,
18
- type DocumentAccessFilter,
19
- type DocumentServices,
20
- } from "@opengeni/documents";
21
- import {
22
- createKnowledgeMemory,
23
- listKnowledgeMemories,
5
+ getKnowledgeEntry,
6
+ listKnowledgeEntries,
7
+ type KnowledgeContext,
24
8
  type Database,
25
- type MemoryAgentScope,
26
9
  } from "@opengeni/db";
27
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
11
  import * as z from "zod/v4";
29
- import { mcpMutationReceipt } from "./receipts";
30
-
31
- const SearchInputSchema = {
32
- query: z.string().min(1),
33
- baseIds: z.array(z.string().uuid()).optional(),
34
- limit: z.number().int().positive().max(50).optional(),
35
- mode: z.enum(["hybrid", "vector", "keyword"]).optional(),
36
- sourceKinds: z
37
- .array(
38
- z.enum([
39
- "manual_upload",
40
- "meeting_transcript",
41
- "repository",
42
- "email",
43
- "chat",
44
- "document",
45
- "web",
46
- "other",
47
- ]),
48
- )
49
- .optional(),
50
- aclTags: z.array(z.string().min(1)).optional(),
51
- };
52
-
53
- const MemoryKindSchema = z.enum(["semantic", "episodic", "procedural", "decision", "preference"]);
54
- const SourceRefSchema = z.object({
55
- kind: z.enum(["document_chunk", "document", "session_event", "memory", "external"]),
56
- id: z.string().min(1),
57
- uri: z.string().min(1).optional(),
58
- title: z.string().min(1).optional(),
59
- metadata: z.record(z.string(), z.unknown()).optional(),
60
- });
61
12
 
13
+ /** The docs endpoint is a transport for canonical Knowledge retrieval. Legacy
14
+ * chunk/base retrieval is retired: it cannot bypass pending Knowledge review. */
62
15
  export function buildDocumentsMcpServer(
63
16
  db: Database,
64
17
  accountId: string,
65
18
  workspaceId: string,
66
19
  documentServices: DocumentServices,
67
- options: {
68
- createdBySessionId?: string | undefined;
69
- attemptId?: string | undefined;
70
- /** Immutable human subject whose agent is making this retrieval request. */
71
- initiatingSubjectId: string;
72
- /**
73
- * The bound session's typed Memory selector (migration 0427), resolved by
74
- * the caller from the session row. `off` registers neither memory tool;
75
- * `user`/`session` search the workspace layer plus the own private layer.
76
- * Omitted keeps the workspace layer.
77
- */
78
- memory?: MemoryAgentScope | null | undefined;
79
- },
20
+ options: { knowledge: KnowledgeContext },
80
21
  ): McpServer {
81
- const server = new McpServer({
82
- name: "opengeni-documents",
83
- version: "1.0.0",
84
- });
85
- // This server is the agent retrieval surface. Agent-disabled documents are
86
- // never reachable. Workspace-visible documents are shared; private
87
- // documents are available only to the creating subject's agent.
88
- const agentAuthority =
89
- options.createdBySessionId && options.attemptId
90
- ? { sessionId: options.createdBySessionId, attemptId: options.attemptId }
91
- : undefined;
92
- const resolveAgentAccess = async (): Promise<DocumentAccessFilter> =>
93
- await resolveEffectiveDocumentAccess(db, {
94
- accountId,
95
- workspaceId,
96
- initiatingSubjectId: options.initiatingSubjectId,
97
- surface: "agent",
98
- agentAuthority,
99
- });
100
-
101
- server.registerTool(
102
- "list_document_bases",
103
- {
104
- description: "List document bases available for retrieval.",
105
- inputSchema: {},
106
- },
107
- async () => ({
108
- content: [{ type: "text", text: JSON.stringify(await listDocumentBases(db, workspaceId)) }],
109
- }),
110
- );
111
-
112
- server.registerTool(
113
- "search_documents",
114
- {
115
- description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
116
- inputSchema: SearchInputSchema,
117
- },
118
- async (input) =>
119
- searchContent(
120
- db,
121
- accountId,
122
- workspaceId,
123
- documentServices,
124
- input,
125
- options.initiatingSubjectId,
126
- agentAuthority,
127
- ),
128
- );
129
-
22
+ const context = options.knowledge;
23
+ if (context.accountId !== accountId || context.workspaceId !== workspaceId)
24
+ throw new Error("Knowledge gateway authority mismatch");
25
+ const server = new McpServer({ name: "opengeni-knowledge", version: "2.0.0" });
130
26
  server.registerTool(
131
27
  "knowledge_search",
132
28
  {
133
29
  description:
134
- "Search the effective authorized organization, current-workspace, and immutable initiating-user personal document scope. Authorization is applied before ranking and every result retains source and authority provenance.",
135
- inputSchema: SearchInputSchema,
136
- },
137
- async (input) =>
138
- searchKnowledge(
139
- db,
140
- accountId,
141
- workspaceId,
142
- documentServices,
143
- input,
144
- options.initiatingSubjectId,
145
- agentAuthority,
146
- ),
147
- );
148
-
149
- server.registerTool(
150
- "knowledge_get",
151
- {
152
- description:
153
- "Fetch one stable Knowledge record by the id returned from knowledge_search or knowledge_browse. Authorization and agent access are rechecked before content or links are returned.",
154
- inputSchema: { id: KnowledgeRecordId },
155
- },
156
- async ({ id }) => {
157
- const record = await getEffectiveKnowledgeRecord(db, {
158
- accountId,
159
- workspaceId,
160
- initiatingSubjectId: options.initiatingSubjectId,
161
- agentAuthority,
162
- id,
163
- });
164
- return {
165
- content: [
166
- {
167
- type: "text",
168
- text: record
169
- ? JSON.stringify(KnowledgeGetResponse.parse({ record }))
170
- : `knowledge record not found: ${id}`,
171
- },
172
- ],
173
- isError: !record,
174
- };
175
- },
176
- );
177
-
178
- server.registerTool(
179
- "knowledge_browse",
180
- {
181
- description:
182
- "Browse authorized Knowledge without crawling folders. Omit parentId to list source documents; pass a document record id to inspect its chunks. Opaque cursors are bound to this user, workspace, parent, and filters.",
183
- inputSchema: {
184
- parentId: KnowledgeRecordId.optional(),
185
- topic: z.string().min(1).max(256).optional(),
186
- sourceKinds: SearchInputSchema.sourceKinds,
187
- cursor: z.string().min(1).max(KNOWLEDGE_BROWSE_CURSOR_MAX_CHARS).optional(),
188
- limit: z.number().int().positive().max(50).optional(),
189
- },
190
- },
191
- async ({ parentId, topic, sourceKinds, cursor, limit }) => ({
30
+ "Search published structured Knowledge and retained source text in the current authorized scope. Use a concise subject or entity name first (for example Acme); omit scope to search all authorized scopes. If a query returns no entries, retry the key name alone in the same scope or browse groups before concluding the information is absent.",
31
+ inputSchema: KnowledgeEntryListRequest.omit({
32
+ view: true,
33
+ sessionId: true,
34
+ reviewBatchId: true,
35
+ }).shape,
36
+ },
37
+ async (input) => ({
192
38
  content: [
193
39
  {
194
40
  type: "text",
195
41
  text: JSON.stringify(
196
- KnowledgeBrowseResponse.parse(
197
- await browseEffectiveKnowledge(db, {
198
- accountId,
199
- workspaceId,
200
- initiatingSubjectId: options.initiatingSubjectId,
201
- agentAuthority,
202
- ...(parentId ? { parentId } : {}),
203
- ...(topic ? { topic } : {}),
204
- ...(sourceKinds ? { sourceKinds } : {}),
205
- ...(cursor ? { cursor } : {}),
206
- ...(limit ? { limit } : {}),
207
- }),
208
- ),
42
+ await searchKnowledgeEntries(db, context, input, () => documentServices.embedder),
209
43
  ),
210
44
  },
211
45
  ],
212
46
  }),
213
47
  );
214
-
215
48
  server.registerTool(
216
- "list_indexed_documents",
217
- {
218
- description:
219
- "List agent-authorized documents that became ready after an opaque checkpoint, ordered by durable indexing completion. Results include source and authority/ingestion provenance. Persist nextCheckpoint only after successfully processing the returned page, then pass it on the next scheduled run to avoid repeats.",
220
- inputSchema: {
221
- checkpoint: z.string().min(1).max(1_024).optional(),
222
- limit: z.number().int().positive().max(100).optional(),
223
- },
224
- },
225
- async ({ checkpoint, limit }) => ({
226
- content: [
227
- {
228
- type: "text",
229
- text: JSON.stringify(
230
- ListIndexedDocumentsResponse.parse(
231
- await listEffectiveIndexedDocuments(db, {
232
- accountId,
233
- workspaceId,
234
- initiatingSubjectId: options.initiatingSubjectId,
235
- agentAuthority,
236
- ...(checkpoint ? { checkpoint } : {}),
237
- ...(limit ? { limit } : {}),
238
- }),
239
- ),
240
- ),
241
- },
242
- ],
243
- }),
244
- );
245
-
246
- server.registerTool(
247
- "fetch_document_chunk",
248
- {
249
- description: "Fetch one indexed document chunk by id.",
250
- inputSchema: {
251
- chunkId: z.string().uuid(),
252
- },
253
- },
254
- async ({ chunkId }) => {
255
- const found = await getDocumentChunk(
256
- db,
257
- accountId,
258
- workspaceId,
259
- chunkId,
260
- await resolveAgentAccess(),
261
- );
262
- return {
263
- content: [
264
- { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
265
- ],
266
- isError: !found,
267
- };
268
- },
269
- );
270
-
271
- server.registerTool(
272
- "knowledge_fetch",
273
- {
274
- description: "Fetch one knowledge source chunk by id.",
275
- inputSchema: {
276
- chunkId: z.string().uuid(),
277
- },
278
- },
279
- async ({ chunkId }) => {
280
- const found = await getDocumentChunk(
281
- db,
282
- accountId,
283
- workspaceId,
284
- chunkId,
285
- await resolveAgentAccess(),
286
- );
287
- return {
288
- content: [
289
- { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
290
- ],
291
- isError: !found,
292
- };
293
- },
294
- );
295
-
296
- const memoryScope = options.memory ?? null;
297
- if (memoryScope?.mode === "off") return server;
298
- const memoryReadScope: MemoryAgentScope = memoryScope ?? {
299
- mode: "workspace",
300
- userSubjectId: null,
301
- rootSessionId: null,
302
- };
303
-
304
- server.registerTool(
305
- "memory_search",
49
+ "knowledge_browse",
306
50
  {
307
- description: "Search approved company memory records.",
51
+ description: "Browse Knowledge groups or entries in one group, across sources.",
308
52
  inputSchema: {
309
- query: z.string().min(1).optional(),
310
- kind: MemoryKindSchema.optional(),
311
- scope: z.string().min(1).optional(),
312
- limit: z.number().int().positive().max(100).optional(),
53
+ groupId: z.uuid().optional(),
54
+ cursor: z.string().optional(),
55
+ limit: z.number().int().positive().max(50).optional(),
313
56
  },
314
57
  },
315
- async ({ query, kind, scope, limit }) => ({
58
+ async (input) => ({
316
59
  content: [
317
60
  {
318
61
  type: "text",
319
62
  text: JSON.stringify(
320
- await listKnowledgeMemories(db, workspaceId, {
321
- ...(query ? { query } : {}),
322
- status: ["active", "approved"],
323
- ...(kind ? { kind } : {}),
324
- ...(scope ? { scope } : {}),
325
- ...(limit ? { limit } : {}),
326
- agentScope: memoryReadScope,
63
+ await listKnowledgeEntries(db, context, {
64
+ ...input,
65
+ ...(input.groupId ? {} : { kind: "group" }),
327
66
  }),
328
67
  ),
329
68
  },
330
69
  ],
331
70
  }),
332
71
  );
333
-
334
72
  server.registerTool(
335
- "memory_propose",
73
+ "knowledge_get",
336
74
  {
337
- description: "Propose a company memory record for human review.",
75
+ description:
76
+ "Read an exact published Knowledge revision and its evidence. Long source text is paginated.",
338
77
  inputSchema: {
339
- text: z.string().min(1),
340
- kind: MemoryKindSchema.optional(),
341
- scope: z.string().min(1).optional(),
342
- sourceRefs: z.array(SourceRefSchema).optional(),
343
- confidence: z.number().min(0).max(1).optional(),
344
- metadata: z.record(z.string(), z.unknown()).optional(),
78
+ entryId: z.uuid(),
79
+ revisionId: z.uuid().optional(),
80
+ offset: z.number().int().nonnegative().default(0),
81
+ maxChars: z.number().int().positive().max(16000).default(8000),
345
82
  },
346
83
  },
347
- async ({ text, kind, scope, sourceRefs, confidence, metadata }) => {
348
- const memory = await createKnowledgeMemory(db, {
349
- accountId,
350
- workspaceId,
351
- status: "proposed",
352
- kind: kind ?? "semantic",
353
- scope: scope ?? "workspace",
354
- text,
355
- sourceRefs:
356
- sourceRefs?.map((sourceRef) => ({
357
- ...sourceRef,
358
- metadata: sourceRef.metadata ?? {},
359
- })) ?? [],
360
- confidence: confidence ?? 0.5,
361
- metadata: metadata ?? {},
362
- createdBySessionId: options.createdBySessionId,
84
+ async (input) => {
85
+ const record = await getKnowledgeEntry(db, context, input.entryId, {
86
+ revisionId: input.revisionId,
363
87
  });
88
+ if (!record) return { content: [{ type: "text", text: JSON.stringify({ found: false }) }] };
89
+ const text = record.revision.entry.content;
90
+ const end = Math.min(text.length, input.offset + input.maxChars);
364
91
  return {
365
92
  content: [
366
93
  {
367
94
  type: "text",
368
- text: JSON.stringify(
369
- mcpMutationReceipt({
370
- operation: "memory_propose",
371
- committed: true,
372
- outcome: "created",
373
- changed: true,
374
- resource: {
375
- type: "knowledge_memory",
376
- id: memory.id,
377
- state: memory.status,
378
- },
379
- timestamp: memory.updatedAt,
380
- idempotency: { status: "not_supported" },
381
- nextAction: { tool: "memory_search", arguments: {} },
382
- }),
383
- ),
95
+ text: JSON.stringify({
96
+ ...record,
97
+ revision: {
98
+ ...record.revision,
99
+ entry: { ...record.revision.entry, content: text.slice(input.offset, end) },
100
+ },
101
+ contentRange: {
102
+ start: input.offset,
103
+ end,
104
+ total: text.length,
105
+ nextOffset: end < text.length ? end : null,
106
+ },
107
+ }),
384
108
  },
385
109
  ],
386
110
  };
387
111
  },
388
112
  );
389
-
390
113
  return server;
391
114
  }
392
-
393
- async function searchContent(
394
- db: Database,
395
- accountId: string,
396
- workspaceId: string,
397
- documentServices: DocumentServices,
398
- input: {
399
- query: string;
400
- baseIds?: string[] | undefined;
401
- limit?: number | undefined;
402
- mode?: "hybrid" | "vector" | "keyword" | undefined;
403
- sourceKinds?:
404
- | Array<
405
- | "manual_upload"
406
- | "meeting_transcript"
407
- | "repository"
408
- | "email"
409
- | "chat"
410
- | "document"
411
- | "web"
412
- | "other"
413
- >
414
- | undefined;
415
- aclTags?: string[] | undefined;
416
- },
417
- initiatingSubjectId: string,
418
- agentAuthority: { sessionId: string; attemptId: string } | undefined,
419
- ) {
420
- const results = await searchEffectiveDocuments(
421
- db,
422
- {
423
- accountId,
424
- workspaceId,
425
- query: input.query,
426
- ...(input.baseIds ? { baseIds: input.baseIds } : {}),
427
- ...(input.limit ? { limit: input.limit } : {}),
428
- ...(input.mode ? { mode: input.mode } : {}),
429
- ...(input.sourceKinds ? { sourceKinds: input.sourceKinds } : {}),
430
- ...(input.aclTags ? { aclTags: input.aclTags } : {}),
431
- initiatingSubjectId,
432
- agentAuthority,
433
- surface: "agent",
434
- },
435
- documentServices,
436
- );
437
- return {
438
- content: [
439
- {
440
- type: "text" as const,
441
- text: JSON.stringify(results),
442
- },
443
- ],
444
- };
445
- }
446
-
447
- async function searchKnowledge(
448
- db: Database,
449
- accountId: string,
450
- workspaceId: string,
451
- documentServices: DocumentServices,
452
- input: {
453
- query: string;
454
- baseIds?: string[] | undefined;
455
- limit?: number | undefined;
456
- mode?: "hybrid" | "vector" | "keyword" | undefined;
457
- sourceKinds?: Parameters<typeof searchContent>[4]["sourceKinds"];
458
- aclTags?: string[] | undefined;
459
- },
460
- initiatingSubjectId: string,
461
- agentAuthority: { sessionId: string; attemptId: string } | undefined,
462
- ) {
463
- const response = await searchEffectiveKnowledge(
464
- db,
465
- {
466
- accountId,
467
- workspaceId,
468
- query: input.query,
469
- ...(input.baseIds ? { baseIds: input.baseIds } : {}),
470
- ...(input.limit ? { limit: input.limit } : {}),
471
- ...(input.mode ? { mode: input.mode } : {}),
472
- ...(input.sourceKinds ? { sourceKinds: input.sourceKinds } : {}),
473
- ...(input.aclTags ? { aclTags: input.aclTags } : {}),
474
- initiatingSubjectId,
475
- agentAuthority,
476
- surface: "agent",
477
- },
478
- documentServices,
479
- );
480
- return {
481
- content: [
482
- { type: "text" as const, text: JSON.stringify(KnowledgeSearchResponse.parse(response)) },
483
- ],
484
- };
485
- }
package/src/mcp/files.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  import type { AccessGrant } from "@opengeni/contracts";
2
- import { recordAuditEvent, requireFileForSubject } from "@opengeni/db";
3
- import { hasPermission, type ApiRouteDeps } from "@opengeni/core";
2
+ import { recordAuditEvent, requireFileForSubject, withSessionRlsActorContext } from "@opengeni/db";
3
+ import {
4
+ hasPermission,
5
+ fileOwnerContextForAccess,
6
+ fileOwnerContextForAgent,
7
+ type AccessGrantAuthorization,
8
+ type ApiRouteDeps,
9
+ } from "@opengeni/core";
4
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
11
  import * as z from "zod/v4";
6
12
 
7
13
  /** Dedicated file MCP surface. The broad `opengeni` server never registers it. */
8
- export function buildFilesMcpServer(deps: ApiRouteDeps, grant: AccessGrant): McpServer {
14
+ export function buildFilesMcpServer(
15
+ deps: ApiRouteDeps,
16
+ authority: AccessGrant | AccessGrantAuthorization,
17
+ ): McpServer {
18
+ const access = "grant" in authority ? authority : undefined;
19
+ const grant = access ? access.grant : (authority as AccessGrant);
9
20
  const server = new McpServer({
10
21
  name: "opengeni-files",
11
22
  version: "1.0.0",
@@ -34,62 +45,69 @@ export function buildFilesMcpServer(deps: ApiRouteDeps, grant: AccessGrant): Mcp
34
45
  inputSchema: { fileId: z.string().uuid() },
35
46
  },
36
47
  async ({ fileId }) => {
37
- if (!deps.objectStorage) {
38
- throw new Error("object storage is not configured");
39
- }
40
- const file = await requireFileForSubject(deps.db, {
41
- accountId: grant.accountId,
42
- workspaceId: grant.workspaceId,
43
- subjectId: grant.subjectId,
44
- fileId,
45
- });
46
- if (file.status !== "ready") {
47
- throw new Error(`file is ${file.status}`);
48
- }
49
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
50
- // Principal-facing signed URL issuance is a metadata-only audit
51
- // fact, awaited before the URL leaves the platform. Never the URL/key.
52
- await recordAuditEvent(deps.db, {
53
- accountId: grant.accountId,
54
- workspaceId: grant.workspaceId,
55
- subjectId: grant.subjectId,
56
- action: "file.signed_url.issued",
57
- targetType: "workspace_file",
58
- targetId: file.id,
59
- metadata: {
60
- fileId: file.id,
61
- kind: "mcp_download",
62
- expiresAt: signed.expiresAt.toISOString(),
63
- },
64
- });
65
- return {
66
- content: [
67
- {
68
- type: "text" as const,
69
- text: JSON.stringify(
70
- {
71
- file: {
72
- id: file.id,
73
- filename: file.filename,
74
- safeFilename: file.safeFilename,
75
- contentType: file.contentType,
76
- sizeBytes: file.sizeBytes,
77
- sha256: file.sha256,
78
- status: file.status,
79
- createdAt: file.createdAt,
80
- updatedAt: file.updatedAt,
81
- },
82
- downloadUrl: {
83
- url: signed.url,
84
- expiresAt: signed.expiresAt.toISOString(),
85
- },
86
- },
87
- null,
88
- 2,
89
- ),
48
+ const actor = access
49
+ ? await fileOwnerContextForAccess(deps, access, "files:read")
50
+ : grant.principalKind === "agent_attempt"
51
+ ? await fileOwnerContextForAgent(deps, grant, "files:read")
52
+ : { subjectId: grant.subjectId, privateFileOwnerSubjectId: null };
53
+ return withSessionRlsActorContext(actor, async () => {
54
+ if (!deps.objectStorage) {
55
+ throw new Error("object storage is not configured");
56
+ }
57
+ const file = await requireFileForSubject(deps.db, {
58
+ accountId: grant.accountId,
59
+ workspaceId: grant.workspaceId,
60
+ subjectId: grant.subjectId,
61
+ fileId,
62
+ });
63
+ if (file.status !== "ready") {
64
+ throw new Error(`file is ${file.status}`);
65
+ }
66
+ const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
67
+ // Principal-facing signed URL issuance is a metadata-only audit
68
+ // fact, awaited before the URL leaves the platform. Never the URL/key.
69
+ await recordAuditEvent(deps.db, {
70
+ accountId: grant.accountId,
71
+ workspaceId: grant.workspaceId,
72
+ subjectId: grant.subjectId,
73
+ action: "file.signed_url.issued",
74
+ targetType: "workspace_file",
75
+ targetId: file.id,
76
+ metadata: {
77
+ fileId: file.id,
78
+ kind: "mcp_download",
79
+ expiresAt: signed.expiresAt.toISOString(),
90
80
  },
91
- ],
92
- };
81
+ });
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text" as const,
86
+ text: JSON.stringify(
87
+ {
88
+ file: {
89
+ id: file.id,
90
+ filename: file.filename,
91
+ safeFilename: file.safeFilename,
92
+ contentType: file.contentType,
93
+ sizeBytes: file.sizeBytes,
94
+ sha256: file.sha256,
95
+ status: file.status,
96
+ createdAt: file.createdAt,
97
+ updatedAt: file.updatedAt,
98
+ },
99
+ downloadUrl: {
100
+ url: signed.url,
101
+ expiresAt: signed.expiresAt.toISOString(),
102
+ },
103
+ },
104
+ null,
105
+ 2,
106
+ ),
107
+ },
108
+ ],
109
+ };
110
+ });
93
111
  },
94
112
  );
95
113