@opengeni/api-router 2.1.0-canary.0 → 2.2.0-canary.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.
@@ -175,11 +175,24 @@ function labelFor(
175
175
  * override beats configuration beats derivation beats asking.
176
176
  */
177
177
  export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteResolution {
178
+ // A personal workspace is only ever a destination for that person's own bot
179
+ // DM. Offering it in a channel would be wrong twice over: routing a shared
180
+ // conversation into one member's private space hides it from everyone else
181
+ // in the channel, and - because managed tenancy provisions a personal
182
+ // workspace for every member - counting it as a candidate means nobody ever
183
+ // has exactly one. That defeats the sole-candidate rule below, so an
184
+ // organization with a single shared workspace would be asked to choose in
185
+ // every channel despite having no choice to make.
186
+ const directMessage = isSlackDirectMessageConversation(input.entry);
187
+ const candidates = directMessage
188
+ ? input.candidates
189
+ : input.candidates.filter((candidate) => !candidate.personal);
190
+
178
191
  const installation = {
179
192
  kind: "resolved" as const,
180
193
  accountId: input.home.accountId,
181
194
  workspaceId: input.home.workspaceId,
182
- label: labelFor(input.candidates, input.home.workspaceId),
195
+ label: labelFor(candidates, input.home.workspaceId),
183
196
  source: "installation" as const,
184
197
  };
185
198
 
@@ -206,7 +219,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
206
219
  kind: "resolved",
207
220
  accountId: input.threadTenancy.accountId,
208
221
  workspaceId: input.threadTenancy.workspaceId,
209
- label: labelFor(input.candidates, input.threadTenancy.workspaceId),
222
+ label: labelFor(candidates, input.threadTenancy.workspaceId),
210
223
  source: "thread",
211
224
  };
212
225
  }
@@ -221,13 +234,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
221
234
  ? parseSlackWorkspacePrefix(splitSlackLeadingMention(input.entry.text, input.botUserId).rest)
222
235
  : null;
223
236
  if (prefix) {
224
- const named = matchCandidate(input.candidates, prefix.requested);
237
+ const named = matchCandidate(candidates, prefix.requested);
225
238
  if (named === "ambiguous" || !named) {
226
239
  return {
227
240
  kind: "denied",
228
241
  reason: "no_access_to_named",
229
242
  requested: prefix.requested,
230
- candidates: input.candidates,
243
+ candidates: candidates,
231
244
  };
232
245
  }
233
246
  return {
@@ -245,7 +258,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
245
258
  kind: "resolved",
246
259
  accountId: input.channelRoute.targetAccountId,
247
260
  workspaceId: input.channelRoute.targetWorkspaceId,
248
- label: labelFor(input.candidates, input.channelRoute.targetWorkspaceId),
261
+ label: labelFor(candidates, input.channelRoute.targetWorkspaceId),
249
262
  source: "channel",
250
263
  };
251
264
  }
@@ -254,13 +267,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
254
267
  // lands in their own workspace unless they chose otherwise. The personal
255
268
  // workspace id is DERIVED from an active organization membership pointer;
256
269
  // it is never accepted from a Slack payload or a route row.
257
- if (isSlackDirectMessageConversation(input.entry)) {
270
+ if (directMessage) {
258
271
  if (input.dmRoute) {
259
272
  return {
260
273
  kind: "resolved",
261
274
  accountId: input.dmRoute.targetAccountId,
262
275
  workspaceId: input.dmRoute.targetWorkspaceId,
263
- label: labelFor(input.candidates, input.dmRoute.targetWorkspaceId),
276
+ label: labelFor(candidates, input.dmRoute.targetWorkspaceId),
264
277
  source: "dm_route",
265
278
  };
266
279
  }
@@ -269,7 +282,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
269
282
  kind: "resolved",
270
283
  accountId: input.home.accountId,
271
284
  workspaceId: input.personalWorkspaceId,
272
- label: labelFor(input.candidates, input.personalWorkspaceId),
285
+ label: labelFor(candidates, input.personalWorkspaceId),
273
286
  source: "dm_personal",
274
287
  };
275
288
  }
@@ -280,8 +293,8 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
280
293
 
281
294
  // 5. One workspace is not a choice. This is what keeps the flag quiet for
282
295
  // installs that only ever had one workspace.
