@opengeni/api-router 0.30.1 → 2.1.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.
- package/dist/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-2PQMSRCB.js → chunk-QKDFBBUE.js} +29110 -18137
- package/dist/chunk-QKDFBBUE.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-office-import.d.ts +7 -4
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +364 -86
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +19 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +149 -9
- package/dist/integrations/slack-interactions.d.ts +10 -4
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +17 -3
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/files.d.ts +4 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +160 -27
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +77 -9
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +416 -101
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +337 -57
- package/src/integrations/slack-interactions.ts +1274 -214
- package/src/integrations/slack-routing.ts +324 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +22 -2
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +388 -98
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +145 -34
- package/src/routes/documents.ts +221 -3
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +152 -27
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +777 -40
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-2PQMSRCB.js.map +0 -1
package/src/http/api-error.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ErrorCode } from "@opengeni/contracts";
|
|
2
|
+
import { WorkspaceControlBusyError } from "@opengeni/db";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
4
|
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ type ApiHttpErrorOptions = {
|
|
|
6
7
|
code: ErrorCode;
|
|
7
8
|
message: string;
|
|
8
9
|
retryable?: boolean;
|
|
10
|
+
outcomeUnknown?: boolean;
|
|
9
11
|
details?: Record<string, unknown>;
|
|
10
12
|
};
|
|
11
13
|
|
|
@@ -13,6 +15,7 @@ type ApiHttpErrorOptions = {
|
|
|
13
15
|
export class ApiHttpError extends HTTPException {
|
|
14
16
|
readonly code: ErrorCode;
|
|
15
17
|
readonly retryable: boolean | undefined;
|
|
18
|
+
readonly outcomeUnknown: boolean | undefined;
|
|
16
19
|
readonly details: Record<string, unknown> | undefined;
|
|
17
20
|
|
|
18
21
|
constructor(status: number, options: ApiHttpErrorOptions) {
|
|
@@ -20,6 +23,31 @@ export class ApiHttpError extends HTTPException {
|
|
|
20
23
|
this.name = "ApiHttpError";
|
|
21
24
|
this.code = options.code;
|
|
22
25
|
this.retryable = options.retryable;
|
|
26
|
+
this.outcomeUnknown = options.outcomeUnknown;
|
|
23
27
|
this.details = options.details;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A request-scoped session/workspace mutation could not enter the workspace
|
|
33
|
+
* control prefix within its bounded wait. The transaction rolled back before
|
|
34
|
+
* any write, so the outcome is known and the client may retry. `app.onError`
|
|
35
|
+
* applies this once for every route; Slack interactions keep the raw typed
|
|
36
|
+
* error so their retry classifier treats it as transient.
|
|
37
|
+
*/
|
|
38
|
+
export function workspaceControlBusyHttpError(error: unknown): ApiHttpError | null {
|
|
39
|
+
const busy =
|
|
40
|
+
error instanceof WorkspaceControlBusyError
|
|
41
|
+
? error
|
|
42
|
+
: error instanceof HTTPException && error.cause instanceof WorkspaceControlBusyError
|
|
43
|
+
? error.cause
|
|
44
|
+
: null;
|
|
45
|
+
if (!busy) return null;
|
|
46
|
+
return new ApiHttpError(503, {
|
|
47
|
+
code: "upstream_unavailable",
|
|
48
|
+
message: "The workspace is busy applying other session commands; retry shortly.",
|
|
49
|
+
retryable: true,
|
|
50
|
+
outcomeUnknown: false,
|
|
51
|
+
details: { code: busy.code, lockTimeoutMs: busy.lockTimeoutMs },
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -40,6 +40,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
40
40
|
if (path === "/v1/webhooks/stripe") {
|
|
41
41
|
return true;
|
|
42
42
|
}
|
|
43
|
+
if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
43
46
|
if (
|
|
44
47
|
path === "/v1/github/setup" ||
|
|
45
48
|
path === "/v1/github/install/callback" ||
|
|
@@ -51,6 +54,7 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
51
54
|
if (
|
|
52
55
|
path === "/v1/integrations/oauth/callback" ||
|
|
53
56
|
path === "/v1/integrations/provider-oauth/callback" ||
|
|
57
|
+
path === "/v1/integrations/github-personal/oauth/callback" ||
|
|
54
58
|
path === "/v1/integrations/google-drive/callback" ||
|
|
55
59
|
path === "/v1/integrations/oauth/client-metadata.json" ||
|
|
56
60
|
path === "/v1/integrations/slack/callback" ||
|
package/src/http/cors.ts
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
|
|
1
3
|
export function allowedCorsOrigin(pattern: string, origin: string): boolean {
|
|
2
4
|
return new RegExp(`^(?:${pattern})$`).test(origin);
|
|
3
5
|
}
|
|
6
|
+
|
|
7
|
+
export function validateInteractionRequestOrigin(
|
|
8
|
+
value: string | undefined,
|
|
9
|
+
input: {
|
|
10
|
+
corsAllowOriginRegex: string;
|
|
11
|
+
publicBaseUrl?: string | undefined;
|
|
12
|
+
webBaseUrl?: string | undefined;
|
|
13
|
+
},
|
|
14
|
+
): string | null {
|
|
15
|
+
if (!value) return null;
|
|
16
|
+
let url: URL;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
url.origin === "null" ||
|
|
24
|
+
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
25
|
+
url.origin !== value
|
|
26
|
+
) {
|
|
27
|
+
throw new HTTPException(400, { message: "invalid request origin" });
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
allowedCorsOrigin(input.corsAllowOriginRegex, url.origin) ||
|
|
31
|
+
[input.publicBaseUrl, input.webBaseUrl].some((baseUrl) =>
|
|
32
|
+
baseUrl ? new URL(baseUrl).origin === url.origin : false,
|
|
33
|
+
)
|
|
34
|
+
) {
|
|
35
|
+
return url.origin;
|
|
36
|
+
}
|
|
37
|
+
throw new HTTPException(403, { message: "request origin is not allowed" });
|
|
38
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { ErrorCode as AgentErrorCode } from "@opengeni/agent-proto";
|
|
2
|
+
import {
|
|
3
|
+
InteractionControlFailureDetails,
|
|
4
|
+
type ErrorCode,
|
|
5
|
+
type InteractionControlFailureCode,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
import { SelfhostedControlError } from "@opengeni/runtime/sandbox";
|
|
8
|
+
import { HTTPException } from "hono/http-exception";
|
|
9
|
+
import { ApiHttpError } from "./api-error";
|
|
10
|
+
|
|
11
|
+
export type InteractionControlSurface = "browser" | "computer";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Projects an internal connected-machine control fault into the public API
|
|
15
|
+
* vocabulary. This is deliberately an allowlist: the agent's free-form message
|
|
16
|
+
* and detail map may contain paths, process output, or provider diagnostics and
|
|
17
|
+
* never cross this boundary. The exact inner request id is safe opaque
|
|
18
|
+
* correlation and links API logs back to the machine operation.
|
|
19
|
+
*/
|
|
20
|
+
export function interactionControlApiError(
|
|
21
|
+
error: unknown,
|
|
22
|
+
surface: InteractionControlSurface,
|
|
23
|
+
): ApiHttpError | null {
|
|
24
|
+
const controlError =
|
|
25
|
+
error instanceof SelfhostedControlError
|
|
26
|
+
? error
|
|
27
|
+
: error instanceof HTTPException && error.cause instanceof SelfhostedControlError
|
|
28
|
+
? error.cause
|
|
29
|
+
: null;
|
|
30
|
+
if (!controlError) return null;
|
|
31
|
+
const controlFailureCode = publicControlFailureCode(controlError.code);
|
|
32
|
+
const details = InteractionControlFailureDetails.parse({
|
|
33
|
+
interactionLayer: "connected_machine",
|
|
34
|
+
interactionSurface: surface,
|
|
35
|
+
controlFailureCode,
|
|
36
|
+
...(safeControlRequestId(controlError.controlRequestId)
|
|
37
|
+
? { controlRequestId: controlError.controlRequestId }
|
|
38
|
+
: {}),
|
|
39
|
+
});
|
|
40
|
+
const ruling = publicControlFailureRuling(controlError, surface, controlFailureCode);
|
|
41
|
+
return new ApiHttpError(ruling.status, {
|
|
42
|
+
code: ruling.code,
|
|
43
|
+
message: ruling.message,
|
|
44
|
+
retryable: controlError.retryable,
|
|
45
|
+
outcomeUnknown:
|
|
46
|
+
controlError.code === AgentErrorCode.ERROR_CODE_TIMEOUT && !controlError.neverSent,
|
|
47
|
+
details,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function publicControlFailureCode(code: AgentErrorCode): InteractionControlFailureCode {
|
|
52
|
+
switch (code) {
|
|
53
|
+
case AgentErrorCode.ERROR_CODE_UNSUPPORTED:
|
|
54
|
+
return "unsupported";
|
|
55
|
+
case AgentErrorCode.ERROR_CODE_OS:
|
|
56
|
+
return "os";
|
|
57
|
+
case AgentErrorCode.ERROR_CODE_NOT_FOUND:
|
|
58
|
+
return "not_found";
|
|
59
|
+
case AgentErrorCode.ERROR_CODE_CONSENT_REQUIRED:
|
|
60
|
+
return "consent_required";
|
|
61
|
+
case AgentErrorCode.ERROR_CODE_TIMEOUT:
|
|
62
|
+
return "timeout";
|
|
63
|
+
case AgentErrorCode.ERROR_CODE_DRAINING:
|
|
64
|
+
return "draining";
|
|
65
|
+
case AgentErrorCode.ERROR_CODE_PROTOCOL:
|
|
66
|
+
return "protocol";
|
|
67
|
+
case AgentErrorCode.ERROR_CODE_STREAM:
|
|
68
|
+
return "stream";
|
|
69
|
+
case AgentErrorCode.ERROR_CODE_AGENT_OFFLINE:
|
|
70
|
+
return "agent_offline";
|
|
71
|
+
case AgentErrorCode.ERROR_CODE_FENCED:
|
|
72
|
+
return "fenced";
|
|
73
|
+
case AgentErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE:
|
|
74
|
+
return "payload_too_large";
|
|
75
|
+
default:
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function publicControlFailureRuling(
|
|
81
|
+
error: SelfhostedControlError,
|
|
82
|
+
surface: InteractionControlSurface,
|
|
83
|
+
code: InteractionControlFailureCode,
|
|
84
|
+
): { status: number; code: ErrorCode; message: string } {
|
|
85
|
+
const liveView = surface === "browser" ? "browser live view" : "computer live view";
|
|
86
|
+
switch (code) {
|
|
87
|
+
case "consent_required":
|
|
88
|
+
return {
|
|
89
|
+
status: 403,
|
|
90
|
+
code: "forbidden",
|
|
91
|
+
message: `Screen access is not enabled for this connected machine's ${liveView}.`,
|
|
92
|
+
};
|
|
93
|
+
case "unsupported":
|
|
94
|
+
return {
|
|
95
|
+
status: 409,
|
|
96
|
+
code: "conflict",
|
|
97
|
+
message: `This connected machine does not support the ${liveView}.`,
|
|
98
|
+
};
|
|
99
|
+
case "not_found":
|
|
100
|
+
return {
|
|
101
|
+
status: 404,
|
|
102
|
+
code: "not_found",
|
|
103
|
+
message: `The connected-machine resource for this ${liveView} no longer exists.`,
|
|
104
|
+
};
|
|
105
|
+
case "fenced":
|
|
106
|
+
return {
|
|
107
|
+
status: 409,
|
|
108
|
+
code: "conflict",
|
|
109
|
+
message: `The connected-machine ${liveView} changed while it was opening.`,
|
|
110
|
+
};
|
|
111
|
+
case "agent_offline":
|
|
112
|
+
return {
|
|
113
|
+
status: 503,
|
|
114
|
+
code: "upstream_unavailable",
|
|
115
|
+
message: `The connected machine is offline, so its ${liveView} is unavailable.`,
|
|
116
|
+
};
|
|
117
|
+
case "draining":
|
|
118
|
+
return {
|
|
119
|
+
status: 503,
|
|
120
|
+
code: "upstream_unavailable",
|
|
121
|
+
message: `The connected machine is busy and could not open the ${liveView}.`,
|
|
122
|
+
};
|
|
123
|
+
case "timeout":
|
|
124
|
+
return {
|
|
125
|
+
status: 504,
|
|
126
|
+
code: "upstream_unavailable",
|
|
127
|
+
message: `The connected machine did not finish opening the ${liveView} in time.`,
|
|
128
|
+
};
|
|
129
|
+
case "payload_too_large":
|
|
130
|
+
return {
|
|
131
|
+
status: 502,
|
|
132
|
+
code: "upstream_unavailable",
|
|
133
|
+
message: `The connected machine could not deliver the ${liveView} within its transport limit.`,
|
|
134
|
+
};
|
|
135
|
+
case "stream":
|
|
136
|
+
return {
|
|
137
|
+
status: error.retryable ? 503 : 502,
|
|
138
|
+
code: "upstream_unavailable",
|
|
139
|
+
message: `The connected machine could not open the ${liveView} stream.`,
|
|
140
|
+
};
|
|
141
|
+
case "protocol":
|
|
142
|
+
return {
|
|
143
|
+
status: 502,
|
|
144
|
+
code: "upstream_unavailable",
|
|
145
|
+
message: `The connected machine returned an invalid ${liveView} response.`,
|
|
146
|
+
};
|
|
147
|
+
case "os":
|
|
148
|
+
return {
|
|
149
|
+
status: 502,
|
|
150
|
+
code: "upstream_unavailable",
|
|
151
|
+
message: `The connected machine could not prepare the ${liveView}.`,
|
|
152
|
+
};
|
|
153
|
+
case "unknown":
|
|
154
|
+
return {
|
|
155
|
+
status: 502,
|
|
156
|
+
code: "upstream_unavailable",
|
|
157
|
+
message: `The connected machine could not open the ${liveView}.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeControlRequestId(value: string | null): value is string {
|
|
163
|
+
return value !== null && value.length <= 128 && /^[A-Za-z0-9._:-]+$/u.test(value);
|
|
164
|
+
}
|
package/src/http/sse.ts
CHANGED
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
type Database,
|
|
11
11
|
} from "@opengeni/db";
|
|
12
12
|
import {
|
|
13
|
+
coalesceSessionEventDeltas,
|
|
13
14
|
formatSessionEventSse,
|
|
14
15
|
formatWorkspaceControlEventSse,
|
|
15
16
|
SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
17
|
+
sessionEventResumeSequence,
|
|
16
18
|
type EventBus,
|
|
17
19
|
} from "@opengeni/events";
|
|
18
20
|
import type { Observability } from "@opengeni/observability";
|
|
@@ -268,31 +270,19 @@ export async function sseSessionStream(
|
|
|
268
270
|
options: SessionSseDeliveryOptions = {},
|
|
269
271
|
): Promise<Response> {
|
|
270
272
|
const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
|
|
271
|
-
if (
|
|
272
|
-
options.reauthorize &&
|
|
273
|
-
options.reauthorizeAfterMs !== undefined &&
|
|
274
|
-
(!Number.isSafeInteger(options.reauthorizeAfterMs) ||
|
|
275
|
-
options.reauthorizeAfterMs < 1_000 ||
|
|
276
|
-
options.reauthorizeAfterMs > 60_000)
|
|
277
|
-
) {
|
|
278
|
-
throw new RangeError("session stream reauthorization must be between 1000 and 60000ms");
|
|
279
|
-
}
|
|
280
273
|
let lastSent = after;
|
|
281
274
|
let bootstrapping = true;
|
|
282
275
|
let newestBuffered: SessionEvent | null = null;
|
|
283
276
|
let unsubscribe: (() => void) | null = null;
|
|
284
277
|
let delivery: LatestWinsDelivery<SessionEvent> | null = null;
|
|
285
|
-
let
|
|
278
|
+
let stopReauthorization = () => {};
|
|
286
279
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
287
280
|
let detachAbortListener = () => {};
|
|
288
281
|
let closeMetrics = () => {};
|
|
289
282
|
const stopUpstream = () => {
|
|
290
283
|
closeMetrics();
|
|
291
284
|
detachAbortListener();
|
|
292
|
-
|
|
293
|
-
clearTimeout(reauthorizationTimer);
|
|
294
|
-
reauthorizationTimer = null;
|
|
295
|
-
}
|
|
285
|
+
stopReauthorization();
|
|
296
286
|
if (heartbeatTimer) {
|
|
297
287
|
clearTimeout(heartbeatTimer);
|
|
298
288
|
heartbeatTimer = null;
|
|
@@ -313,20 +303,14 @@ export async function sseSessionStream(
|
|
|
313
303
|
const fail = (error: unknown) => {
|
|
314
304
|
channel.fail(retryableSseFailure("session event stream delivery failed", error));
|
|
315
305
|
};
|
|
316
|
-
|
|
317
|
-
if (!options.reauthorize || channel.stopped()) return;
|
|
318
|
-
const interval = options.reauthorizeAfterMs ?? 15_000;
|
|
319
|
-
reauthorizationTimer = setTimeout(() => {
|
|
320
|
-
reauthorizationTimer = null;
|
|
321
|
-
void options.reauthorize!()
|
|
322
|
-
.then(scheduleReauthorization)
|
|
323
|
-
.catch((error) => fail(error));
|
|
324
|
-
}, interval);
|
|
325
|
-
};
|
|
326
|
-
scheduleReauthorization();
|
|
306
|
+
stopReauthorization = startSseReauthorization(options, channel.stopped, fail);
|
|
327
307
|
let writeTail = Promise.resolve();
|
|
328
308
|
const writeFrame = (frame: string): Promise<void> => {
|
|
329
309
|
const write = writeTail.then(async () => {
|
|
310
|
+
// A periodic check closes an idle stream, while this exact pre-delivery
|
|
311
|
+
// check prevents a buffered/replayed event from crossing a revocation
|
|
312
|
+
// boundary merely because its timer has not fired yet.
|
|
313
|
+
await options.reauthorize?.();
|
|
330
314
|
if (!(await channel.write(frame))) throw new SseStreamStoppedError();
|
|
331
315
|
});
|
|
332
316
|
writeTail = write.catch(() => {});
|
|
@@ -343,36 +327,47 @@ export async function sseSessionStream(
|
|
|
343
327
|
});
|
|
344
328
|
}, heartbeatIntervalMs);
|
|
345
329
|
};
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
330
|
+
const deliverDurableThrough = async (targetSequence?: number) => {
|
|
331
|
+
while (true) {
|
|
332
|
+
if (targetSequence !== undefined && lastSent >= targetSequence) return;
|
|
333
|
+
const previousLastSent = lastSent;
|
|
334
|
+
const limit =
|
|
335
|
+
targetSequence === undefined
|
|
336
|
+
? SESSION_REPLAY_PAGE_SIZE
|
|
337
|
+
: Math.min(SESSION_REPLAY_PAGE_SIZE, targetSequence - lastSent);
|
|
338
|
+
const page = await listSessionEvents(db, workspaceId, sessionId, {
|
|
339
|
+
after: lastSent,
|
|
340
|
+
limit,
|
|
341
|
+
});
|
|
342
|
+
const eligible =
|
|
343
|
+
targetSequence === undefined
|
|
344
|
+
? page
|
|
345
|
+
: page.filter((event) => event.sequence <= targetSequence);
|
|
346
|
+
if (eligible.length === 0) {
|
|
347
|
+
if (targetSequence === undefined) return;
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Session event replay stalled before sequence ${targetSequence}; last sent ${lastSent}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
// The durable audit log remains exact. The browser transport combines
|
|
353
|
+
// adjacent text deltas into bounded frames carrying `coalescedUntil`, so
|
|
354
|
+
// a long answer cannot create thousands of React renders and starve
|
|
355
|
+
// command acknowledgements behind its own token stream.
|
|
356
|
+
for (const projected of coalesceSessionEventDeltas(eligible)) {
|
|
357
|
+
await writeFrame(formatSessionEventSse(projected));
|
|
358
|
+
lastSent = sessionEventResumeSequence(projected);
|
|
372
359
|
}
|
|
360
|
+
if (lastSent <= previousLastSent) {
|
|
361
|
+
throw new Error(`Session event replay made no progress after sequence ${lastSent}`);
|
|
362
|
+
}
|
|
363
|
+
if (targetSequence !== undefined && lastSent >= targetSequence) return;
|
|
364
|
+
if (targetSequence === undefined && page.length < limit) return;
|
|
373
365
|
}
|
|
374
|
-
|
|
375
|
-
|
|
366
|
+
};
|
|
367
|
+
const send = async (event: SessionEvent) => {
|
|
368
|
+
const targetSequence = sessionEventResumeSequence(event);
|
|
369
|
+
if (targetSequence <= lastSent) return;
|
|
370
|
+
await deliverDurableThrough(targetSequence);
|
|
376
371
|
};
|
|
377
372
|
delivery = createLatestWinsDelivery(send, fail);
|
|
378
373
|
|
|
@@ -394,12 +389,7 @@ export async function sseSessionStream(
|
|
|
394
389
|
}
|
|
395
390
|
unsubscribe = release;
|
|
396
391
|
|
|
397
|
-
await
|
|
398
|
-
(cursor, limit) => listSessionEvents(db, workspaceId, sessionId, cursor, limit),
|
|
399
|
-
send,
|
|
400
|
-
after,
|
|
401
|
-
SESSION_REPLAY_PAGE_SIZE,
|
|
402
|
-
);
|
|
392
|
+
await deliverDurableThrough();
|
|
403
393
|
await writeFrame(": connected\n\n");
|
|
404
394
|
scheduleHeartbeat();
|
|
405
395
|
bootstrapping = false;
|
|
@@ -471,12 +461,14 @@ export async function sseWorkspaceControlStream(
|
|
|
471
461
|
let newestBuffered: WorkspaceControlEvent | null = null;
|
|
472
462
|
let unsubscribe: (() => void) | null = null;
|
|
473
463
|
let delivery: LatestWinsDelivery<WorkspaceControlEvent> | null = null;
|
|
464
|
+
let stopReauthorization = () => {};
|
|
474
465
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
475
466
|
let detachAbortListener = () => {};
|
|
476
467
|
let closeMetrics = () => {};
|
|
477
468
|
const stopUpstream = () => {
|
|
478
469
|
closeMetrics();
|
|
479
470
|
detachAbortListener();
|
|
471
|
+
stopReauthorization();
|
|
480
472
|
if (heartbeatTimer) {
|
|
481
473
|
clearTimeout(heartbeatTimer);
|
|
482
474
|
heartbeatTimer = null;
|
|
@@ -497,9 +489,11 @@ export async function sseWorkspaceControlStream(
|
|
|
497
489
|
const fail = (error: unknown) => {
|
|
498
490
|
channel.fail(retryableSseFailure("workspace control stream delivery failed", error));
|
|
499
491
|
};
|
|
492
|
+
stopReauthorization = startSseReauthorization(options, channel.stopped, fail);
|
|
500
493
|
let writeTail = Promise.resolve();
|
|
501
494
|
const writeFrame = (frame: string): Promise<void> => {
|
|
502
495
|
const write = writeTail.then(async () => {
|
|
496
|
+
await options.reauthorize?.();
|
|
503
497
|
if (!(await channel.write(frame))) throw new SseStreamStoppedError();
|
|
504
498
|
});
|
|
505
499
|
writeTail = write.catch(() => {});
|
|
@@ -619,11 +613,13 @@ export async function sseWorkspaceLiveStream(
|
|
|
619
613
|
options: WorkspaceInteractionSseOptions = {},
|
|
620
614
|
): Promise<Response> {
|
|
621
615
|
const upstream = new AbortController();
|
|
616
|
+
let stopReauthorization = () => {};
|
|
622
617
|
const channel = createByteBoundedSseStream({
|
|
623
618
|
maxQueuedBytes: options.maxQueuedBytes ?? SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
624
619
|
...(options.stallTimeoutMs === undefined ? {} : { stallTimeoutMs: options.stallTimeoutMs }),
|
|
625
620
|
onObservation: sseObservationReporter("workspace_interaction", options),
|
|
626
621
|
onStop: () => {
|
|
622
|
+
stopReauthorization();
|
|
627
623
|
signal.removeEventListener("abort", abort);
|
|
628
624
|
upstream.abort();
|
|
629
625
|
},
|
|
@@ -634,10 +630,22 @@ export async function sseWorkspaceLiveStream(
|
|
|
634
630
|
};
|
|
635
631
|
if (signal.aborted) abort();
|
|
636
632
|
else signal.addEventListener("abort", abort, { once: true });
|
|
633
|
+
stopReauthorization = startSseReauthorization(options, channel.stopped, (error) => {
|
|
634
|
+
channel.fail(retryableSseFailure("workspace live stream authorization failed", error));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const upstreamOptions: WorkspaceInteractionSseOptions = {
|
|
638
|
+
...options,
|
|
639
|
+
reauthorize: undefined,
|
|
640
|
+
reauthorizeAfterMs: undefined,
|
|
641
|
+
};
|
|
637
642
|
|
|
638
643
|
let writeTail = Promise.resolve(true);
|
|
639
644
|
const write = (frame: string): Promise<boolean> => {
|
|
640
|
-
const pending = writeTail.then(() =>
|
|
645
|
+
const pending = writeTail.then(async () => {
|
|
646
|
+
await options.reauthorize?.();
|
|
647
|
+
return await channel.write(frame);
|
|
648
|
+
});
|
|
641
649
|
writeTail = pending.catch(() => false);
|
|
642
650
|
return pending;
|
|
643
651
|
};
|
|
@@ -665,14 +673,21 @@ export async function sseWorkspaceLiveStream(
|
|
|
665
673
|
void (async () => {
|
|
666
674
|
try {
|
|
667
675
|
const [control, interaction] = await Promise.all([
|
|
668
|
-
sseWorkspaceControlStream(
|
|
676
|
+
sseWorkspaceControlStream(
|
|
677
|
+
db,
|
|
678
|
+
bus,
|
|
679
|
+
workspaceId,
|
|
680
|
+
controlAfter,
|
|
681
|
+
upstream.signal,
|
|
682
|
+
upstreamOptions,
|
|
683
|
+
),
|
|
669
684
|
sseWorkspaceInteractionRevisionStream(
|
|
670
685
|
db,
|
|
671
686
|
accountId,
|
|
672
687
|
workspaceId,
|
|
673
688
|
interactionAfter,
|
|
674
689
|
upstream.signal,
|
|
675
|
-
|
|
690
|
+
upstreamOptions,
|
|
676
691
|
),
|
|
677
692
|
]);
|
|
678
693
|
const pumps = [pump(control), pump(interaction)];
|
|
@@ -721,6 +736,7 @@ export async function sseWorkspaceInteractionRevisionStream(
|
|
|
721
736
|
let lastSent = after;
|
|
722
737
|
let lastWriteAt = Date.now();
|
|
723
738
|
let stopRequested = false;
|
|
739
|
+
let stopReauthorization = () => {};
|
|
724
740
|
let detachAbortListener = () => {};
|
|
725
741
|
let closeMetrics = () => {};
|
|
726
742
|
const channel = createByteBoundedSseStream({
|
|
@@ -729,13 +745,18 @@ export async function sseWorkspaceInteractionRevisionStream(
|
|
|
729
745
|
onObservation: sseObservationReporter("workspace_interaction", options),
|
|
730
746
|
onStop: () => {
|
|
731
747
|
stopRequested = true;
|
|
748
|
+
stopReauthorization();
|
|
732
749
|
closeMetrics();
|
|
733
750
|
detachAbortListener();
|
|
734
751
|
},
|
|
735
752
|
});
|
|
736
753
|
closeMetrics = observeSseConnection("workspace_interaction", after, options.observability);
|
|
754
|
+
stopReauthorization = startSseReauthorization(options, channel.stopped, (error) => {
|
|
755
|
+
channel.fail(retryableSseFailure("workspace interaction stream authorization failed", error));
|
|
756
|
+
});
|
|
737
757
|
|
|
738
758
|
const write = async (frame: string): Promise<boolean> => {
|
|
759
|
+
await options.reauthorize?.();
|
|
739
760
|
const accepted = await channel.write(frame);
|
|
740
761
|
if (accepted) lastWriteAt = Date.now();
|
|
741
762
|
return accepted;
|
|
@@ -849,14 +870,13 @@ export type SseDeliveryOptions = {
|
|
|
849
870
|
heartbeatIntervalMs?: number;
|
|
850
871
|
observability?: Observability | undefined;
|
|
851
872
|
onObservation?: ((observation: SseDeliveryBoundObservation) => void) | undefined;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
export type SessionSseDeliveryOptions = SseDeliveryOptions & {
|
|
855
|
-
/** Host ACL re-check, run even while the event stream is otherwise idle. */
|
|
873
|
+
/** Current ACL re-check, run even while the event stream is idle. */
|
|
856
874
|
reauthorize?: (() => Promise<void>) | undefined;
|
|
857
875
|
reauthorizeAfterMs?: number | undefined;
|
|
858
876
|
};
|
|
859
877
|
|
|
878
|
+
export type SessionSseDeliveryOptions = SseDeliveryOptions;
|
|
879
|
+
|
|
860
880
|
function sseObservationReporter(
|
|
861
881
|
stream: SseStreamKind,
|
|
862
882
|
options: SseDeliveryOptions,
|
|
@@ -900,6 +920,32 @@ function resolveInteractionPollInterval(value: number | undefined): number {
|
|
|
900
920
|
return interval;
|
|
901
921
|
}
|
|
902
922
|
|
|
923
|
+
function startSseReauthorization(
|
|
924
|
+
options: SseDeliveryOptions,
|
|
925
|
+
stopped: () => boolean,
|
|
926
|
+
fail: (error: unknown) => void,
|
|
927
|
+
): () => void {
|
|
928
|
+
if (!options.reauthorize) return () => {};
|
|
929
|
+
const interval = options.reauthorizeAfterMs ?? 15_000;
|
|
930
|
+
if (!Number.isSafeInteger(interval) || interval < 1_000 || interval > 60_000) {
|
|
931
|
+
throw new RangeError("SSE reauthorization must be between 1000 and 60000ms");
|
|
932
|
+
}
|
|
933
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
934
|
+
const schedule = () => {
|
|
935
|
+
if (stopped()) return;
|
|
936
|
+
timer = setTimeout(() => {
|
|
937
|
+
timer = null;
|
|
938
|
+
void options.reauthorize!().then(schedule).catch(fail);
|
|
939
|
+
}, interval);
|
|
940
|
+
};
|
|
941
|
+
schedule();
|
|
942
|
+
return () => {
|
|
943
|
+
if (!timer) return;
|
|
944
|
+
clearTimeout(timer);
|
|
945
|
+
timer = null;
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
903
949
|
function formatWorkspaceInteractionRevisionSse(event: WorkspaceInteractionRevisionEvent): string {
|
|
904
950
|
return `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
905
951
|
}
|