@opengeni/api-router 0.17.0 → 0.21.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.
@@ -13,6 +13,8 @@ import {
13
13
  import {
14
14
  GOOGLE_DRIVE_PROVIDER_DOMAIN,
15
15
  GoogleDriveConnectionMetadata,
16
+ GoogleDriveDisconnectRequest,
17
+ GoogleDriveLifecycleActionRequest,
16
18
  GoogleDriveOAuthStartRequest,
17
19
  GoogleDriveOAuthStartResponse,
18
20
  } from "@opengeni/contracts/google-drive";
@@ -47,8 +49,10 @@ import { HTTPException } from "hono/http-exception";
47
49
  import {
48
50
  browseGoogleDrive,
49
51
  completeGoogleDriveOAuthCallback,
52
+ disconnectGoogleDrive,
50
53
  saveGoogleDriveSource,
51
54
  startGoogleDriveOAuth,
55
+ transitionGoogleDriveLifecycle,
52
56
  } from "../integrations/google-drive";
53
57
  import {
54
58
  completeMcpOAuthCallback,
@@ -288,6 +292,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
288
292
  return c.redirect(result.redirectTo, 302);
289
293
  });
290
294
 
295
+ app.patch(
296
+ "/v1/workspaces/:workspaceId/connections/google-drive/:connectionId/lifecycle",
297
+ async (c) => {
298
+ assertIntegrationsEnabled();
299
+ const workspaceId = c.req.param("workspaceId");
300
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
301
+ const parsed = GoogleDriveLifecycleActionRequest.safeParse(await c.req.json());
302
+ if (!parsed.success) {
303
+ throw new HTTPException(400, { message: "invalid Google Drive lifecycle request" });
304
+ }
305
+ return c.json(
306
+ ConnectionResponse.parse({
307
+ connection: await transitionGoogleDriveLifecycle(deps, {
308
+ workspaceId,
309
+ subjectId: grant.subjectId,
310
+ connectionId: c.req.param("connectionId"),
311
+ payload: parsed.data,
312
+ }),
313
+ }),
314
+ );
315
+ },
316
+ );
317
+
291
318
  app.get(
292
319
  "/v1/workspaces/:workspaceId/connections/google-drive/:connectionId/browse",
293
320
  async (c) => {
@@ -421,18 +448,43 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
421
448
  if (!existing) {
422
449
  throw new HTTPException(404, { message: "connection not found" });
423
450
  }
424
- const connection = isOpenGeniSlackBotConnection(existing)
425
- ? await revokeConnectionWithSlackBotSuccessAudit(db, {
426
- accountId: grant.accountId,
451
+ const isGoogleDrive =
452
+ existing.subjectId === grant.subjectId &&
453
+ existing.providerDomain === GOOGLE_DRIVE_PROVIDER_DOMAIN &&
454
+ existing.kind === "oauth2" &&
455
+ GoogleDriveConnectionMetadata.safeParse(existing.metadata).success;
456
+ const googleDriveDisconnect = isGoogleDrive
457
+ ? GoogleDriveDisconnectRequest.safeParse(await c.req.json().catch(() => null))
458
+ : null;
459
+ if (googleDriveDisconnect && !googleDriveDisconnect.success) {
460
+ throw new HTTPException(400, {
461
+ message:
462
+ googleDriveDisconnect.error.issues[0]?.message ??
463
+ "invalid Google Drive disconnect request",
464
+ });
465
+ }
466
+ if (existing.status === "revoked" && !isGoogleDrive) {
467
+ return c.json(ConnectionResponse.parse({ connection: existing }));
468
+ }
469
+ const connection = isGoogleDrive
470
+ ? await disconnectGoogleDrive(deps, {
427
471
  workspaceId,
428
472
  subjectId: grant.subjectId,
429
- connectionId,
430
- expectedVersion: existing.version,
431
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
432
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
433
- slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
473
+ connection: existing,
474
+ payload: googleDriveDisconnect!.data,
434
475
  })
435
- : await revokeConnection(db, workspaceId, connectionId, grant.subjectId);
476
+ : isOpenGeniSlackBotConnection(existing)
477
+ ? await revokeConnectionWithSlackBotSuccessAudit(db, {
478
+ accountId: grant.accountId,
479
+ workspaceId,
480
+ subjectId: grant.subjectId,
481
+ connectionId,
482
+ expectedVersion: existing.version,
483
+ credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
484
+ credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
485
+ slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
486
+ })
487
+ : await revokeConnection(db, workspaceId, connectionId, grant.subjectId, existing.version);
436
488
  if (!connection) {
437
489
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
438
490
  }
@@ -6,6 +6,7 @@ import {
6
6
  Document,
7
7
  DocumentBase,
8
8
  DocumentSearchRequest,
9
+ DocumentSearchResponse,
9
10
  KnowledgeMemory,
10
11
  KnowledgeMemorySearchRequest,
11
12
  MoveDocumentRequest,
@@ -34,12 +35,12 @@ import {
34
35
  listDocuments,
35
36
  moveDocumentToBase,
36
37
  queueDocumentForReindex,
37
- searchDocuments,
38
+ searchEffectiveDocuments,
38
39
  } from "@opengeni/documents";
39
40
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
40
41
  import type { Context, Hono } from "hono";
41
42
  import { HTTPException } from "hono/http-exception";
42
- import { requireAccessGrant } from "@opengeni/core";
43
+ import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/core";
43
44
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
44
45
  import type { ApiRouteDeps } from "@opengeni/core";
45
46
  import { buildDocumentsMcpServer } from "../mcp/documents";
@@ -85,7 +86,8 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
85
86
 
86
87
  app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
87
88
  const workspaceId = c.req.param("workspaceId");
88
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
89
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:manage");
90
+ const { grant } = access;
89
91
  if (!objectStorage) {
90
92
  throw new HTTPException(503, { message: "object storage is not configured" });
91
93
  }
@@ -96,6 +98,11 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
96
98
  quantity: 0,
97
99
  });
98
100
  const payload = AddDocumentRequest.parse(await c.req.json());
101
+ const organizationAuthorityGranted =
102
+ access.accountGrant?.permissions.includes("account:admin") === true;
103
+ if (payload.authorityKind === "organization" && !organizationAuthorityGranted) {
104
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
105
+ }
99
106
  try {
100
107
  const document = await addDocumentToBase(db, {
101
108
  ...payload,
@@ -103,6 +110,8 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
103
110
  workspaceId,
104
111
  baseId: c.req.param("baseId"),
105
112
  createdBy: grant.subjectId,
113
+ initiatingSubjectId: grant.subjectId,
114
+ organizationAuthorityGranted,
106
115
  access: { viewerSubjectId: grant.subjectId },
107
116
  });
108
117
  const wasCreated =
@@ -114,6 +123,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
114
123
  accountId: grant.accountId,
115
124
  workspaceId,
116
125
  documentId: document.id,
126
+ authorityKind: document.authorityKind,
127
+ authorityWorkspaceId: document.authorityWorkspaceId,
128
+ authoritySubjectId: document.authoritySubjectId,
117
129
  })) ?? document);
