@opengeni/api-router 1.3.0 → 1.4.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 (42) hide show
  1. package/dist/access-grant-rls.d.ts +7 -0
  2. package/dist/app.d.ts +2 -2
  3. package/dist/app.js +1 -1
  4. package/dist/azure-live.d.ts +28 -0
  5. package/dist/{chunk-IZEGLLKZ.js → chunk-F3LQ6VWR.js} +1334 -486
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/http/api-error.d.ts +14 -0
  9. package/dist/index.js +1 -1
  10. package/dist/integrations/oauth-profiles.d.ts +3 -1
  11. package/dist/routes/sessions.d.ts +2 -0
  12. package/dist/transcription/billing-refusal.d.ts +8 -0
  13. package/dist/transcription/normalize.d.ts +23 -0
  14. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  15. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  16. package/dist/transcription/providers/openai.d.ts +16 -0
  17. package/dist/transcription/service.d.ts +10 -1
  18. package/dist/transcription/wav-duration.d.ts +8 -0
  19. package/package.json +20 -20
  20. package/src/access-grant-rls.ts +18 -10
  21. package/src/app.ts +62 -8
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/gateway-realtime.ts +11 -7
  25. package/src/http/api-error.ts +36 -1
  26. package/src/integrations/oauth-client.ts +9 -17
  27. package/src/integrations/oauth-profiles.ts +17 -6
  28. package/src/routes/editable-artifacts.ts +8 -0
  29. package/src/routes/session-artifact-associations.ts +80 -6
  30. package/src/routes/sessions.ts +263 -66
  31. package/src/routes/transcription-recordings.ts +71 -4
  32. package/src/routes/transcriptions.ts +26 -2
  33. package/src/routes/workspaces.ts +49 -7
  34. package/src/sandbox/channel-a.ts +22 -0
  35. package/src/transcription/billing-refusal.ts +14 -0
  36. package/src/transcription/normalize.ts +77 -0
  37. package/src/transcription/providers/azure-mai.ts +98 -0
  38. package/src/transcription/providers/azure-openai.ts +16 -7
  39. package/src/transcription/providers/openai.ts +79 -5
  40. package/src/transcription/segmenter.ts +7 -1
  41. package/src/transcription/service.ts +215 -10
  42. package/src/transcription/wav-duration.ts +39 -0
@@ -5,7 +5,11 @@ import {
5
5
  ScheduledTaskTargetAccessChange,
6
6
  type ErrorCode,
7
7
  } from "@opengeni/contracts";
8
- import { isDatabaseConnectionLoss, WorkspaceControlBusyError } from "@opengeni/db";
8
+ import {
9
+ isDatabaseConnectionLoss,
10
+ nestedPostgresSqlState,
11
+ WorkspaceControlBusyError,
12
+ } from "@opengeni/db";
9
13
  import type { ContentfulStatusCode } from "hono/utils/http-status";
10
14
  import { HTTPException } from "hono/http-exception";
11
15
 
@@ -116,6 +120,37 @@ export function databaseUnavailableHttpError(error: unknown, method: string): Ap
116
120
  });
117
121
  }
118
122
 
