@opengeni/api-router 0.21.3 → 0.21.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.
@@ -22,6 +22,7 @@ import { OAUTH_MAX_RESPONSE_BYTES, pinnedFetch, readResponseJsonBounded } from "
22
22
  import { Buffer } from "node:buffer";
23
23
  import { createHash, randomBytes } from "node:crypto";
24
24
  import { HTTPException } from "hono/http-exception";
25
+ import { ApiHttpError } from "../http/api-error";
25
26
  import {
26
27
  integrationBaseUrl,
27
28
  oauthStateTtlMs,
@@ -154,8 +155,12 @@ export function socialOAuthClientFor(
154
155
  ): { clientId: string; clientSecret?: string | undefined } {
155
156
  const configured = parseSocialOauthClientsJson(settings.socialOauthClientsJson)[provider];
156
157
  if (!configured) {
157
- throw new HTTPException(503, {
158
- message: `social provider ${provider} requires an operator OAuth app in OPENGENI_SOCIAL_OAUTH_CLIENTS_JSON`,
158
+ const providerLabel = provider === "x" ? "X" : "Reddit";
159
+ throw new ApiHttpError(503, {
160
+ code: "upstream_unavailable",
161
+ message: `${providerLabel} connection is not configured. An operator must add ${providerLabel} OAuth credentials to OPENGENI_SOCIAL_OAUTH_CLIENTS_JSON.`,
162
+ retryable: false,
163
+ details: { oauthReason: "operator_oauth_app_missing", provider },
159
164
  });
160
165
  }
161
166
  return configured;
@@ -50,6 +50,7 @@ export function projectClientModel(model: ConfiguredModel): ClientModel {
50
50
  return ClientModel.parse({
51
51
  id: model.id,
52
52
  label: model.label,
53
+ ...(model.shortLabel ? { shortLabel: model.shortLabel } : {}),
53
54
  ...publicProvider,
54
55
  source,
55
56
  api: model.api,
@@ -517,7 +517,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
517
517
  }
518
518
  const payload = parsed.data;
519
519
  const result = await startMcpOAuth(
520
- { db, settings, observability },
520
+ { db, settings, observability, oauthStartDeadlineMs: deps.oauthStartDeadlineMs },
521
521
  {
522
522
  accountId: grant.accountId,
523
523
  workspaceId,
@@ -532,7 +532,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
532
532
  app.get("/v1/integrations/oauth/callback", async (c) => {
533
533
  assertIntegrationsEnabled();
534
534
  const result = await completeMcpOAuthCallback(
535
- { db, settings, observability },
535
+ { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
536
536
  {
537
537
  code: c.req.query("code"),
538
538
  state: c.req.query("state"),
@@ -4,6 +4,8 @@ import {
4
4
  WORKSPACE_STATE_TOPIC_MAX_CHARS,
5
5
  WorkspaceStateQuery,
6
6
  WorkspaceStateResponse,
7
+ type AccessGrant,
8
+ type WorkspaceStateQuery as WorkspaceStateQueryType,
7
9
  } from "@opengeni/contracts";
8
10
  import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
9
11
  import {
@@ -17,90 +19,113 @@ import { getDocumentInventory } from "@opengeni/documents";
17
19
  import type { Hono } from "hono";
18
20
  import { HTTPException } from "hono/http-exception";
19
21
 
22
+ import { serializeWorkspaceStateExport } from "../workspace-state-export";
20
23
  import { projectWorkspaceState } from "../workspace-state-projection";
21
24
 
25
+ async function readWorkspaceState(
26
+ deps: ApiRouteDeps,
27
+ input: { workspaceId: string; query: WorkspaceStateQueryType; grant: AccessGrant },
28
+ ) {
29
+ const { workspaceId, query, grant } = input;
30
+ const generatedAt = new Date().toISOString();
31
+ const canInspectKnowledge = hasPermission(grant.permissions, "documents:search");
32
+
33
+ const [workspace, policies, knowledge, currentPreferences, acceptedAttempt] = await Promise.all([
34
+ getWorkspace(deps.db, workspaceId),
35
+ listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, { limit: 1 }),
36
+ canInspectKnowledge
37
+ ? (async () => {
38
+ const [documents, memories] = await Promise.all([
39
+ getDocumentInventory(deps.db, workspaceId, {
40
+ baseLimit: WORKSPACE_STATE_MAX_BASES,
41
+ topicLimit: WORKSPACE_STATE_MAX_TOPICS,
42
+ topicMaxChars: WORKSPACE_STATE_TOPIC_MAX_CHARS,
43
+ access: { viewerSubjectId: grant.subjectId },
44
+ }),
45
+ listWorkspaceStateMemoryRecords(deps.db, workspaceId),
46
+ ]);
47
+ return { documents, memories };
48
+ })()
49
+ : Promise.resolve(null),
50
+ getCurrentPreferenceRegistryGovernanceMetadata(deps.db, {
51
+ workspaceId,
52
+ subjectId: grant.subjectId,
53
+ }),
54
+ query.attemptId
55
+ ? getWorkspaceStateAcceptedAttemptGovernance(deps.db, {
56
+ accountId: grant.accountId,
57
+ workspaceId,
58
+ subjectId: grant.subjectId,
59
+ attemptId: query.attemptId,
60
+ })
61
+ : Promise.resolve(null),
62
+ ]);
63
+
64
+ if (!workspace) throw new HTTPException(404, { message: "workspace not found" });
65
+ const attemptGovernance = query.attemptId
66
+ ? acceptedAttempt
67
+ ? {
68
+ status: "available" as const,
69
+ attemptId: acceptedAttempt.attemptId,
70
+ executionGeneration: acceptedAttempt.executionGeneration,
71
+ acceptedAt: acceptedAttempt.acceptedAt,
72
+ policySnapshot: acceptedAttempt.policySnapshot,
73
+ preferenceSnapshot: acceptedAttempt.preferenceSnapshot
74
+ ? {
75
+ id: acceptedAttempt.preferenceSnapshot.id,
76
+ descriptorHash: acceptedAttempt.preferenceSnapshot.descriptorHash,
77
+ descriptors: acceptedAttempt.preferenceSnapshot.descriptors.map((descriptor) => ({
78
+ id: descriptor.id,
79
+ revisionId: descriptor.revisionId,
80
+ contentHash: descriptor.contentHash,
81
+ activeVersion: descriptor.activeVersion,
82
+ scope: descriptor.scope,
83
+ })),
84
+ truncated: acceptedAttempt.preferenceSnapshot.truncated,
85
+ createdAt: acceptedAttempt.preferenceSnapshot.createdAt,
86
+ }
87
+ : null,
88
+ currentPreferences,
89
+ }
90
+ : { status: "unavailable" as const }
91
+ : null;
92
+
93
+ return WorkspaceStateResponse.parse(
94
+ projectWorkspaceState({
95
+ workspaceId,
96
+ generatedAt,
97
+ workspaceAgentInstructions: workspace.agentInstructions,
98
+ policies,
99
+ preferences: currentPreferences,
100
+ knowledge,
101
+ attemptGovernance,
102
+ }),
103
+ );
104
+ }
105
+
22
106
  export function registerWorkspaceStateRoutes(app: Hono, deps: ApiRouteDeps): void {
23
- app.get("/v1/workspaces/:workspaceId/workspace-state", async (context) => {
107
+ const base = "/v1/workspaces/:workspaceId/workspace-state";
108
+
109
+ app.get(base, async (context) => {
24
110
  const workspaceId = context.req.param("workspaceId");
25
111
  const query = WorkspaceStateQuery.parse(context.req.query());
26
112
  const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
27
- const generatedAt = new Date().toISOString();
28
- const canInspectKnowledge = hasPermission(grant.permissions, "documents:search");
29
-
30
- const [workspace, policies, knowledge, attemptGovernance] = await Promise.all([
31
- getWorkspace(deps.db, workspaceId),
32
- listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, { limit: 1 }),
33
- canInspectKnowledge
34
- ? (async () => {
35
- const [documents, memories] = await Promise.all([
36
- getDocumentInventory(deps.db, workspaceId, {
37
- baseLimit: WORKSPACE_STATE_MAX_BASES,
38
- topicLimit: WORKSPACE_STATE_MAX_TOPICS,
39
- topicMaxChars: WORKSPACE_STATE_TOPIC_MAX_CHARS,
40
- access: { viewerSubjectId: grant.subjectId },
41
- }),
42
- listWorkspaceStateMemoryRecords(deps.db, workspaceId),
43
- ]);
44
- return { documents, memories };
45
- })()
46
- : Promise.resolve(null),
47
- query.attemptId
48
- ? getWorkspaceStateAcceptedAttemptGovernance(deps.db, {
49
- accountId: grant.accountId,
50
- workspaceId,
51
- subjectId: grant.subjectId,
52
- attemptId: query.attemptId,
53
- }).then(async (snapshot) => {
54
- if (!snapshot) return { status: "unavailable" as const };
55
- const currentPreferences = await getCurrentPreferenceRegistryGovernanceMetadata(
56
- deps.db,
57
- {
58
- workspaceId,
59
- subjectId: grant.subjectId,
60
- },
61
- );
62
- return {
63
- status: "available" as const,
64
- attemptId: snapshot.attemptId,
65
- executionGeneration: snapshot.executionGeneration,
66
- acceptedAt: snapshot.acceptedAt,
67
- policySnapshot: snapshot.policySnapshot,
68
- preferenceSnapshot: snapshot.preferenceSnapshot
69
- ? {
70
- id: snapshot.preferenceSnapshot.id,
71
- descriptorHash: snapshot.preferenceSnapshot.descriptorHash,
72
- descriptors: snapshot.preferenceSnapshot.descriptors.map((descriptor) => ({
73
- id: descriptor.id,
74
- revisionId: descriptor.revisionId,
75
- contentHash: descriptor.contentHash,
76
- activeVersion: descriptor.activeVersion,
77
- scope: descriptor.scope,
78
- })),
79
- truncated: snapshot.preferenceSnapshot.truncated,
80
- createdAt: snapshot.preferenceSnapshot.createdAt,
81
- }
82
- : null,
83
- currentPreferences,
84
- };
85
- })
86
- : Promise.resolve(null),
87
- ]);
113
+ const state = await readWorkspaceState(deps, { workspaceId, query, grant });
114
+ context.header("cache-control", "private, no-store");
115
+ return context.json(state);
116
+ });
88
117
 
89
- if (!workspace) {
90
- throw new HTTPException(404, { message: "workspace not found" });
91
- }
118
+ app.get(`${base}/export`, async (context) => {
119
+ const workspaceId = context.req.param("workspaceId");
120
+ const query = WorkspaceStateQuery.parse(context.req.query());
121
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
122
+ const state = await readWorkspaceState(deps, { workspaceId, query, grant });
92
123
  context.header("cache-control", "private, no-store");
93
- return context.json(
94
- WorkspaceStateResponse.parse(
95
- projectWorkspaceState({
96
- workspaceId,
97
- generatedAt,
98
- workspaceAgentInstructions: workspace.agentInstructions,
99
- policies,
100
- knowledge,
101
- attemptGovernance,
102
- }),
103
- ),
124
+ context.header("content-type", "application/json; charset=utf-8");
125
+ context.header(
126
+ "content-disposition",
127
+ `attachment; filename="workspace-state-${workspaceId}-sanitized.json"`,
104
128
  );
129
+ return context.body(serializeWorkspaceStateExport(state));
105
130
  });
106
131
  }
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ WORKSPACE_STATE_EXPORT_SCHEMA_VERSION,
4
+ WorkspaceStateExportResponse,
5
+ type WorkspaceStateExportResponse as WorkspaceStateExportResponseType,
6
+ type WorkspaceStateResponse,
7
+ } from "@opengeni/contracts";
8
+
9
+ const WORKSPACE_STATE_EXPORT_OMISSIONS = [
10
+ "hidden_platform_prompts",
11
+ "policy_bodies",
12
+ "preference_content",
13
+ "document_content_and_private_metadata",
14
+ "memory_content_and_provenance",
15
+ "secret_values_and_credentials",
16
+ "session_messages_and_tool_outputs",
17
+ ] as const;
18
+
19
+ function canonicalize(value: unknown): unknown {
20
+ if (Array.isArray(value)) return value.map(canonicalize);
21
+ if (value === null || typeof value !== "object") return value;
22
+ return Object.fromEntries(
23
+ Object.entries(value as Record<string, unknown>)
24
+ .sort(([left], [right]) => left.localeCompare(right))
25
+ .map(([key, nested]) => [key, canonicalize(nested)]),
26
+ );
27
+ }
28
+
29
+ export function canonicalWorkspaceStateJson(value: unknown): string {
30
+ return `${JSON.stringify(canonicalize(value), null, 2)}\n`;
31
+ }
32
+
33
+ export function createWorkspaceStateExport(
34
+ state: WorkspaceStateResponse,
35
+ ): WorkspaceStateExportResponseType {
36
+ const canonicalState = canonicalWorkspaceStateJson(state);
37
+ return WorkspaceStateExportResponse.parse({
38
+ kind: "opengeni.workspace_state.sanitized_export",
39
+ schemaVersion: WORKSPACE_STATE_EXPORT_SCHEMA_VERSION,
40
+ generatedAt: state.generatedAt,
41
+ stateSha256: createHash("sha256").update(canonicalState, "utf8").digest("hex"),
42
+ omissions: WORKSPACE_STATE_EXPORT_OMISSIONS,
43
+ state,
44
+ });
45
+ }
46
+
47
+ export function serializeWorkspaceStateExport(state: WorkspaceStateResponse): string {
48
+ return canonicalWorkspaceStateJson(createWorkspaceStateExport(state));
49
+ }
@@ -34,6 +34,11 @@ type PreferenceGovernanceIdentity = {
34
34
  scope: "organization" | "workspace" | "user";
35
35
  };
36
36
 
37
+ type CurrentPreferenceProjectionInput = {
38
+ descriptors: PreferenceGovernanceIdentity[];
39
+ truncated: boolean;
40
+ };
41
+
37
42
  type AttemptGovernanceProjectionInput =
38
43
  | { status: "unavailable" }
39
44
  | {
@@ -60,6 +65,7 @@ export type WorkspaceStateProjectionInput = {
60
65
  generatedAt: string;
61
66
  workspaceAgentInstructions: string | null;
62
67
  policies: WorkspaceInstructionPolicyListResponse;
68
+ preferences: CurrentPreferenceProjectionInput;
63
69
  knowledge: KnowledgeProjectionInput | null;
64
70
  attemptGovernance?: AttemptGovernanceProjectionInput | null;
65
71
  };
@@ -308,6 +314,21 @@ function policyProjection(input: WorkspaceStateProjectionInput) {
308
314
  };
309
315
  }
310
316
 
317
+ function preferenceProjection(input: CurrentPreferenceProjectionInput) {
318
+ const descriptors = [...input.descriptors].sort((left, right) =>
319
+ preferenceIdentity(left).localeCompare(preferenceIdentity(right)),
320
+ );
321
+ const scopeCounts = { organization: 0, workspace: 0, user: 0 };
322
+ for (const descriptor of descriptors) scopeCounts[descriptor.scope] += 1;
323
+ return {
324
+ authority: "preference_registry_preferences" as const,
325
+ activeDescriptorCount: descriptors.length,
326
+ activeDescriptorHash: hashIdentities(descriptors.map(preferenceIdentity)),
327
+ scopeCounts,
328
+ truncated: input.truncated,
329
+ };
330
+ }
331
+
311
332
  function availableKnowledgeProjection(knowledge: KnowledgeProjectionInput) {
312
333
  const inventory = knowledge.documents;
313
334
  const selectedBases = inventory.bases.slice(0, WORKSPACE_STATE_MAX_BASES);
@@ -402,6 +423,7 @@ function availableKnowledgeProjection(knowledge: KnowledgeProjectionInput) {
402
423
  inspectedVisibleDocumentCount,
403
424
  documentStatusCounts: aggregateStatuses,
404
425
  sourceKindCounts: aggregateSources,
426
+ authorityKindCounts: { ...inventory.authorityKindCounts },
405
427
  topics,
406
428
  topicsTruncated: inventory.topicsTruncated || sortedTopics.length > topics.length,
407
429
  latestDocumentUpdatedAt: inventory.latestUpdatedAt,
@@ -432,6 +454,7 @@ export function projectWorkspaceState(
432
454
  attemptGovernance: attemptGovernanceProjection(input),
433
455
  },
434
456
  policy: policyProjection(input),
457
+ preferences: preferenceProjection(input.preferences),
435
458
  knowledge: input.knowledge
436
459
  ? availableKnowledgeProjection(input.knowledge)
437
460
  : {