118
130
  if (indexed.status === "ready") {
119
131
  await recordWorkspaceUsage(deps, {
@@ -150,13 +162,28 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
150
162
  "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId",
151
163
  async (c) => {
152
164
  const workspaceId = c.req.param("workspaceId");
153
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
165
+ const authorization = await requireAccessGrantAuthorization(
166
+ c,
167
+ deps,
168
+ workspaceId,
169
+ "documents:manage",
170
+ );
171
+ const { grant } = authorization;
172
+ const organizationAuthorityGranted = hasAccountAdminAuthority(authorization);
154
173
  try {
174
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
175
+ viewerSubjectId: grant.subjectId,
176
+ });
177
+ if (!document || document.baseId !== c.req.param("baseId")) {
178
+ throw new HTTPException(404, { message: "document not found" });
179
+ }
180
+ requireOrganizationDocumentAuthority(document.authorityKind, organizationAuthorityGranted);
155
181
  await deleteDocumentFromBase(db, {
156
182
  accountId: grant.accountId,
157
183
  workspaceId,
158
184
  baseId: c.req.param("baseId"),
159
185
  documentId: c.req.param("documentId"),
186
+ organizationAuthorityGranted,
160
187
  access: { viewerSubjectId: grant.subjectId },
161
188
  });
162
189
  return c.body(null, 204);
@@ -173,7 +200,14 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
173
200
  "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex",
174
201
  async (c) => {
175
202
  const workspaceId = c.req.param("workspaceId");
176
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
203
+ const authorization = await requireAccessGrantAuthorization(
204
+ c,
205
+ deps,
206
+ workspaceId,
207
+ "documents:manage",
208
+ );
209
+ const { grant } = authorization;
210
+ const organizationAuthorityGranted = hasAccountAdminAuthority(authorization);
177
211
  if (!objectStorage) {
178
212
  throw new HTTPException(503, { message: "object storage is not configured" });
179
213
  }
@@ -190,20 +224,30 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
190
224
  if (!document) {
191
225
  throw new HTTPException(404, { message: "document not found" });
192
226
  }
227
+ requireOrganizationDocumentAuthority(document.authorityKind, organizationAuthorityGranted);
193
228
  if (document.status !== "failed") {
194
229
  throw new HTTPException(422, { message: "only failed documents can be retried" });
195
230
  }
196
231
  if (document.baseId !== c.req.param("baseId")) {
197
232
  throw new HTTPException(404, { message: "document not found" });
198
233
  }
199
- const queued = await queueDocumentForReindex(db, workspaceId, document.id, {
200
- viewerSubjectId: grant.subjectId,
201
- });
234
+ const queued = await queueDocumentForReindex(
235
+ db,
236
+ workspaceId,
237
+ document.id,
238
+ {
239
+ viewerSubjectId: grant.subjectId,
240
+ },
241
+ organizationAuthorityGranted,
242
+ );
202
243
  const indexed =
203
244
  (await documentIndexer.indexDocument({
204
245
  accountId: grant.accountId,
205
246
  workspaceId,
206
247
  documentId: document.id,
248
+ authorityKind: document.authorityKind,
249
+ authorityWorkspaceId: document.authorityWorkspaceId,
250
+ authoritySubjectId: document.authoritySubjectId,
207
251
  })) ?? queued;
208
252
  if (indexed.status === "ready") {
209
253
  await recordWorkspaceUsage(deps, {
@@ -236,44 +280,52 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
236
280
  if (!base) {
237
281
  throw new HTTPException(404, { message: "document base not found" });
238
282
  }
239
- return c.json({
240
- results: await searchDocuments(
241
- db,
242
- {
243
- workspaceId,
244
- baseIds: [base.id],
245
- query: payload.query,
246
- limit: payload.limit,
247
- mode: payload.mode,
248
- sourceKinds: payload.sourceKinds,
249
- aclTags: payload.aclTags,
250
- access: { viewerSubjectId: grant.subjectId },
251
- },
252
- getDocumentServices(),
253
- ),
254
- });
283
+ return c.json(
284
+ DocumentSearchResponse.parse({
285
+ results: await searchEffectiveDocuments(
286
+ db,
287
+ {
288
+ accountId: grant.accountId,
289
+ workspaceId,
290
+ baseIds: [base.id],
291
+ query: payload.query,
292
+ limit: payload.limit,
293
+ mode: payload.mode,
294
+ sourceKinds: payload.sourceKinds,
295
+ aclTags: payload.aclTags,
296
+ initiatingSubjectId: grant.subjectId,
297
+ surface: "human",
298
+ },
299
+ getDocumentServices(),
300
+ ),
301
+ }),
302
+ );
255
303
  });
256
304
 
257
305
  app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
258
306
  const workspaceId = c.req.param("workspaceId");
259
307
  const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
260
308
  const payload = await parseDocumentSearchRequest(c, "invalid knowledge search request");
261
- return c.json({
262
- results: await searchDocuments(
263
- db,
264
- {
265
- workspaceId,
266
- query: payload.query,
267
- baseIds: payload.baseIds,
268
- limit: payload.limit,
269
- mode: payload.mode,
270
- sourceKinds: payload.sourceKinds,
271
- aclTags: payload.aclTags,
272
- access: { viewerSubjectId: grant.subjectId },
273
- },
274
- getDocumentServices(),
275
- ),
276
- });
309
+ return c.json(
310
+ DocumentSearchResponse.parse({
311
+ results: await searchEffectiveDocuments(
312
+ db,
313
+ {
314
+ accountId: grant.accountId,
315
+ workspaceId,
316
+ query: payload.query,
317
+ baseIds: payload.baseIds,
318
+ limit: payload.limit,
319
+ mode: payload.mode,
320
+ sourceKinds: payload.sourceKinds,
321
+ aclTags: payload.aclTags,
322
+ initiatingSubjectId: grant.subjectId,
323
+ surface: "human",
324
+ },
325
+ getDocumentServices(),
326
+ ),
327
+ }),
328
+ );
277
329
  });
278
330
 
279
331
  // Knowledge drop: raw text or an uploaded file, no metadata required. Lands
@@ -282,7 +334,8 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
282
334
  // curation is disabled.
283
335
  app.post("/v1/workspaces/:workspaceId/knowledge/drops", async (c) => {
284
336
  const workspaceId = c.req.param("workspaceId");
285
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
337
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:manage");
338
+ const { grant } = access;
286
339
  if (!objectStorage) {
287
340
  throw new HTTPException(503, { message: "object storage is not configured" });
288
341
  }
@@ -293,6 +346,11 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
293
346
  quantity: 0,
294
347
  });
295
348
  const payload = CreateKnowledgeDropRequest.parse(await c.req.json());
349
+ const organizationAuthorityGranted =
350
+ access.accountGrant?.permissions.includes("account:admin") === true;
351
+ if (payload.authorityKind === "organization" && !organizationAuthorityGranted) {
352
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
353
+ }
296
354
  try {
297
355
  let fileId: string;
298
356
  if (payload.text !== undefined) {
@@ -353,12 +411,15 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
353
411
  const document = await addDocumentToBase(db, {
354
412
  fileId,
355
413
  ...(payload.title ? { title: payload.title } : {}),
414
+ ...(payload.authorityKind ? { authorityKind: payload.authorityKind } : {}),
356
415
  ...(payload.visibility ? { visibility: payload.visibility } : {}),
357
416
  ...(payload.agentAccess !== undefined ? { agentAccess: payload.agentAccess } : {}),
358
417
  accountId: grant.accountId,
359
418
  workspaceId,
360
419
  baseId: defaultBase.id,
361
420
  createdBy: grant.subjectId,
421
+ initiatingSubjectId: grant.subjectId,
422
+ organizationAuthorityGranted,
362
423
  curationStatus: "pending",
363
424
  access: { viewerSubjectId: grant.subjectId },
364
425
  });
@@ -371,6 +432,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
371
432
  accountId: grant.accountId,
372
433
  workspaceId,
373
434
  documentId: document.id,
435
+ authorityKind: document.authorityKind,
436
+ authorityWorkspaceId: document.authorityWorkspaceId,
437
+ authoritySubjectId: document.authoritySubjectId,
374
438
  })) ?? document);
375
439
  if (indexed.status === "ready") {
376
440
  await recordWorkspaceUsage(deps, {
@@ -397,7 +461,14 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
397
461
  // Apply a curation suggestion (no body target) or move to an explicit base.
398
462
  app.post("/v1/workspaces/:workspaceId/documents/:documentId/move", async (c) => {
399
463
  const workspaceId = c.req.param("workspaceId");
400
- const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
464
+ const authorization = await requireAccessGrantAuthorization(
465
+ c,
466
+ deps,
467
+ workspaceId,
468
+ "documents:manage",
469
+ );
470
+ const { grant } = authorization;
471
+ const organizationAuthorityGranted = hasAccountAdminAuthority(authorization);
401
472
  const payload = MoveDocumentRequest.parse(await c.req.json().catch(() => ({})));
402
473
  try {
403
474
  const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
@@ -406,6 +477,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
406
477
  if (!document) {
407
478
  throw new HTTPException(404, { message: "document not found" });
408
479
  }
480
+ requireOrganizationDocumentAuthority(document.authorityKind, organizationAuthorityGranted);
409
481
  return c.json(
410
482
  Document.parse(
411
483
  await moveDocumentToBase(db, {
@@ -413,6 +485,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
413
485
  workspaceId,
414
486
  documentId: document.id,
415
487
  targetBaseId: payload.targetBaseId ?? null,
488
+ organizationAuthorityGranted,
416
489
  access: { viewerSubjectId: grant.subjectId },
417
490
  }),
418
491
  ),
@@ -565,7 +638,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
565
638
  grant.accountId,
566
639
  workspaceId,
567
640
  getDocumentServices(),
568
- { createdBySessionId: sessionId, viewerSubjectId: grant.subjectId },
641
+ { createdBySessionId: sessionId, initiatingSubjectId: grant.subjectId },
569
642
  );
570
643
  await server.connect(transport);
571
644
  return await transport.handleRequest(c.req.raw);
@@ -591,6 +664,9 @@ function dropFilename(preferred: string | undefined): string {
591
664
 
592
665
  function documentHttpException(error: unknown): HTTPException {
593
666
  const message = error instanceof Error ? error.message : String(error);
667
+ if (message.includes("organization document") && message.includes("exact account authority")) {
668
+ return new HTTPException(403, { message: "missing permission: account:admin" });
669
+ }
594
670
  if (message.includes("not found")) {
595
671
  return new HTTPException(404, { message });
596
672
  }
@@ -615,3 +691,18 @@ function documentHttpException(error: unknown): HTTPException {
615
691
  }
616
692
  return new HTTPException(500, { message });
617
693
  }
694
+
695
+ function hasAccountAdminAuthority(
696
+ authorization: Awaited<ReturnType<typeof requireAccessGrantAuthorization>>,
697
+ ): boolean {
698
+ return authorization.accountGrant?.permissions.includes("account:admin") === true;
699
+ }
700
+
701
+ function requireOrganizationDocumentAuthority(
702
+ authorityKind: string,
703
+ organizationAuthorityGranted: boolean,
704
+ ): void {
705
+ if (authorityKind === "organization" && !organizationAuthorityGranted) {
706
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
707
+ }
708
+ }
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
2
3
  ActivateWorkspaceInstructionPolicyRequest,
3
4
  CreateWorkspaceInstructionPolicyDraftRequest,
5
+ CreateWorkspaceInstructionPolicyOnboardingProposalRequest,
4
6
  ImportLegacyWorkspaceInstructionPolicyDraftRequest,
5
7
  RollbackWorkspaceInstructionPolicyRequest,
6
8
  WorkspaceInstructionPolicyActivationResponse,
@@ -9,21 +11,35 @@ import {
9
11
  WorkspaceInstructionPolicyDiffResponse,
10
12
  WorkspaceInstructionPolicyListQuery,
11
13
  WorkspaceInstructionPolicyListResponse,
14
+ WorkspaceInstructionPolicyOnboardingProposal,
15
+ WorkspaceInstructionPolicyOnboardingProposalConflictResponse,
16
+ WorkspaceInstructionPolicyOnboardingProposalContentErrorResponse,
17
+ WorkspaceInstructionPolicyOnboardingProposalListQuery,
18
+ WorkspaceInstructionPolicyOnboardingProposalListResponse,
19
+ WorkspaceInstructionPolicyOnboardingProposalStaleResponse,
20
+ WorkspaceInstructionPolicyOperationReuseResponse,
12
21
  WorkspaceInstructionPolicyRevision,
22
+ WORKSPACE_INSTRUCTION_POLICY_CONTENT_MAX_CHARS,
13
23
  } from "@opengeni/contracts";
14
24
  import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
15
25
  import {
16
26
  activateWorkspaceInstructionPolicyRevision,
17
27
  createWorkspaceInstructionPolicyDraft,
28
+ createWorkspaceInstructionPolicyOnboardingProposal,
18
29
  diffWorkspaceInstructionPolicyRevisions,
19
30
  getWorkspaceInstructionPolicyRevision,
20
31
  importLegacyWorkspaceInstructionPolicyDraft,
21
32
  listWorkspaceInstructionPolicyRevisions,
33
+ listWorkspaceInstructionPolicyOnboardingProposals,
22
34
  rollbackWorkspaceInstructionPolicyRevision,
23
35
  WorkspaceInstructionPolicyConflictError,
24
36
  WorkspaceInstructionPolicyInvalidOperationError,
25
37
  WorkspaceInstructionPolicyLegacyUnavailableError,
26
38
  WorkspaceInstructionPolicyNotFoundError,
39
+ WorkspaceInstructionPolicyOnboardingProposalConflictError,
40
+ WorkspaceInstructionPolicyOnboardingProposalContentError,
41
+ WorkspaceInstructionPolicyOnboardingProposalStaleError,
42
+ WorkspaceInstructionPolicyOperationReuseError,
27
43
  } from "@opengeni/db";
28
44
  import type { Context, Hono } from "hono";
29
45
  import { HTTPException } from "hono/http-exception";
@@ -50,6 +66,46 @@ function policyErrorResponse(context: Context, error: unknown): Response {
50
66
  409,
51
67
  );
52
68
  }
69
+ if (error instanceof WorkspaceInstructionPolicyOperationReuseError) {
70
+ return context.json(
71
+ WorkspaceInstructionPolicyOperationReuseResponse.parse({
72
+ code: error.code,
73
+ message: error.message,
74
+ }),
75
+ 409,
76
+ );
77
+ }
78
+ if (error instanceof WorkspaceInstructionPolicyOnboardingProposalContentError) {
79
+ return context.json(
80
+ WorkspaceInstructionPolicyOnboardingProposalContentErrorResponse.parse({
81
+ code: error.code,
82
+ message: error.message,
83
+ maxChars: WORKSPACE_INSTRUCTION_POLICY_CONTENT_MAX_CHARS,
84
+ }),
85
+ 422,
86
+ );
87
+ }
88
+ if (error instanceof WorkspaceInstructionPolicyOnboardingProposalStaleError) {
89
+ return context.json(
90
+ WorkspaceInstructionPolicyOnboardingProposalStaleResponse.parse({
91
+ code: error.code,
92
+ message: error.message,
93
+ currentHead: error.currentHead,
94
+ }),
95
+ 409,
96
+ );
97
+ }
98
+ if (error instanceof WorkspaceInstructionPolicyOnboardingProposalConflictError) {
99
+ return context.json(
100
+ WorkspaceInstructionPolicyOnboardingProposalConflictResponse.parse({
101
+ code: error.code,
102
+ message: error.message,
103
+ existingProposalId: error.existingProposalId,
104
+ existingDraftRevisionId: error.existingDraftRevisionId,
105
+ }),
106
+ 409,
107
+ );
108
+ }
53
109
  if (error instanceof WorkspaceInstructionPolicyNotFoundError) {
54
110
  return context.json(
55
111
  { code: "WORKSPACE_INSTRUCTION_POLICY_NOT_FOUND", message: error.message },
@@ -117,6 +173,7 @@ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRou
117
173
  return context.json(
118
174
  WorkspaceInstructionPolicyRevision.parse(
119
175
  await createWorkspaceInstructionPolicyDraft(deps.db, {
176
+ operationId: request.operationId ?? randomUUID(),
120
177
  accountId: grant.accountId,
121
178
  workspaceId,
122
179
  createdBySubjectId: grant.subjectId,
@@ -145,6 +202,7 @@ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRou
145
202
  return context.json(
146
203
  WorkspaceInstructionPolicyRevision.parse(
147
204
  await importLegacyWorkspaceInstructionPolicyDraft(deps.db, {
205
+ operationId: request.operationId ?? randomUUID(),
148
206
  accountId: grant.accountId,
149
207
  workspaceId,
150
208
  createdBySubjectId: grant.subjectId,
@@ -158,6 +216,58 @@ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRou
158
216
  }
159
217
  });
160
218
 
219
+ app.get(`${base}/onboarding-proposals`, async (context) => {
220
+ const workspaceId = context.req.param("workspaceId");
221
+ await requireAccessGrant(context, deps, workspaceId, "workspace:read");
222
+ const parsed = WorkspaceInstructionPolicyOnboardingProposalListQuery.safeParse({
223
+ limit: context.req.query("limit"),
224
+ });
225
+ if (!parsed.success) {
226
+ throw new HTTPException(422, {
227
+ message: "Invalid workspace instruction-policy onboarding-proposal query",
228
+ });
229
+ }
230
+ return context.json(
231
+ WorkspaceInstructionPolicyOnboardingProposalListResponse.parse(
232
+ await listWorkspaceInstructionPolicyOnboardingProposals(deps.db, workspaceId, parsed.data),
233
+ ),
234
+ );
235
+ });
236
+
237
+ app.post(`${base}/onboarding-proposals`, async (context) => {
238
+ const workspaceId = context.req.param("workspaceId");
239
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
240
+ assertBoundedActor(grant.subjectId);
241
+ const request = await parseBody(
242
+ context,
243
+ CreateWorkspaceInstructionPolicyOnboardingProposalRequest,
244
+ );
245
+ try {
246
+ return context.json(
247
+ WorkspaceInstructionPolicyOnboardingProposal.parse(
248
+ await createWorkspaceInstructionPolicyOnboardingProposal(deps.db, {
249
+ operationId: request.operationId ?? randomUUID(),
250
+ accountId: grant.accountId,
251
+ workspaceId,
252
+ createdBySubjectId: grant.subjectId,
253
+ kind: request.kind,
254
+ scope: request.scope,
255
+ roleKey: request.roleKey,
256
+ content: request.content,
257
+ sourceId: request.sourceId,
258
+ sourceVersion: request.sourceVersion,
259
+ confidenceBps: request.confidenceBps,
260
+ expectedCurrentRevisionId: request.expectedCurrentRevisionId,
261
+ expectedActivationVersion: request.expectedActivationVersion,
262
+ }),
263
+ ),
264
+ 201,
265
+ );
266
+ } catch (error) {
267
+ return policyErrorResponse(context, error);
268
+ }
269
+ });
270
+
161
271
  app.get(`${base}/diff`, async (context) => {
162
272
  const workspaceId = context.req.param("workspaceId");
163
273
  await requireAccessGrant(context, deps, workspaceId, "workspace:read");
@@ -188,10 +298,14 @@ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRou
188
298
  return context.json(
189
299
  WorkspaceInstructionPolicyActivationResponse.parse(
190
300
  await rollbackWorkspaceInstructionPolicyRevision(deps.db, {
301
+ operationId: request.operationId ?? randomUUID(),
191
302
  accountId: grant.accountId,
192
303
  workspaceId,
193
304
  targetRevisionId: request.targetRevisionId,
194
305
  expectedCurrentRevisionId: request.expectedCurrentRevisionId,
306
+ ...(request.expectedActivationVersion === undefined
307
+ ? {}
308
+ : { expectedActivationVersion: request.expectedActivationVersion }),
195
309
  actorSubjectId: grant.subjectId,
196
310
  reason: request.reason,
197
311
  }),
@@ -227,10 +341,14 @@ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRou
227
341
  return context.json(
228
342
  WorkspaceInstructionPolicyActivationResponse.parse(
229
343
  await activateWorkspaceInstructionPolicyRevision(deps.db, {
344
+ operationId: request.operationId ?? randomUUID(),
230
345
  accountId: grant.accountId,
231
346
  workspaceId,
232
347
  revisionId,
233
348
  expectedCurrentRevisionId: request.expectedCurrentRevisionId,
349
+ ...(request.expectedActivationVersion === undefined
350
+ ? {}
351
+ : { expectedActivationVersion: request.expectedActivationVersion }),
234
352
  actorSubjectId: grant.subjectId,
235
353
  reason: request.reason,
236
354
  }),