@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.
- package/dist/access-grant-rls.d.ts +7 -0
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/azure-live.d.ts +28 -0
- package/dist/{chunk-IZEGLLKZ.js → chunk-F3LQ6VWR.js} +1334 -486
- package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
- package/dist/codemode.d.ts +3 -3
- package/dist/http/api-error.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-profiles.d.ts +3 -1
- package/dist/routes/sessions.d.ts +2 -0
- package/dist/transcription/billing-refusal.d.ts +8 -0
- package/dist/transcription/normalize.d.ts +23 -0
- package/dist/transcription/providers/azure-mai.d.ts +12 -0
- package/dist/transcription/providers/azure-openai.d.ts +4 -0
- package/dist/transcription/providers/openai.d.ts +16 -0
- package/dist/transcription/service.d.ts +10 -1
- package/dist/transcription/wav-duration.d.ts +8 -0
- package/package.json +20 -20
- package/src/access-grant-rls.ts +18 -10
- package/src/app.ts +62 -8
- package/src/azure-live.ts +103 -0
- package/src/codemode.ts +3 -5
- package/src/gateway-realtime.ts +11 -7
- package/src/http/api-error.ts +36 -1
- package/src/integrations/oauth-client.ts +9 -17
- package/src/integrations/oauth-profiles.ts +17 -6
- package/src/routes/editable-artifacts.ts +8 -0
- package/src/routes/session-artifact-associations.ts +80 -6
- package/src/routes/sessions.ts +263 -66
- package/src/routes/transcription-recordings.ts +71 -4
- package/src/routes/transcriptions.ts +26 -2
- package/src/routes/workspaces.ts +49 -7
- package/src/sandbox/channel-a.ts +22 -0
- package/src/transcription/billing-refusal.ts +14 -0
- package/src/transcription/normalize.ts +77 -0
- package/src/transcription/providers/azure-mai.ts +98 -0
- package/src/transcription/providers/azure-openai.ts +16 -7
- package/src/transcription/providers/openai.ts +79 -5
- package/src/transcription/segmenter.ts +7 -1
- package/src/transcription/service.ts +215 -10
- package/src/transcription/wav-duration.ts +39 -0
package/src/http/api-error.ts
CHANGED
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
ScheduledTaskTargetAccessChange,
|
|
6
6
|
type ErrorCode,
|
|
7
7
|
} from "@opengeni/contracts";
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
1820
|
-
|
|
1821
|
-
candidates
|
|
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 {
|
|
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: (
|
|
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(
|
|
270
|
-
(
|
|
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
|
-
|
|
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
|
-
? "
|
|
44
|
+
? requestedKind === "retained"
|
|
45
|
+
? "retained"
|
|
46
|
+
: "site"
|
|
38
47
|
: null;
|
|
39
|
-
const
|
|
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
|
-
:
|
|
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
|
+
}
|