123
+ /** `details.code` of the retryable 503 for a deadlock/serialization victim. */
124
+ export const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
125
+
126
+ /**
127
+ * PostgreSQL aborted the request's transaction as a deadlock (40P01) or
128
+ * serialization (40001) victim after any in-process retry was exhausted. The
129
+ * aborted transaction committed nothing, so this is transient contention, not
130
+ * an internal error. A read is safe to repeat. For a mutation the default is
131
+ * `outcomeUnknown` because an earlier transaction of the same request may have
132
+ * committed; a route whose only write is the aborted idempotent transaction
133
+ * passes `outcomeUnknown: false`.
134
+ */
135
+ export function databaseContentionHttpError(
136
+ error: unknown,
137
+ method: string,
138
+ options: { outcomeUnknown?: boolean } = {},
139
+ ): ApiHttpError | null {
140
+ if (error instanceof ApiHttpError) return null;
141
+ if (error instanceof HTTPException && error.status < 500) return null;
142
+ const sqlState = nestedPostgresSqlState(error);
143
+ if (sqlState !== "40P01" && sqlState !== "40001") return null;
144
+ const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS";
145
+ return new ApiHttpError(503, {
146
+ code: "upstream_unavailable",
147
+ message: "Opengeni hit transient database contention. Retry shortly.",
148
+ retryable: true,
149
+ outcomeUnknown: options.outcomeUnknown ?? !safeMethod,
150
+ details: { code: DATABASE_CONTENTION_ERROR_DETAIL_CODE, sqlState },
151
+ });
152
+ }
153
+
119
154
  /**
120
155
  * A typed agent-configuration failure (capability unavailable, conflict,
121
156
  * widening, not enabled). Rendered as 422 validation_failed with the specific
@@ -7,7 +7,11 @@ import {
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
9
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
10
- import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
10
+ import {
11
+ findIntegrationsOauthClient,
12
+ parseIntegrationsOauthClientsJson,
13
+ type Settings,
14
+ } from "@opengeni/config";
11
15
  import {
12
16
  OAuthStartResponse,
13
17
  selectCanonicalPersonalSlackConnection,
@@ -1799,7 +1803,6 @@ function operatorClientEntryFor(
1799
1803
  settings: Settings,
1800
1804
  candidates: string[],
1801
1805
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
1802
- const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
1803
1806
  const candidateOrigins = candidates.flatMap((candidate) => {
1804
1807
  try {
1805
1808
  return [new URL(candidate).origin];
@@ -1816,22 +1819,10 @@ function operatorClientEntryFor(
1816
1819
  return resolved;
1817
1820
  }
1818
1821
  }
1819
- const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
1820
- const exactKeys = uniqueStrings(
1821
- candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
1822
+ return findIntegrationsOauthClient(
1823
+ parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson),
1824
+ candidates,
1822
1825
  );
1823
- for (const key of exactKeys) {
1824
- const entry = configured[key];
1825
- if (entry) {
1826
- return entry;
1827
- }
1828
- }
1829
- for (const [key, entry] of Object.entries(configured)) {
1830
- if (normalizedCandidates.has(normalizedIssuerKey(key))) {
1831
- return entry;
1832
- }
1833
- }
1834
- return null;
1835
1826
  }
1836
1827
 
1837
1828
  /** Secret-free readiness for the exact registered client used by Gmail setup. */
@@ -2708,6 +2699,7 @@ async function verifyMcpToolsListNonFatal(
2708
2699
  providerIdentity = local.validateIdentity(payload);
2709
2700
  const verifiedTools = local.toolsForScopes(
2710
2701
  grantedScopes(token.scopeText, state.authorizeScopes, profile),
2702
+ settings,
2711
2703
  );
2712
2704
  if (local.required && verifiedTools.length === 0)
2713
2705
  throw new Error("Connector verification did not report an authorized tool scope");
@@ -7,7 +7,11 @@ import { OPENGENI_PERSONAL_SLACK_MCP_URL, type ConnectionOwnership } from "@open
7
7
  import type { Settings } from "@opengeni/config";
8
8
  import { HTTPException } from "hono/http-exception";
9
9
  import { z } from "zod";
10
- import { GMAIL_REST_MCP_TOOLS, gmailToolSupportsScopes } from "@opengeni/runtime/gmail-rest-mcp";
10
+ import {
11
+ GMAIL_REST_MCP_TOOLS,
12
+ gmailToolAvailableOnDeployment,
13
+ gmailToolSupportsScopes,
14
+ } from "@opengeni/runtime/gmail-rest-mcp";
11
15
  import { canonicalProviderDomain } from "./provider-domain";
12
16
 
13
17
  /**
@@ -132,7 +136,10 @@ export type OAuthProviderProfile = {
132
136
  /** Distinguish a rejected OAuth grant from a temporary verification failure. */
133
137
  isRejectedGrant?: (payload: Record<string, unknown>) => boolean;
134
138
  validateIdentity: (payload: Record<string, unknown>) => Record<string, string>;
135
- toolsForScopes: (scopes: readonly string[]) => Array<{ name: string; description?: string }>;
139
+ toolsForScopes: (
140
+ scopes: readonly string[],
141
+ deployment: { gmailWatchTopicName?: string | undefined },
142
+ ) => Array<{ name: string; description?: string }>;
136
143
  };