283
- const sole = input.candidates[0];
284
- if (input.candidates.length === 1 && sole) {
296
+ const sole = candidates[0];
297
+ if (candidates.length === 1 && sole) {
285
298
  return {
286
299
  kind: "resolved",
287
300
  accountId: sole.accountId,
@@ -291,13 +304,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
291
304
  };
292
305
  }
293
306
 
294
- if (input.candidates.length === 0) {
307
+ if (candidates.length === 0) {
295
308
  return { kind: "denied", reason: "no_candidates", requested: null, candidates: [] };
296
309
  }
297
310
 
298
311
  // 6. Genuinely ambiguous. Until the picker exists, keep the installation's
299
312
  // workspace rather than inventing an answer.
300
- return input.askEnabled ? { kind: "ask", candidates: input.candidates } : installation;
313
+ return input.askEnabled ? { kind: "ask", candidates: candidates } : installation;
301
314
  }
302
315
 
303
316
  /**
@@ -7,6 +7,8 @@ import {
7
7
  DocumentAuthorityReclassification,
8
8
  DocumentBase,
9
9
  DocumentDefaultCollectionBackfill,
10
+ DocumentDefaultCollectionBackfillAudit,
11
+ GetDocumentDefaultCollectionBackfillAuditQuery,
10
12
  DocumentSearchRequest,
11
13
  DocumentSearchResponse,
12
14
  FileAsset,
@@ -15,6 +17,9 @@ import {
15
17
  KnowledgeMemorySearchRequest,
16
18
  ListDocumentAuthorityReclassificationsQuery,
17
19
  ListDocumentAuthorityReclassificationsResponse,
20
+ ListDocumentDefaultCollectionBackfillRunsResponse,
21
+ ListDocumentMigrationAuditQuery,
22
+ ListOrganizationDocumentAuthorityReclassificationsResponse,
18
23
  MoveDocumentRequest,
19
24
  ReclassifyDocumentAuthorityRequest,
20
25
  RunDocumentDefaultCollectionBackfillRequest,
@@ -40,10 +45,13 @@ import {
40
45
  getDocument,
41
46
  getDocumentOriginalFile,
42
47
  getDocumentBase,
48
+ getDocumentDefaultCollectionBackfillAudit,
43
49
  listAccessibleDocuments,
44
50
  listDocumentAuthorityReclassifications,
51
+ listDocumentDefaultCollectionBackfillRuns,
45
52
  listDocumentBasesEnsuringDefault,
46
53
  listDocuments,
54
+ listOrganizationDocumentAuthorityReclassifications,
47
55
  moveDocumentToBase,
48
56
  queueDocumentForReindex,
49
57
  reclassifyDocumentAuthority,
@@ -132,6 +140,106 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
132
140
  }
133
141
  });
134
142
 
143
+ app.get("/v1/workspaces/:workspaceId/document-default-collection-backfills", async (c) => {
144
+ const workspaceId = c.req.param("workspaceId");
145
+ const authorization = await requireAccessGrantAuthorization(
146
+ c,
147
+ deps,
148
+ workspaceId,
149
+ "documents:manage",
150
+ );
151
+ if (!hasAccountAdminAuthority(authorization)) {
152
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
153
+ }
154
+ const query = ListDocumentMigrationAuditQuery.safeParse(c.req.query());
155
+ if (!query.success) {
156
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
157
+ }
158
+ try {
159
+ return c.json(
160
+ ListDocumentDefaultCollectionBackfillRunsResponse.parse(
161
+ await listDocumentDefaultCollectionBackfillRuns(db, {
162
+ accountId: authorization.grant.accountId,
163
+ workspaceId,
164
+ actorSubjectId: authorization.grant.subjectId,
165
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
166
+ ...query.data,
167
+ }),
168
+ ),
169
+ );
170
+ } catch (error) {
171
+ throw documentHttpException(error);
172
+ }
173
+ });
174
+
175
+ app.get("/v1/workspaces/:workspaceId/document-default-collection-backfills/:runId", async (c) => {
176
+ const workspaceId = c.req.param("workspaceId");
177
+ const authorization = await requireAccessGrantAuthorization(
178
+ c,
179
+ deps,
180
+ workspaceId,
181
+ "documents:manage",
182
+ );
183
+ if (!hasAccountAdminAuthority(authorization)) {
184
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
185
+ }
186
+ const runId = RunDocumentDefaultCollectionBackfillRequest.shape.runId.safeParse(
187
+ c.req.param("runId"),
188
+ );
189
+ const query = GetDocumentDefaultCollectionBackfillAuditQuery.safeParse(c.req.query());
190
+ if (!runId.success || !query.success) {
191
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
192
+ }
193
+ try {
194
+ return c.json(
195
+ DocumentDefaultCollectionBackfillAudit.parse(
196
+ await getDocumentDefaultCollectionBackfillAudit(db, {
197
+ accountId: authorization.grant.accountId,
198
+ workspaceId,
199
+ actorSubjectId: authorization.grant.subjectId,
200
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
201
+ runId: runId.data,
202
+ ...query.data,
203
+ }),
204
+ ),
205
+ );
206
+ } catch (error) {
207
+ throw documentHttpException(error);
208
+ }
209
+ });
210
+
211
+ app.get("/v1/workspaces/:workspaceId/document-authority-reclassifications", async (c) => {
212
+ const workspaceId = c.req.param("workspaceId");
213
+ const authorization = await requireAccessGrantAuthorization(
214
+ c,
215
+ deps,
216
+ workspaceId,
217
+ "documents:manage",
218
+ );
219
+ if (!hasAccountAdminAuthority(authorization)) {
220
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
221
+ }
222
+ const query = ListDocumentMigrationAuditQuery.safeParse(c.req.query());
223
+ if (!query.success) {
224
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
225
+ }
226
+ try {
227
+ return c.json(
228
+ ListOrganizationDocumentAuthorityReclassificationsResponse.parse(
229
+ await listOrganizationDocumentAuthorityReclassifications(db, {
230
+ accountId: authorization.grant.accountId,
231
+ workspaceId,
232
+ actorSubjectId: authorization.grant.subjectId,
233
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
234
+ ...query.data,
235
+ }),
236
+ ),
237
+ );
238
+ } catch (error) {
239
+ throw documentHttpException(error);
240
+ }
241
+ });
242
+
135
243
  app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
136
244
  const workspaceId = c.req.param("workspaceId");
137
245
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:manage");
@@ -879,13 +987,16 @@ function dropFilename(preferred: string | undefined): string {
879
987
  }
880
988
 
881
989
  function documentHttpException(error: unknown): HTTPException {
882
- const message = error instanceof Error ? error.message : String(error);
990
+ const message = documentDomainErrorMessage(error);
883
991
  if (message.includes("organization document") && message.includes("exact account authority")) {
884
992
  return new HTTPException(403, { message: "missing permission: account:admin" });
885
993
  }
886
994
  if (message.includes("not found")) {
887
995
  return new HTTPException(404, { message });
888
996
  }
997
+ if (message.includes("document Default collection backfill audit run is unavailable")) {
998
+ return new HTTPException(404, { message });
999
+ }
889
1000
  if (message.includes("already exists")) {
890
1001
  return new HTTPException(409, { message });
891
1002
  }
@@ -898,7 +1009,9 @@ function documentHttpException(error: unknown): HTTPException {
898
1009
  }
899
1010
  if (
900
1011
  message.includes("reclassification requires") ||
901
- message.includes("backfill requires organization administration")
1012
+ message.includes("backfill requires organization administration") ||
1013
+ message.includes("document migration audit requires organization administration") ||
1014
+ message.includes("document migration audit scope is invalid")
902
1015
  ) {
903
1016
  return new HTTPException(403, { message });
904
1017
  }
@@ -906,7 +1019,12 @@ function documentHttpException(error: unknown): HTTPException {
906
1019
  message.includes("reclassification input is invalid") ||
907
1020
  message.includes("reclassification authority is invalid") ||
908
1021
  message.includes("invalid document authority receipt cursor") ||
909
- message.includes("document authority receipt limit")
1022
+ message.includes("document authority receipt limit") ||
1023
+ message.includes("invalid document migration audit cursor") ||
1024
+ message.includes("document migration audit cursor is invalid") ||
1025
+ message.includes("document migration audit limit") ||
1026
+ message.includes("document migration audit authority is incomplete") ||
1027
+ message.includes("document Default collection backfill audit run id is required")
910
1028
  ) {
911
1029
  return new HTTPException(400, { message });
912
1030
  }
@@ -929,6 +1047,19 @@ function documentHttpException(error: unknown): HTTPException {
929
1047
  return new HTTPException(500, { message });
930
1048
  }
931
1049
 
1050
+ function documentDomainErrorMessage(error: unknown): string {
1051
+ let current: unknown = error;
1052
+ let message = error instanceof Error ? error.message : String(error);
1053
+ const seen = new Set<unknown>();
1054
+ while (current && typeof current === "object" && !seen.has(current) && seen.size < 8) {
1055
+ seen.add(current);
1056
+ const record = current as { cause?: unknown; message?: unknown };
1057
+ if (typeof record.message === "string") message = record.message;
1058
+ current = record.cause;
1059
+ }
1060
+ return message;
1061
+ }
1062
+
932
1063
  function hasAccountAdminAuthority(
933
1064
  authorization: Awaited<ReturnType<typeof requireAccessGrantAuthorization>>,
934
1065
  ): boolean {