137
144
  };
138
145
 
@@ -265,10 +272,14 @@ const OFFICIAL_GMAIL_PROFILE: OAuthProviderProfile = {
265
272
  }
266
273
  return { gmailEmail: payload.emailAddress };
267
274
  },
268
- toolsForScopes: (scopes) => {
269
- return GMAIL_REST_MCP_TOOLS.filter((tool) => gmailToolSupportsScopes(tool.name, scopes)).map(
270
- ({ name, description }) => ({ name, ...(description ? { description } : {}) }),
271
- );
275
+ toolsForScopes: (scopes, deployment) => {
276
+ return GMAIL_REST_MCP_TOOLS.filter(
277
+ (tool) =>
278
+ gmailToolSupportsScopes(tool.name, scopes) &&
279
+ gmailToolAvailableOnDeployment(tool.name, {
280
+ watchTopicName: deployment.gmailWatchTopicName,
281
+ }),
282
+ ).map(({ name, description }) => ({ name, ...(description ? { description } : {}) }));
272
283
  },
273
284
  },
274
285
  };
@@ -609,6 +609,14 @@ export function registerEditableArtifactRoutes(
609
609
  const body = await parseBoundedMaterializationRequest(context);
610
610
  const scope = editableArtifactScope({ accountId: grant.accountId, workspaceId });
611
611
  const actor = editableArtifactActorForGrant(grant, body.replicaId);
612
+ // Without a materializer workload the job would stay queued forever.
613
+ if (!deps.settings.artifactMaterializerDeployed) {
614
+ throw new ApiHttpError(503, {
615
+ code: "upstream_unavailable",
616
+ message: "File export is not available on this deployment.",
617
+ retryable: false,
618
+ });
619
+ }
612
620
  try {
613
621
  const result = await requireEditableArtifactExports(deps).enqueueMaterialization({
614
622
  scope,
@@ -1,5 +1,6 @@
1
1
  import {
2
- requireAccessGrant,
2
+ fileOwnerContextForAccess,
3
+ requireAccessGrantAuthorization,
3
4
  requirePermission,
4
5
  requireSessionAuthorization,
5
6
  SessionAuthorizationDeniedError,
@@ -7,10 +8,15 @@ import {
7
8
  type ApiRouteDeps,
8
9
  } from "@opengeni/core";
9
10
  import {
11
+ getGeneratedImageArtifact,
12
+ getGeneratedVideoArtifact,
13
+ getRetainedFileArtifact,
10
14
  hasEditableArtifactSessionLink,
11
15
  hasWorkspaceArtifactSessionLink,
16
+ listArtifactCatalogCandidates,
12
17
  transactionallyAuthorizeEditableArtifactActor,
13
18
  withRlsContext,
19
+ withSessionRlsActorContext,
14
20
  } from "@opengeni/db";
15
21
  import type { Hono } from "hono";
16
22
  import { HTTPException } from "hono/http-exception";
@@ -27,16 +33,26 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
27
33
  "/v1/workspaces/:workspaceId/sessions/:sessionId/artifact-associations/:artifactId",
28
34
  async (context) => {
29
35
  const workspaceId = context.req.param("workspaceId");
30
- const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
31
- requirePermission(grant, "sessions:read");
32
36
  const sessionId = context.req.param("sessionId");
33
37
  const artifactId = context.req.param("artifactId");
38
+ const requestedKind = context.req.query("kind");
39
+ // A UUID names a Site unless the caller asks for a retained artifact
40
+ // (generated image or video, or a published sandbox file) explicitly.
34
41
  const kind = EditableArtifactId.safeParse(artifactId).success
35
42
  ? "editable"
36
43
  : SessionId.safeParse(artifactId).success
37
- ? "site"
44
+ ? requestedKind === "retained"
45
+ ? "retained"
46
+ : "site"
38
47
  : null;
39
- const requestedKind = context.req.query("kind");
48
+ const authorization = await requireAccessGrantAuthorization(
49
+ context,
50
+ deps,
51
+ workspaceId,
52
+ kind === "retained" ? "files:read" : "artifacts:read",
53
+ );
54
+ const grant = authorization.grant;
55
+ requirePermission(grant, "sessions:read");
40
56
  if (
41
57
  !SessionId.safeParse(sessionId).success ||
42
58
  !kind ||
@@ -55,7 +71,20 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
55
71
  const linked =
56
72
  kind === "editable"
57
73
  ? await hasEditableArtifactSessionLink(deps.db, scope, sessionId, artifactId)
58
- : await hasWorkspaceArtifactSessionLink(deps.db, workspaceId, sessionId, artifactId);
74
+ : kind === "retained"
75
+ ? await hasRetainedArtifactSessionLink(
76
+ deps,
77
+ authorization,
78
+ workspaceId,
79
+ sessionId,
80
+ artifactId.toLowerCase(),
81
+ )
82
+ : await hasWorkspaceArtifactSessionLink(
83
+ deps.db,
84
+ workspaceId,
85
+ sessionId,
86
+ artifactId,
87
+ );
59
88
  if (!linked) throw new HTTPException(404, { message: "Artifact association not found" });
60
89
  if (kind === "editable") {
61
90
  const decision = await withRlsContext(deps.db, scope, (tx) =>
@@ -85,3 +114,48 @@ export function registerSessionArtifactAssociationRoutes(app: Hono, deps: ApiRou
85
114
  },
86
115
  );
87
116
  }
117
+
118
+ /**
119
+ * Whether a retained artifact originated in this exact session: a generated
120
+ * image or video the session produced, or a sandbox file it published. Never
121
+ * inferred from an artifact id appearing in a message.
122
+ */
123
+ async function hasRetainedArtifactSessionLink(
124
+ deps: ApiRouteDeps,
125
+ authorization: Awaited<ReturnType<typeof requireAccessGrantAuthorization>>,
126
+ workspaceId: string,
127
+ sessionId: string,
128
+ artifactId: string,
129
+ ): Promise<boolean> {
130
+ const image = await getGeneratedImageArtifact(deps.db, workspaceId, artifactId);
131
+ if (image) return image.sessionId === sessionId;
132
+ const video = await getGeneratedVideoArtifact(deps.db, workspaceId, artifactId);
133
+ if (video) return !video.artifact.deletedAt && video.artifact.sessionId === sessionId;
134
+ const file = await getRetainedFileArtifact(deps.db, workspaceId, artifactId);
135
+ if (!file) return false;
136
+ // Publication receipts are private; the bounded catalog seam filters them
137
+ // by source session and this file's name, then the exact id must match.
138
+ const grant = authorization.grant;
139
+ const actor = await fileOwnerContextForAccess(deps, authorization, "files:read");
140
+ const candidates = await withSessionRlsActorContext(actor, () =>
141
+ listArtifactCatalogCandidates(
142
+ deps.db,
143
+ { accountId: grant.accountId, workspaceId },
144
+ {
145
+ query: {
146
+ sourceSessionId: sessionId,
147
+ q: file.file.filename.slice(0, 200),
148
+ sort: "newest",
149
+ status: "active",
150
+ limit: 100,
151
+ },
152
+ kinds: ["file", "image"],
153
+ snapshotAt: new Date().toISOString(),
154
+ limit: 201,
155
+ },
156
+ ),
157
+ );
158
+ return candidates.some(
159
+ (candidate) => candidate.origin === "sandbox_file" && candidate.id === artifactId,
160
+ );
161
+ }