@opengeni/sdk 3.1.0 → 3.1.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 (295) hide show
  1. package/README.md +1037 -71
  2. package/dist/accounts.d.ts +5 -1
  3. package/dist/accounts.js +13 -1
  4. package/dist/accounts.js.map +1 -1
  5. package/dist/adapters/express.d.ts +31 -0
  6. package/dist/adapters/express.js +87 -0
  7. package/dist/adapters/express.js.map +1 -0
  8. package/dist/adapters/hono.d.ts +16 -0
  9. package/dist/adapters/hono.js +8 -0
  10. package/dist/adapters/hono.js.map +1 -0
  11. package/dist/adapters/next.d.ts +29 -0
  12. package/dist/adapters/next.js +22 -0
  13. package/dist/adapters/next.js.map +1 -0
  14. package/dist/allowance-refusal.d.ts +2 -0
  15. package/dist/allowance-refusal.js +50 -0
  16. package/dist/allowance-refusal.js.map +1 -0
  17. package/dist/app-links.d.ts +42 -0
  18. package/dist/app-links.js +66 -0
  19. package/dist/app-links.js.map +1 -0
  20. package/dist/artifact-catalog.d.ts +40 -0
  21. package/dist/artifact-client.d.ts +20 -8
  22. package/dist/artifacts.d.ts +1 -1
  23. package/dist/artifacts.js +8 -6
  24. package/dist/automations.js +8 -2
  25. package/dist/automations.js.map +1 -1
  26. package/dist/azure-live-transport.d.ts +32 -0
  27. package/dist/browser.d.ts +7 -2
  28. package/dist/browser.js +27 -5
  29. package/dist/chat/fold.d.ts +75 -0
  30. package/dist/chat/handler.d.ts +41 -0
  31. package/dist/chat/http.d.ts +67 -0
  32. package/dist/chat/ids.d.ts +34 -0
  33. package/dist/chat/index.d.ts +9 -0
  34. package/dist/chat/index.js +1703 -0
  35. package/dist/chat/index.js.map +1 -0
  36. package/dist/chat/openai.d.ts +30 -0
  37. package/dist/chat/opengeni.d.ts +129 -0
  38. package/dist/chat/types.d.ts +207 -0
  39. package/dist/chat/vercel.d.ts +49 -0
  40. package/dist/chats.d.ts +8 -0
  41. package/dist/chunk-4AHR4KGA.js +301 -0
  42. package/dist/chunk-4AHR4KGA.js.map +1 -0
  43. package/dist/chunk-6HZGAIUE.js +1665 -0
  44. package/dist/chunk-6HZGAIUE.js.map +1 -0
  45. package/dist/chunk-7EWT3PV6.js +368 -0
  46. package/dist/chunk-7EWT3PV6.js.map +1 -0
  47. package/dist/{chunk-VYCTL62C.js → chunk-GZQSWAKD.js} +1 -1
  48. package/dist/chunk-GZQSWAKD.js.map +1 -0
  49. package/dist/chunk-JXXCTW5W.js +1392 -0
  50. package/dist/chunk-JXXCTW5W.js.map +1 -0
  51. package/dist/{chunk-YTAWBDO2.js → chunk-KADZ5PGG.js} +28 -2
  52. package/dist/chunk-KADZ5PGG.js.map +1 -0
  53. package/dist/{chunk-YKZME56C.js → chunk-KWADQ227.js} +3 -3
  54. package/dist/{chunk-YKZME56C.js.map → chunk-KWADQ227.js.map} +1 -1
  55. package/dist/{chunk-7ZXBPJZP.js → chunk-NEM5N6C6.js} +3902 -1457
  56. package/dist/chunk-NEM5N6C6.js.map +1 -0
  57. package/dist/{chunk-GW3EJ2GP.js → chunk-O7KANXC3.js} +86 -13
  58. package/dist/chunk-O7KANXC3.js.map +1 -0
  59. package/dist/{chunk-VUQZAB3O.js → chunk-OAKHWFSV.js} +28 -32
  60. package/dist/chunk-OAKHWFSV.js.map +1 -0
  61. package/dist/chunk-P7UWKORG.js +561 -0
  62. package/dist/chunk-P7UWKORG.js.map +1 -0
  63. package/dist/chunk-QGEIL4TR.js +195 -0
  64. package/dist/chunk-QGEIL4TR.js.map +1 -0
  65. package/dist/chunk-RJHCBZF7.js +270 -0
  66. package/dist/chunk-RJHCBZF7.js.map +1 -0
  67. package/dist/chunk-S3D75WSQ.js +340 -0
  68. package/dist/chunk-S3D75WSQ.js.map +1 -0
  69. package/dist/chunk-UM4ZXXJR.js +9 -0
  70. package/dist/chunk-UM4ZXXJR.js.map +1 -0
  71. package/dist/chunk-VPVMLM7Q.js +15 -0
  72. package/dist/chunk-VPVMLM7Q.js.map +1 -0
  73. package/dist/{chunk-C2H7HBNP.js → chunk-WVCJRSGJ.js} +119 -14
  74. package/dist/chunk-WVCJRSGJ.js.map +1 -0
  75. package/dist/chunk-XA7HVIHI.js +126 -0
  76. package/dist/chunk-XA7HVIHI.js.map +1 -0
  77. package/dist/{chunk-TX3EIENU.js → chunk-YDVPI7WT.js} +73 -2
  78. package/dist/chunk-YDVPI7WT.js.map +1 -0
  79. package/dist/chunk-YKIHNLWD.js +153 -0
  80. package/dist/chunk-YKIHNLWD.js.map +1 -0
  81. package/dist/chunk-ZDUBPV33.js +277 -0
  82. package/dist/chunk-ZDUBPV33.js.map +1 -0
  83. package/dist/client.d.ts +1065 -124
  84. package/dist/codex-realtime-controller.d.ts +13 -0
  85. package/dist/codex-realtime-controller.js +4 -2
  86. package/dist/codex-realtime-v3-wire.d.ts +3 -2
  87. package/dist/codex-realtime.d.ts +5 -1
  88. package/dist/company-brain.d.ts +1 -11
  89. package/dist/company-profile.d.ts +13 -0
  90. package/dist/core.d.ts +7 -2
  91. package/dist/core.js +25 -6
  92. package/dist/deprecation.d.ts +47 -0
  93. package/dist/document-authority-client.d.ts +24 -2
  94. package/dist/document-authority.js +7 -5
  95. package/dist/editable-artifacts/index.d.ts +1 -1
  96. package/dist/editable-artifacts/types.d.ts +12 -2
  97. package/dist/editable-artifacts.js +19 -3
  98. package/dist/editable-artifacts.js.map +1 -1
  99. package/dist/embedding-client.d.ts +129 -0
  100. package/dist/errors.d.ts +76 -1
  101. package/dist/feedback.d.ts +23 -0
  102. package/dist/gateway-realtime-transport.js +1 -1
  103. package/dist/github-repositories.d.ts +10 -0
  104. package/dist/github-repositories.js +36 -0
  105. package/dist/github-repositories.js.map +1 -0
  106. package/dist/index.d.ts +64 -11
  107. package/dist/index.js +566 -141
  108. package/dist/index.js.map +1 -1
  109. package/dist/insights-usage.d.ts +33 -0
  110. package/dist/insights-usage.js +7 -0
  111. package/dist/insights-usage.js.map +1 -0
  112. package/dist/interaction.d.ts +103 -71
  113. package/dist/interaction.js +4 -2
  114. package/dist/knowledge.d.ts +7 -0
  115. package/dist/knowledge.js +20 -0
  116. package/dist/knowledge.js.map +1 -0
  117. package/dist/known-tools.d.ts +4 -0
  118. package/dist/message-links.d.ts +68 -0
  119. package/dist/model-context.d.ts +68 -0
  120. package/dist/model-display.d.ts +2 -0
  121. package/dist/model-display.js +17 -0
  122. package/dist/model-display.js.map +1 -0
  123. package/dist/model-picker-order.d.ts +1 -0
  124. package/dist/model-picker-order.js +10 -0
  125. package/dist/model-picker-order.js.map +1 -0
  126. package/dist/organization-integration-policy.d.ts +26 -0
  127. package/dist/organization-integration-policy.js +26 -0
  128. package/dist/organization-integration-policy.js.map +1 -0
  129. package/dist/pr-review-client.d.ts +1 -1
  130. package/dist/pr-review.d.ts +1 -1
  131. package/dist/pr-review.js.map +1 -1
  132. package/dist/preference-registry.d.ts +1 -1
  133. package/dist/prepared-mcp-setup.d.ts +5 -0
  134. package/dist/proxy.d.ts +13 -11
  135. package/dist/realtime.d.ts +4 -2
  136. package/dist/realtime.js +253 -7
  137. package/dist/realtime.js.map +1 -1
  138. package/dist/session-history-import.d.ts +22 -0
  139. package/dist/session-history-import.js +36 -0
  140. package/dist/session-history-import.js.map +1 -0
  141. package/dist/session-list-entries.d.ts +1 -0
  142. package/dist/session-list-entries.js +7 -0
  143. package/dist/session-list-entries.js.map +1 -0
  144. package/dist/session-message-search.d.ts +53 -0
  145. package/dist/session-proxy-client.d.ts +28 -0
  146. package/dist/session-proxy.d.ts +334 -0
  147. package/dist/session-proxy.js +24 -0
  148. package/dist/session-proxy.js.map +1 -0
  149. package/dist/session-titles.d.ts +2 -0
  150. package/dist/session-usage-limits.d.ts +10 -0
  151. package/dist/session-usage-limits.js +43 -0
  152. package/dist/session-usage-limits.js.map +1 -0
  153. package/dist/shared-catalog-loader.d.ts +34 -0
  154. package/dist/site-bridge-host.d.ts +112 -0
  155. package/dist/site-bridge-host.js +285 -0
  156. package/dist/site-bridge-host.js.map +1 -0
  157. package/dist/site-browser-entry.d.ts +1 -0
  158. package/dist/site-browser-runtime.gen.d.ts +1 -0
  159. package/dist/site-data.d.ts +178 -0
  160. package/dist/site-document.d.ts +5 -0
  161. package/dist/site-document.js +20 -0
  162. package/dist/site-document.js.map +1 -0
  163. package/dist/site-document.test.d.ts +1 -0
  164. package/dist/site-http.d.ts +17 -0
  165. package/dist/site-sql.d.ts +31 -0
  166. package/dist/site-tool-bridge.d.ts +57 -0
  167. package/dist/site.d.ts +182 -0
  168. package/dist/site.js +62 -0
  169. package/dist/site.js.map +1 -0
  170. package/dist/skills.d.ts +75 -0
  171. package/dist/sse.d.ts +1 -1
  172. package/dist/stream.d.ts +2 -0
  173. package/dist/tenant-workspaces.d.ts +47 -0
  174. package/dist/tenant-workspaces.js +8 -0
  175. package/dist/tenant-workspaces.js.map +1 -0
  176. package/dist/tool-auth.d.ts +87 -0
  177. package/dist/tool-auth.js +23 -0
  178. package/dist/tool-auth.js.map +1 -0
  179. package/dist/tool-display-metadata.d.ts +3 -0
  180. package/dist/tools-37DCTCNZ.js +12 -0
  181. package/dist/tools-37DCTCNZ.js.map +1 -0
  182. package/dist/tools.d.ts +81 -0
  183. package/dist/transcription.d.ts +1 -1
  184. package/dist/types.d.ts +2848 -573
  185. package/dist/usage-allowances.d.ts +36 -0
  186. package/dist/usage-allowances.js +80 -0
  187. package/dist/usage-allowances.js.map +1 -0
  188. package/dist/vault-types.d.ts +236 -0
  189. package/dist/vaults-api.d.ts +57 -0
  190. package/dist/workspace-artifacts.d.ts +47 -5
  191. package/dist/workspace-instruction-policies.d.ts +2 -2
  192. package/dist/workspace-integrations.d.ts +74 -0
  193. package/dist/workspace-integrations.js +65 -0
  194. package/dist/workspace-integrations.js.map +1 -0
  195. package/dist/workspace-state.d.ts +8 -71
  196. package/package.json +99 -5
  197. package/src/accounts.ts +18 -0
  198. package/src/adapters/express.ts +136 -0
  199. package/src/adapters/hono.ts +18 -0
  200. package/src/adapters/next.ts +42 -0
  201. package/src/allowance-refusal.ts +5 -0
  202. package/src/app-links.ts +117 -0
  203. package/src/artifact-catalog.ts +51 -0
  204. package/src/artifact-client.ts +40 -49
  205. package/src/artifacts.ts +1 -0
  206. package/src/automations-client.ts +8 -2
  207. package/src/azure-live-transport.ts +286 -0
  208. package/src/browser.ts +37 -0
  209. package/src/chat/fold.ts +431 -0
  210. package/src/chat/handler.ts +240 -0
  211. package/src/chat/http.ts +257 -0
  212. package/src/chat/ids.ts +85 -0
  213. package/src/chat/index.ts +88 -0
  214. package/src/chat/openai.ts +439 -0
  215. package/src/chat/opengeni.ts +737 -0
  216. package/src/chat/types.ts +219 -0
  217. package/src/chat/vercel.ts +204 -0
  218. package/src/chats.ts +16 -0
  219. package/src/client.ts +5822 -1847
  220. package/src/codex-realtime-controller.ts +163 -8
  221. package/src/codex-realtime-v3-wire.ts +4 -3
  222. package/src/codex-realtime-v3.ts +12 -3
  223. package/src/codex-realtime.ts +11 -3
  224. package/src/company-brain.ts +0 -40
  225. package/src/company-profile.ts +13 -0
  226. package/src/core.ts +20 -0
  227. package/src/deprecation.ts +149 -0
  228. package/src/document-authority-client.ts +104 -1
  229. package/src/editable-artifacts/controller.ts +36 -2
  230. package/src/editable-artifacts/index.ts +1 -0
  231. package/src/editable-artifacts/types.ts +29 -16
  232. package/src/embedding-client.ts +519 -0
  233. package/src/errors.ts +303 -4
  234. package/src/feedback.ts +20 -0
  235. package/src/gateway-realtime-transport.ts +3 -3
  236. package/src/github-repositories.ts +60 -0
  237. package/src/index.ts +611 -64
  238. package/src/insights-usage.ts +61 -0
  239. package/src/interaction.ts +274 -93
  240. package/src/knowledge.ts +72 -0
  241. package/src/known-tools.ts +279 -0
  242. package/src/message-links.ts +178 -0
  243. package/src/model-context.ts +113 -0
  244. package/src/model-display.ts +11 -0
  245. package/src/model-picker-order.ts +6 -0
  246. package/src/organization-integration-policy.ts +54 -0
  247. package/src/pr-review-client.ts +1 -1
  248. package/src/pr-review.ts +1 -1
  249. package/src/preference-registry.ts +2 -0
  250. package/src/prepared-mcp-setup.ts +123 -0
  251. package/src/proxy.ts +23 -12
  252. package/src/realtime.ts +16 -6
  253. package/src/session-history-import.ts +84 -0
  254. package/src/session-list-entries.ts +1 -0
  255. package/src/session-message-search.ts +47 -0
  256. package/src/session-proxy-client.ts +68 -0
  257. package/src/session-proxy.ts +2098 -0
  258. package/src/session-titles.ts +10 -0
  259. package/src/session-usage-limits.ts +71 -0
  260. package/src/shared-catalog-loader.ts +169 -0
  261. package/src/site-bridge-host.ts +422 -0
  262. package/src/site-browser-entry.ts +6 -0
  263. package/src/site-browser-runtime.gen.ts +2 -0
  264. package/src/site-data.ts +372 -0
  265. package/src/site-document.test.ts +64 -0
  266. package/src/site-document.ts +18 -0
  267. package/src/site-http.ts +171 -0
  268. package/src/site-sql.ts +153 -0
  269. package/src/site-tool-bridge.ts +401 -0
  270. package/src/site.ts +867 -0
  271. package/src/skills.ts +69 -0
  272. package/src/sse.ts +9 -3
  273. package/src/stream.ts +33 -10
  274. package/src/tenant-workspaces.ts +148 -0
  275. package/src/tool-auth.ts +301 -0
  276. package/src/tool-display-metadata.ts +27 -0
  277. package/src/tools.ts +452 -0
  278. package/src/transcription.ts +3 -0
  279. package/src/types.ts +3216 -581
  280. package/src/usage-allowances.ts +189 -0
  281. package/src/vault-types.ts +293 -0
  282. package/src/vaults-api.ts +288 -0
  283. package/src/workspace-artifacts.ts +55 -6
  284. package/src/workspace-instruction-policies.ts +2 -1
  285. package/src/workspace-integrations.ts +402 -0
  286. package/src/workspace-state.ts +8 -82
  287. package/dist/chunk-7ZXBPJZP.js.map +0 -1
  288. package/dist/chunk-C2H7HBNP.js.map +0 -1
  289. package/dist/chunk-C4DJPZRU.js +0 -119
  290. package/dist/chunk-C4DJPZRU.js.map +0 -1
  291. package/dist/chunk-GW3EJ2GP.js.map +0 -1
  292. package/dist/chunk-TX3EIENU.js.map +0 -1
  293. package/dist/chunk-VUQZAB3O.js.map +0 -1
  294. package/dist/chunk-VYCTL62C.js.map +0 -1
  295. package/dist/chunk-YTAWBDO2.js.map +0 -1
@@ -0,0 +1,2098 @@
1
+ import type { OpenGeniEmbeddingClient } from "./embedding-client";
2
+ import { OpenGeniApiError, OpenGeniSetupError } from "./errors";
3
+ import { chatDefaults, type Chats } from "./chats";
4
+ import { SESSION_SCOPE_HEADER } from "./message-links";
5
+ import type { WorkspaceIdOptions, WorkspaceIdTarget } from "./tenant-workspaces";
6
+ import { proxySessionEventStream } from "./proxy";
7
+ import { OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION } from "./types";
8
+ import type {
9
+ CreateSessionRequest,
10
+ FileResourceRef,
11
+ LatencyMode,
12
+ ReasoningEffort,
13
+ RetainedArtifactContent,
14
+ SessionMcpCredentialUpdateInput,
15
+ } from "./types";
16
+ import { mintToolToken, resolveToolTokenSecret } from "./tool-auth";
17
+ import {
18
+ downloadSessionProxySiteHtml,
19
+ getSessionProxyArtifactAssociation,
20
+ getSessionProxyWorkspaceGrant,
21
+ SESSION_PROXY_SITE_HTML_MAX_BYTES,
22
+ SessionProxySiteHtmlTooLargeError,
23
+ } from "./session-proxy-client";
24
+ export {
25
+ downloadSessionProxySiteHtml,
26
+ getSessionProxyArtifactAssociation,
27
+ getSessionProxyWorkspaceGrant,
28
+ SESSION_PROXY_SITE_HTML_MAX_BYTES,
29
+ SessionProxySiteHtmlTooLargeError,
30
+ } from "./session-proxy-client";
31
+
32
+ /**
33
+ * Packaged same-origin backend for the React conversation surfaces.
34
+ *
35
+ * Mount the handler at a catch-all route (for example `/api/opengeni/*`) and
36
+ * point an unmodified browser `OpenGeniClient({ baseUrl: "/api/opengeni" })`
37
+ * at it. Every request is authenticated by the host's `resolve` hook, pinned
38
+ * to the resolved workspace, executed as the resolved external user through
39
+ * `asUser` (never the organization key's own service authority), and limited
40
+ * to the exact native routes `SessionConversation` and its hooks use. It is
41
+ * an explicit dispatcher, not an arbitrary upstream proxy.
42
+ */
43
+
44
+ type ProxyClient = OpenGeniEmbeddingClient;
45
+
46
+ /** The `@opengeni/sdk/chat` `Opengeni` facade, structurally. */
47
+ type ProxyFacade = {
48
+ readonly client: ProxyClient;
49
+ readonly source: string;
50
+ workspaceId(target: { tenant: string }): Promise<string>;
51
+ workspaceIdFor?(target: WorkspaceIdTarget, options: WorkspaceIdOptions): Promise<string>;
52
+ };
53
+
54
+ /**
55
+ * Who the authenticated request acts as, in your own ids. Never derive any of
56
+ * it from the request body or path. `{ user, tenant }`: one workspace per
57
+ * tenant. `{ user }`: one workspace per user. `{ user, workspaceId }`: your
58
+ * own workspace. Tenant and per-user mapping require the `Opengeni` facade
59
+ * from `@opengeni/sdk/chat`; workspaces are created on first use and Opengeni
60
+ * adds the user on their first request.
61
+ */
62
+ export type SessionProxyResolution = (
63
+ | { workspaceId: string; tenant?: undefined }
64
+ | { tenant: string; workspaceId?: undefined }
65
+ | { tenant?: undefined; workspaceId?: undefined }
66
+ ) & {
67
+ /** Host-authenticated external user id; every call runs through `asUser(user)`. */
68
+ user: string;
69
+ /** External identity source. Defaults to the facade's `source`, else `"default"`. */
70
+ source?: string | undefined;
71
+ /**
72
+ * The user is an anonymous visitor your product has not signed in, for
73
+ * example a website visitor keyed by a cookie. Visitors cannot upload
74
+ * composer file attachments unless the handler sets `visitorUploads: true`;
75
+ * everything else is unchanged.
76
+ */
77
+ visitor?: boolean | undefined;
78
+ };
79
+
80
+ /** Host auth hook: return the resolution, or a `Response` (for example 401) to reject. */
81
+ export type SessionProxyResolve = (
82
+ request: Request,
83
+ ) => Promise<SessionProxyResolution | Response> | SessionProxyResolution | Response;
84
+
85
+ export type SessionProxyContext = {
86
+ request: Request;
87
+ workspaceId: string;
88
+ user: string;
89
+ source: string;
90
+ /** The `asUser` client the proxy uses for this request. */
91
+ client: ProxyClient;
92
+ };
93
+
94
+ /**
95
+ * The only fields a browser may send when creating a session through the proxy.
96
+ * After the `createSession` hook returns, the proxy adds the attached files to
97
+ * the created request (deduplicated by file id) and, when `modelSelection` is
98
+ * not `false`, applies the browser's explicit model choices over the hook's,
99
+ * exactly as follow-up messages carry them. Return a `Response` from the hook
100
+ * to refuse an input.
101
+ */
102
+ export type SessionProxyCreateInput = {
103
+ /** Empty for a voice-first session; no initial user message is fabricated. */
104
+ initialMessage: string;
105
+ /** Available only while the proxy permits realtime voice. */
106
+ startMode?: "realtime" | undefined;
107
+ /** Browser retry key; replay is scoped to the acting user by the API. */
108
+ idempotencyKey?: string | undefined;
109
+ /** Files attached to the first message (uploaded through this proxy). */
110
+ resources?: FileResourceRef[] | undefined;
111
+ /** The user's model choice; only when `modelSelection` is not `false`. */
112
+ model?: string | undefined;
113
+ /** The user's reasoning choice; only when `modelSelection` is not `false`. */
114
+ reasoningEffort?: ReasoningEffort | undefined;
115
+ /** The user's latency choice; only when `modelSelection` is not `false`. */
116
+ latencyMode?: LatencyMode | undefined;
117
+ };
118
+
119
+ /** Which browser action is about to forward a user message or response. */
120
+ export type SessionProxyMessageInput = {
121
+ /** Absent for `create`. */
122
+ sessionId?: string | undefined;
123
+ /** An empty voice-first session shell, not an initial model-producing turn. */
124
+ startMode?: "realtime" | undefined;
125
+ /**
126
+ * `realtime` is live voice: once when a call starts (refuse it, or rotate
127
+ * MCP credentials), and before each batch of finalized transcripts and spoken
128
+ * requests is saved (refuse it, or add `modelContext`).
129
+ */
130
+ delivery: "create" | "send" | "steer" | "submit" | "realtime";
131
+ };
132
+
133
+ /** Server-side additions the host attaches to one forwarded user message or response. */
134
+ export type SessionProxyMessageExtras = {
135
+ /**
136
+ * Model-visible context for this message (current page, time zone, today's
137
+ * date). Placed before any context the browser sent. Not secret. Ignored for
138
+ * approval decisions and human-input responses, which are not new messages,
139
+ * and when a live voice call starts. Keep it stable for the same message:
140
+ * a retried voice entry must carry the same context to be accepted.
141
+ */
142
+ modelContext?: string | undefined;
143
+ /**
144
+ * Header-only credential rotation for MCP servers already attached to the
145
+ * session (for example a fresh short-lived per-user bearer), applied
146
+ * atomically as the message or response is accepted. Ignored for `create`,
147
+ * where the `createSession` hook sets the initial headers.
148
+ */
149
+ mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[] | undefined;
150
+ };
151
+
152
+ /**
153
+ * Your product's own MCP tool server, attached to every session this proxy
154
+ * creates with a per-user bearer token. Verify it on every MCP request with
155
+ * `verifyToolRequest` from `@opengeni/sdk/tool-auth`. Tools always act as the
156
+ * chat's creator: in a shared chat, other members' messages do not change it.
157
+ */
158
+ export type SessionProxyToolServer = {
159
+ /**
160
+ * Public HTTPS URL of your MCP endpoint, e.g. `https://app.example.com/api/mcp`.
161
+ * Defaults to `OPENGENI_TOOL_SERVER_URL`, which `verifyToolRequest` also reads.
162
+ */
163
+ url?: string | undefined;
164
+ /** Session MCP server id (model-facing tool prefix). Defaults to `"app"`. */
165
+ id?: string | undefined;
166
+ /** Display name. */
167
+ name?: string | undefined;
168
+ /**
169
+ * Tools the user must approve before each call: list your write tools here
170
+ * (unprefixed MCP tool names), or `true` for every tool. Others run directly.
171
+ */
172
+ approvals?: { ask: string[] | true } | undefined;
173
+ /** Token signing secret. Defaults to `OPENGENI_API_KEY`; `verifyToolRequest` must use the same. */
174
+ secret?: string | undefined;
175
+ /** Token lifetime in seconds. Defaults to 24 hours; refreshed on every message, approval, and answer. */
176
+ ttlSeconds?: number | undefined;
177
+ };
178
+
179
+ export type SessionProxyHandlerOptions = {
180
+ /**
181
+ * Private (default) uses personal Knowledge and session-only reach; shared uses
182
+ * workspace Knowledge and reach. Isolated also gives each user their own tenant workspace.
183
+ */
184
+ chats?: Chats | undefined;
185
+ /** Mandatory host auth hook, called on every request. */
186
+ resolve: SessionProxyResolve;
187
+ /**
188
+ * Host CSRF/session policy for non-GET requests. Without it the proxy only
189
+ * rejects `Sec-Fetch-Site: cross-site` mutations and requires JSON bodies;
190
+ * cookie-authenticated hosts should supply their existing CSRF check.
191
+ */
192
+ authorizeMutation?: ((request: Request) => boolean | Promise<boolean>) | undefined;
193
+ /**
194
+ * Optional product-level session check (for example "this ticket's session
195
+ * belongs to this user"). Opengeni still enforces membership and private
196
+ * visibility on every call.
197
+ */
198
+ authorizeSession?:
199
+ | ((sessionId: string, context: SessionProxyContext) => boolean | Promise<boolean>)
200
+ | undefined;
201
+ /**
202
+ * Server-controlled session creation. The browser supplies only
203
+ * {@link SessionProxyCreateInput}; this hook returns the complete create
204
+ * request (agent, tools, MCP servers, Skills, instructions, model policy). Omit it
205
+ * to disable browser-initiated creation entirely.
206
+ */
207
+ createSession?:
208
+ | ((
209
+ input: SessionProxyCreateInput,
210
+ context: SessionProxyContext,
211
+ ) => CreateSessionRequest | Response | Promise<CreateSessionRequest | Response>)
212
+ | undefined;
213
+ /**
214
+ * Called before every forwarded user message (send, steer, composer submit,
215
+ * and browser-started create), approval decision, human-input response, and
216
+ * live voice start and transcript save (`delivery: "realtime"`).
217
+ * Return server-owned `modelContext` (messages and voice transcripts only)
218
+ * and MCP credential rotations, or a `Response` to reject the action.
219
+ */
220
+ beforeForwardMessage?:
221
+ | ((
222
+ input: SessionProxyMessageInput,
223
+ context: SessionProxyContext,
224
+ ) =>
225
+ | SessionProxyMessageExtras
226
+ | Response
227
+ | undefined
228
+ | Promise<SessionProxyMessageExtras | Response | undefined>)
229
+ | undefined;
230
+ /**
231
+ * Attach your product's MCP tool server to every session the `createSession`
232
+ * hook creates, authenticated as the resolved user. The proxy mints the
233
+ * token, adds the `mcpServers` entry (plus an eager ref when the hook lists
234
+ * explicit `tools`), and rotates the token on every send, steer, submit,
235
+ * approval decision, and human-input answer.
236
+ */
237
+ toolServer?: SessionProxyToolServer | undefined;
238
+ /** Mount prefix, e.g. `/api/opengeni`. Defaults to everything before the first `/v1/`. */
239
+ basePath?: string | undefined;
240
+ /** Maximum JSON request body. Defaults to 1 MiB. */
241
+ maxBodyBytes?: number | undefined;
242
+ /**
243
+ * Expose composer file attachments (upload begin/complete, download URL) and
244
+ * the media agents produce in a session: generated images, browser and
245
+ * computer screenshots, published sandbox files, and generated video
246
+ * playback. Workspace-level artifact reads must name their session in the
247
+ * `x-opengeni-session-id` header (`SessionConversation` does this); the proxy
248
+ * runs `authorizeSession` and only forwards an artifact Opengeni proves that
249
+ * session produced. Defaults to true.
250
+ */
251
+ files?: boolean | undefined;
252
+ /**
253
+ * Let anonymous visitors (`resolve` returned `visitor: true`) upload composer
254
+ * file attachments. Defaults to false: for a visitor the client config reports
255
+ * file uploads off, so stock UIs hide the attach button, and the upload routes
256
+ * are refused. Agent-produced media stays readable under `files`. Signed-in
257
+ * users follow `files` alone.
258
+ */
259
+ visitorUploads?: boolean | undefined;
260
+ /**
261
+ * Forward composer voice input (`POST .../transcriptions`, one recording per
262
+ * request) as the resolved user; Opengeni still requires that user's
263
+ * `sessions:create` permission and the workspace's voice-input setting.
264
+ * `false` reports voice input unavailable in the client config so stock UIs
265
+ * hide the microphone. Defaults to true.
266
+ */
267
+ voiceInput?: boolean | undefined;
268
+ /**
269
+ * Forward live speech-to-speech voice for existing chats as the resolved
270
+ * user: the voice model catalog and the call lifecycle routes under
271
+ * `.../sessions/{id}/realtime`. Opengeni still requires that user's
272
+ * `sessions:control` permission, binds the call to that user and browser,
273
+ * runs spoken requests as ordinary steers of the chat, and meters
274
+ * deployment-funded voice against the organization's credits. Defaults to
275
+ * true. The stock conversation's voice button is opt-in: explicit `true`
276
+ * reports `realtimeVoice: true` in the client config so `SessionConversation`
277
+ * and `OpenGeniChat` show it (when a voice model is available), as does their
278
+ * own `realtimeVoice` prop. `false` reports `realtimeVoice: false` so stock
279
+ * UIs hide it, and refuses the routes.
280
+ */
281
+ realtimeVoice?: boolean | undefined;
282
+ /**
283
+ * Let the browser read a file from the session's sandbox (`POST .../fs/read`)
284
+ * so `sandbox:` links in agent replies can be downloaded. Only `path`,
285
+ * `encoding`, and `maxBytes` are forwarded; Opengeni still requires the
286
+ * user's `files:read` permission on that session. Explicit opt-in, default
287
+ * false. Reads are confined to its working directory, including on Connected
288
+ * Machines; symlinked paths are refused.
289
+ */
290
+ sandboxFiles?: boolean | undefined;
291
+ /**
292
+ * Serve the embedded artifact viewer and inline Site previews: reads of the
293
+ * editable artifacts and Sites a session produced, and live-ticket minting
294
+ * for the editor. Explicit opt-in, default false.
295
+ *
296
+ * Every request must name its session in the `x-opengeni-session-id` header
297
+ * (`SessionArtifactViewer` and `SessionConversation` do this). The proxy
298
+ * runs `authorizeSession`, then only forwards an artifact Opengeni associates with
299
+ * that session, so the browser cannot open other workspace artifacts through
300
+ * it. Editing still requires the user's own `artifacts:publish` grant. Site
301
+ * tool calls are not proxied.
302
+ *
303
+ * The editor's live socket is ticket-authenticated and connects to Opengeni
304
+ * directly; the ticket binds the source session and the API revalidates its
305
+ * authority while connected. `editableLiveUrl` overrides the derived URL.
306
+ * Site HTML streams with backpressure and cancellation, with a 25 MiB
307
+ * actual-byte ceiling (`SESSION_PROXY_SITE_HTML_MAX_BYTES`). Oversize streams
308
+ * fail with `SessionProxySiteHtmlTooLargeError` / `site_html_too_large`;
309
+ * headers cannot be replaced once streaming has begun.
310
+ */
311
+ artifacts?: boolean | { editableLiveUrl?: string | undefined } | undefined;
312
+ /**
313
+ * Read the workspace's Sites without naming a session: list, detail,
314
+ * content and HTML of published artifacts the signed-in user may read, for
315
+ * pages that belong to the workspace rather than to one chat (for example a
316
+ * page several sessions keep up to date, shown with `SiteDetail`). Read-only:
317
+ * publishing, rollback and status changes stay closed. Requests that send
318
+ * `x-opengeni-session-id` keep the session-scoped `artifacts` checks.
319
+ * Explicit opt-in, default false.
320
+ */
321
+ sites?: boolean | undefined;
322
+ /**
323
+ * Chat list for `SessionList` / `OpenGeniChat` (`listSessionPage` only).
324
+ * `"mine"` (default) lists sessions the resolved user created; `"visible"`
325
+ * lists every session Opengeni lets that user read in the workspace (shared
326
+ * chats included); `false` disables listing.
327
+ */
328
+ sessionList?: "mine" | "visible" | false | undefined;
329
+ /** Let the user archive or restore their own chats. Defaults to true. */
330
+ archive?: boolean | undefined;
331
+ /**
332
+ * Let the browser choose model, reasoning effort, and latency per message,
333
+ * draft, or new chat (still limited by the workspace model catalog). When
334
+ * false those choices are removed from messages and draft saves, and refused
335
+ * on create. Saves use the actor's
336
+ * server-owned draft policy (initially the session defaults). Submit must repeat
337
+ * the saved policy unchanged as an integrity fence, not a new selection: the
338
+ * API atomically checks the saved revision/content or replays the original
339
+ * receipt. Hide the composer's model picker to match. Defaults to true.
340
+ * Pass `true` explicitly to also show end users the stock model picker
341
+ * (`SessionConversation`/`OpenGeniChat` hide it unless asked). The picker
342
+ * lists the workspace's model catalog, so the workspace's allowed-model
343
+ * settings decide which models end users see.
344
+ */
345
+ modelSelection?: boolean | undefined;
346
+ /**
347
+ * SSE heartbeat interval. Defaults to 5 seconds, under Bun.serve's default
348
+ * 10-second `idleTimeout`, which otherwise closes a quiet event stream.
349
+ */
350
+ heartbeatMs?: number | undefined;
351
+ };
352
+
353
+ /**
354
+ * Default SSE heartbeat. Bun.serve closes a connection that sends nothing for
355
+ * 10 seconds by default, so a quiet stream (a long tool call, an idle chat)
356
+ * would drop and reconnect every 10 seconds through a Bun or Hono-on-Bun host.
357
+ */
358
+ const SESSION_PROXY_DEFAULT_HEARTBEAT_MS = 5_000;
359
+
360
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
361
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
362
+ const QUEUE_OPERATIONS: ReadonlySet<string> = new Set(["move", "edit", "steer", "delete"]);
363
+ const CREATE_FIELDS: ReadonlySet<string> = new Set([
364
+ "initialMessage",
365
+ "idempotencyKey",
366
+ "resources",
367
+ "startMode",
368
+ ]);
369
+ const MODEL_FIELDS = ["model", "reasoningEffort", "latencyMode"] as const;
370
+ /** The API's single-recording ceiling (25 MiB) plus multipart framing. */
371
+ const MAX_TRANSCRIPTION_BODY_BYTES = 25 * 1024 * 1024 + 64 * 1024;
372
+
373
+ class ProxyRejection extends Error {
374
+ constructor(
375
+ readonly status: number,
376
+ readonly code: string,
377
+ message: string,
378
+ ) {
379
+ super(message);
380
+ }
381
+ }
382
+
383
+ function reject(status: number, code: string, message: string): never {
384
+ throw new ProxyRejection(status, code, message);
385
+ }
386
+
387
+ function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
388
+ return new Response(JSON.stringify(body), {
389
+ status,
390
+ headers: {
391
+ "Content-Type": "application/json; charset=utf-8",
392
+ "Cache-Control": "no-store",
393
+ ...headers,
394
+ },
395
+ });
396
+ }
397
+
398
+ function errorJson(status: number, code: string, message: string): Response {
399
+ return json({ error: { code, message } }, status);
400
+ }
401
+
402
+ function isFacade(target: ProxyClient | ProxyFacade): target is ProxyFacade {
403
+ return !("asUser" in target) && "client" in target;
404
+ }
405
+
406
+ /**
407
+ * Create the packaged session proxy. Returns a web-standard
408
+ * `(Request) => Promise<Response>` for Next.js route handlers, Hono, Bun.serve,
409
+ * Cloudflare Workers, and similar hosts.
410
+ */
411
+ export function createSessionProxyHandler(
412
+ target: ProxyClient | ProxyFacade,
413
+ options: SessionProxyHandlerOptions,
414
+ ): (request: Request) => Promise<Response> {
415
+ const service = isFacade(target) ? target.client : target;
416
+ const defaultSource = isFacade(target) ? target.source : "default";
417
+ // One rejected-key notice per handler (each handler holds one server key).
418
+ const rejectedKeyNotice = { sent: false };
419
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
420
+ const filesEnabled = options.files ?? true;
421
+ const voiceInputEnabled = options.voiceInput ?? true;
422
+ const realtimeVoiceEnabled = options.realtimeVoice ?? true;
423
+ const sandboxFilesEnabled = options.sandboxFiles === true;
424
+ const artifactsEnabled = options.artifacts !== undefined && options.artifacts !== false;
425
+ const sitesEnabled = options.sites === true;
426
+ const editableLiveUrl = artifactsEnabled
427
+ ? liveSocketUrl(
428
+ (typeof options.artifacts === "object" ? options.artifacts.editableLiveUrl : undefined) ??
429
+ service.apiUrl(EDITABLE_ARTIFACT_LIVE_PATH),
430
+ )
431
+ : null;
432
+ const modelSelection = options.modelSelection ?? true;
433
+ const heartbeatMs = options.heartbeatMs ?? SESSION_PROXY_DEFAULT_HEARTBEAT_MS;
434
+ const sessionList = options.sessionList ?? "mine";
435
+ const archiveEnabled = options.archive ?? true;
436
+ const chats = options.chats ?? "private";
437
+ const defaults = chatDefaults(chats);
438
+ const toolServer = options.toolServer ? normalizeToolServer(options.toolServer) : undefined;
439
+ // Whether a session carries this proxy's tool server (attachments are immutable).
440
+ const toolSessions = new Map<string, Promise<string | null>>();
441
+ // Canonical subject per external user, for the "mine" list filter.
442
+ const subjects = new Map<string, Promise<string>>();
443
+ const subjectOf = (client: ProxyClient, key: string): Promise<string> => {
444
+ let subject = subjects.get(key);
445
+ if (!subject) {
446
+ subject = client.getAccessContext().then((access) => access.subjectId);
447
+ subject.catch(() => subjects.delete(key));
448
+ if (subjects.size >= 1_000) subjects.delete(subjects.keys().next().value!);
449
+ subjects.set(key, subject);
450
+ }
451
+ return subject;
452
+ };
453
+
454
+ return async (request) => {
455
+ try {
456
+ const method = request.method;
457
+ if (!PROXY_METHODS.includes(method)) {
458
+ return new Response(null, {
459
+ status: 405,
460
+ headers: { Allow: PROXY_METHODS.join(", ") },
461
+ });
462
+ }
463
+ const resolved = await options.resolve(request);
464
+ if (resolved instanceof Response) return resolved;
465
+ if (typeof resolved?.user !== "string" || !resolved.user) {
466
+ // Never fall back to the organization key's service authority.
467
+ return errorJson(401, "user_required", "Authentication required.");
468
+ }
469
+ const url = new URL(request.url);
470
+ const segments = routeSegments(url.pathname, options.basePath);
471
+ if (!segments) return errorJson(404, "route_not_allowed", "Not found.");
472
+
473
+ if (method !== "GET") {
474
+ const allowed = options.authorizeMutation
475
+ ? await options.authorizeMutation(request)
476
+ : request.headers.get("sec-fetch-site") !== "cross-site";
477
+ if (!allowed) return errorJson(403, "mutation_denied", "Request denied.");
478
+ }
479
+ const source = resolved.source ?? defaultSource;
480
+ let workspaceId: string;
481
+ // A tenant/workspaceId key that resolves to nothing must never fall
482
+ // through to the per-user workspace: that is a host auth bug, not a choice.
483
+ if (
484
+ ("workspaceId" in resolved && !resolved.workspaceId && !resolved.tenant) ||
485
+ ("tenant" in resolved && !resolved.tenant && !resolved.workspaceId) ||
486
+ resolved.workspaceId === "" ||
487
+ resolved.tenant === ""
488
+ ) {
489
+ throw new TypeError(
490
+ "resolve returned an empty tenant or workspaceId. Return a non-empty id, or omit the key for one workspace per user.",
491
+ );
492
+ }
493
+ if (chats === "isolated") {
494
+ if (resolved.workspaceId || !isFacade(target) || !target.workspaceIdFor) {
495
+ throw new TypeError(
496
+ 'chats: "isolated" requires the Opengeni facade and a tenant or user resolution.',
497
+ );
498
+ }
499
+ workspaceId = await target.workspaceIdFor(
500
+ { tenant: resolved.tenant, user: resolved.user, source },
501
+ { isolation: "user" },
502
+ );
503
+ } else if (resolved.workspaceId) {
504
+ workspaceId = resolved.workspaceId;
505
+ } else if (resolved.tenant && isFacade(target)) {
506
+ workspaceId = await target.workspaceId({ tenant: resolved.tenant });
507
+ } else if (
508
+ !("tenant" in resolved) &&
509
+ !("workspaceId" in resolved) &&
510
+ isFacade(target) &&
511
+ target.workspaceIdFor
512
+ ) {
513
+ // A user alone: their own workspace, keyed by the identity source.
514
+ workspaceId = await target.workspaceIdFor(
515
+ { user: resolved.user, source },
516
+ { isolation: "user" },
517
+ );
518
+ } else {
519
+ throw new TypeError(
520
+ "resolve must return a workspaceId (or a tenant or a user alone when given the Opengeni facade).",
521
+ );
522
+ }
523
+ // Visitors upload only when the host opts them in; reads stay under `files`.
524
+ const uploadsEnabled =
525
+ filesEnabled && (resolved.visitor !== true || options.visitorUploads === true);
526
+ const client = service.asUser(resolved.user, { source });
527
+ const context: SessionProxyContext = {
528
+ request,
529
+ workspaceId,
530
+ user: resolved.user,
531
+ source,
532
+ client,
533
+ };
534
+
535
+ const call = { signal: request.signal };
536
+ const toolToken = async () =>
537
+ await mintToolToken({
538
+ audience: toolServer!.url,
539
+ user: resolved.user,
540
+ tenant: resolved.tenant,
541
+ workspaceId,
542
+ source,
543
+ secret: toolServer!.secret,
544
+ ttlSeconds: toolServer!.ttlSeconds,
545
+ });
546
+ /** The creator's subject when the session carries this tool server, else null. */
547
+ const toolServerOwner = (sessionId: string): Promise<string | null> => {
548
+ const key = `${workspaceId}\u0000${sessionId}`;
549
+ let attached = toolSessions.get(key);
550
+ if (!attached) {
551
+ attached = client
552
+ .getSession(workspaceId, sessionId, call)
553
+ .then((session) =>
554
+ (session.mcpServers ?? []).some(
555
+ (server) => server.id === toolServer!.id && server.url === toolServer!.url,
556
+ )
557
+ ? (session.createdBy?.subjectId ?? null)
558
+ : null,
559
+ );
560
+ attached.catch(() => toolSessions.delete(key));
561
+ if (toolSessions.size >= 1_000) toolSessions.delete(toolSessions.keys().next().value!);
562
+ toolSessions.set(key, attached);
563
+ }
564
+ return attached;
565
+ };
566
+ const messageExtras = async (
567
+ input: SessionProxyMessageInput,
568
+ ): Promise<SessionProxyMessageExtras | Response | undefined> => {
569
+ const extras = options.beforeForwardMessage
570
+ ? await options.beforeForwardMessage(input, context)
571
+ : undefined;
572
+ if (extras instanceof Response || !toolServer || !input.sessionId) return extras;
573
+ const updates = extras?.mcpCredentialUpdates ?? [];
574
+ // A host-supplied rotation for the same id wins; sessions created
575
+ // without this tool server (or for an older URL) are left alone.
576
+ if (updates.some((update) => update.id === toolServer.id)) return extras;
577
+ // Only the chat's creator refreshes: tools keep acting as that user.
578
+ const owner = await toolServerOwner(input.sessionId);
579
+ if (!owner || owner !== (await subjectOf(client, `${source}\u0000${resolved.user}`))) {
580
+ return extras;
581
+ }
582
+ return {
583
+ ...extras,
584
+ mcpCredentialUpdates: [
585
+ ...updates,
586
+ {
587
+ id: toolServer.id,
588
+ headers: { Authorization: `Bearer ${await toolToken()}` },
589
+ },
590
+ ],
591
+ };
592
+ };
593
+ /**
594
+ * Best-effort standalone rotation before a voice call. Opengeni refuses
595
+ * it while a turn is running; that turn's message already refreshed them.
596
+ */
597
+ const refreshRealtimeCredentials = async (
598
+ sessionId: string,
599
+ updates: SessionMcpCredentialUpdateInput[],
600
+ ): Promise<void> => {
601
+ if (updates.length === 0) return;
602
+ try {
603
+ const servers = (await client.getSession(workspaceId, sessionId, call)).mcpServers ?? [];
604
+ const rotations = updates.flatMap((update) => {
605
+ const server = servers.find((candidate) => candidate.id === update.id);
606
+ return server
607
+ ? [
608
+ {
609
+ id: update.id,
610
+ expectedCredentialVersion: server.credentialVersion,
611
+ expectedServerUrl: server.url,
612
+ headers: update.headers,
613
+ },
614
+ ]
615
+ : [];
616
+ });
617
+ if (rotations.length === 0) return;
618
+ await client.rotateSessionMcpCredentials(workspaceId, sessionId, {
619
+ operationKey: crypto.randomUUID(),
620
+ updates: rotations,
621
+ });
622
+ } catch (error) {
623
+ if (!(error instanceof OpenGeniApiError)) throw error;
624
+ }
625
+ };
626
+ /** Browser input sanitized, then server-owned extras merged in. */
627
+ const forwardMessage = async (
628
+ value: unknown,
629
+ input: SessionProxyMessageInput,
630
+ ): Promise<Record<string, unknown> | Response> => {
631
+ // Submit repeats the saved policy as a mandatory integrity fence. Never
632
+ // replace it with a newer draft's policy: outcome-unknown retries must
633
+ // retain the original receipt hash. The API rejects any new selection.
634
+ const message = sanitizeMessage(value, modelSelection || input.delivery === "submit");
635
+ const extras = await messageExtras(input);
636
+ if (extras instanceof Response) return extras;
637
+ const modelContext = joinContext(
638
+ extras?.modelContext,
639
+ typeof message.modelContext === "string" ? message.modelContext : undefined,
640
+ );
641
+ return {
642
+ ...message,
643
+ ...(modelContext ? { modelContext } : {}),
644
+ ...(extras?.mcpCredentialUpdates?.length
645
+ ? { mcpCredentialUpdates: extras.mcpCredentialUpdates }
646
+ : {}),
647
+ };
648
+ };
649
+ /** Responses reuse send hooks unchanged; only credentials are added. */
650
+ const forwardResponse = async (
651
+ value: unknown,
652
+ input: SessionProxyMessageInput,
653
+ ): Promise<Record<string, unknown> | Response> => {
654
+ const payload = browserPayload(value);
655
+ const extras = await messageExtras(input);
656
+ if (extras instanceof Response) return extras;
657
+ return {
658
+ ...payload,
659
+ ...(extras?.mcpCredentialUpdates?.length
660
+ ? { mcpCredentialUpdates: extras.mcpCredentialUpdates }
661
+ : {}),
662
+ };
663
+ };
664
+ // Unknown additive query parameters on allowlisted reads pass through, so a
665
+ // newer browser SDK keeps working; the route and method allowlist is exact.
666
+ const query = Object.fromEntries(url.searchParams);
667
+ const read = async (path: string) =>
668
+ json(await client.requestJson("GET", path, undefined, query, call));
669
+ const [root, ...rest] = segments;
670
+ if (root === "config") {
671
+ if (rest.length === 1 && rest[0] === "client" && method === "GET") {
672
+ // The browser speaks this proxy's contract, not the upstream deployment's:
673
+ // report the server SDK's revision so an Opengeni deploy never makes the
674
+ // embedded page look stale (and never triggers a host reload).
675
+ const config = await client.requestJson<Record<string, unknown>>(
676
+ "GET",
677
+ "/v1/config/client",
678
+ undefined,
679
+ { ...query, workspaceId },
680
+ call,
681
+ );
682
+ let artifacts: Awaited<ReturnType<typeof artifactViewerCapability>> | null = null;
683
+ if (editableLiveUrl) {
684
+ try {
685
+ // Successful effective-grant resolution negotiates the viewer's
686
+ // narrow API support. Older APIs do not have this endpoint: that
687
+ // 404 disables only artifacts, not ordinary conversation config.
688
+ artifacts = {
689
+ editableLiveUrl,
690
+ cachePartition: await cachePartition(
691
+ await getSessionProxyWorkspaceGrant(client, workspaceId, call),
692
+ workspaceId,
693
+ source,
694
+ ),
695
+ };
696
+ } catch (error) {
697
+ if (!(error instanceof OpenGeniApiError) || error.status !== 404) throw error;
698
+ }
699
+ }
700
+ // Upstream proxy capabilities never authorize this host's routes.
701
+ // Task usage limits are set by this host's server, never through the
702
+ // browser boundary, so the producer capability is not forwarded.
703
+ const {
704
+ artifacts: _upstreamArtifacts,
705
+ sessionUsageLimits: _upstreamUsageLimits,
706
+ ...conversationConfig
707
+ } = config;
708
+ const upstreamVoice = config.voiceInput;
709
+ if (upstreamVoice && typeof upstreamVoice === "object") {
710
+ // Only one-shot recordings are forwarded, never resumable chunk uploads.
711
+ const { resumable: _resumable, ...voice } = upstreamVoice as Record<string, unknown>;
712
+ conversationConfig.voiceInput = voiceInputEnabled
713
+ ? voice
714
+ : { ...voice, available: false };
715
+ }
716
+ const upstreamUploads = config.fileUploads;
717
+ if (!uploadsEnabled) {
718
+ // The browser cannot upload through this proxy: stock UIs hide the attach control.
719
+ conversationConfig.fileUploads = {
720
+ ...(upstreamUploads && typeof upstreamUploads === "object" ? upstreamUploads : {}),
721
+ enabled: false,
722
+ };
723
+ }
724
+ return json({
725
+ ...conversationConfig,
726
+ apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
727
+ // The resolved workspace: a browser given only the proxy's baseUrl
728
+ // reads it here instead of knowing any Opengeni id.
729
+ workspaceId,
730
+ sandboxFiles: sandboxFilesEnabled,
731
+ // Stock UIs show Site previews and the artifact viewer only when
732
+ // this proxy serves them; `false` keeps them from failing on click.
733
+ artifacts: artifacts ?? false,
734
+ // Whether the stock "New chat" and "Archive" actions can succeed.
735
+ sessionCreation: options.createSession !== undefined,
736
+ archive: archiveEnabled,
737
+ // Explicit true also tells stock UIs to show the voice button, which
738
+ // is otherwise opt-in on the embedded conversation.
739
+ ...(realtimeVoiceEnabled
740
+ ? options.realtimeVoice === true
741
+ ? { realtimeVoice: true }
742
+ : {}
743
+ : { realtimeVoice: false }),
744
+ // Explicit true also tells stock UIs to offer end users the model picker.
745
+ ...(modelSelection
746
+ ? options.modelSelection === true
747
+ ? { modelSelection: true }
748
+ : {}
749
+ : { modelSelection: false }),
750
+ });
751
+ }
752
+ return errorJson(404, "route_not_allowed", "Not found.");
753
+ }
754
+ if (root !== "workspaces" || rest.length < 1) {
755
+ return errorJson(404, "route_not_allowed", "Not found.");
756
+ }
757
+ const [pathWorkspaceId, area, ...tail] = rest as [string, string | undefined, ...string[]];
758
+ if (pathWorkspaceId !== workspaceId) {
759
+ return errorJson(403, "workspace_not_allowed", "This workspace is not available.");
760
+ }
761
+ const base = `/v1/workspaces/${workspaceId}`;
762
+
763
+ // Only the resolved user's own usage. No full roster, allowance config,
764
+ // member selectors, or controls can pass through this browser boundary.
765
+ if (area === "usage" && tail.length === 1 && tail[0] === "me" && method === "GET") {
766
+ if (Object.keys(query).some((key) => key !== "period")) {
767
+ return errorJson(400, "invalid_usage_query", "Only period is accepted for own usage.");
768
+ }
769
+ return await read(`${base}/usage/me`);
770
+ }
771
+
772
+ // Workspace reads and the live control stream used by <OpenGeniProvider>.
773
+ if (area === undefined && method === "GET") return await read(base);
774
+ if (area === "model-catalog" && tail.length === 0 && method === "GET") {
775
+ return await read(`${base}/model-catalog`);
776
+ }
777
+ if (area === "realtime-model-catalog" && tail.length === 0 && method === "GET") {
778
+ if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
779
+ return await read(`${base}/realtime-model-catalog`);
780
+ }
781
+ if (area === "live-events" && tail.length === 1 && tail[0] === "stream" && method === "GET") {
782
+ // Surface authorization failures as HTTP status before streaming.
783
+ await client.requestJson("GET", base, undefined, {}, call);
784
+ return workspaceLiveStream(client, workspaceId, url.searchParams, request, heartbeatMs);
785
+ }
786
+ if (area === "inference-control" && tail.length === 0 && method === "POST") {
787
+ // The conversation's paused-state UI only offers workspace Resume.
788
+ const body = await readJsonBody(request, maxBodyBytes);
789
+ if (body?.action !== "resume") {
790
+ reject(403, "control_not_allowed", "Only workspace resume is available.");
791
+ }
792
+ return json(await client.requestJson("POST", `${base}/inference-control`, body));
793
+ }
794
+
795
+ if (area === "files" && filesEnabled && method === "POST") {
796
+ if (tail[0] === "uploads" && !uploadsEnabled) {
797
+ return errorJson(404, "route_not_allowed", "Not found.");
798
+ }
799
+ if (tail.length === 1 && tail[0] === "uploads") {
800
+ const body = await readJsonBody(request, maxBodyBytes);
801
+ return json(
802
+ await client.requestJson("POST", `${base}/files/uploads`, pick(body, UPLOAD_FIELDS)),
803
+ );
804
+ }
805
+ if (tail.length === 3 && tail[0] === "uploads" && tail[2] === "complete") {
806
+ return json(
807
+ await client.requestJson("POST", `${base}/files/uploads/${tail[1]}/complete`),
808
+ );
809
+ }
810
+ if (tail.length === 2 && tail[1] === "download-url") {
811
+ return json(
812
+ await client.requestJson(
813
+ "POST",
814
+ `${base}/files/${tail[0]}/download-url`,
815
+ undefined,
816
+ query,
817
+ call,
818
+ ),
819
+ );
820
+ }
821
+ return errorJson(404, "route_not_allowed", "Not found.");
822
+ }
823
+
824
+ if (area === "artifacts") {
825
+ // Retained media a session produced: only an artifact Opengeni proves
826
+ // that session produced, never an arbitrary workspace artifact.
827
+ const [artifactId, ...artifactOp] = tail as [string | undefined, ...string[]];
828
+ const route = `${method} ${artifactOp.join("/")}`;
829
+ if (
830
+ !filesEnabled ||
831
+ !artifactId ||
832
+ !SEGMENT.test(artifactId) ||
833
+ (route !== "GET content" && route !== "POST playback-source")
834
+ ) {
835
+ return errorJson(404, "route_not_allowed", "Not found.");
836
+ }
837
+ const sessionId = request.headers.get(SESSION_SCOPE_HEADER)?.trim() ?? "";
838
+ if (!SEGMENT.test(sessionId)) {
839
+ reject(400, "session_scope_required", "Artifact reads must name their session.");
840
+ }
841
+ if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
842
+ return errorJson(404, "session_not_found", "Session not found.");
843
+ }
844
+ await getSessionProxyArtifactAssociation(
845
+ client,
846
+ workspaceId,
847
+ sessionId,
848
+ "retained",
849
+ artifactId,
850
+ call,
851
+ );
852
+ if (route === "POST playback-source") {
853
+ return json(
854
+ await client.requestJson(
855
+ "POST",
856
+ `${base}/artifacts/${artifactId}/playback-source`,
857
+ undefined,
858
+ {},
859
+ call,
860
+ ),
861
+ );
862
+ }
863
+ return retainedContent(
864
+ await client.getRetainedArtifactContent(workspaceId, artifactId, {
865
+ ...retainedRange(request),
866
+ signal: request.signal,
867
+ }),
868
+ );
869
+ }
870
+
871
+ if (area === "transcriptions" && tail.length === 0 && method === "POST") {
872
+ if (!voiceInputEnabled) return errorJson(404, "route_not_allowed", "Not found.");
873
+ return json(await client.transcribeAudio(workspaceId, await transcriptionInput(request)));
874
+ }
875
+
876
+ if (area === "editable-artifacts" || area === "published-artifacts") {
877
+ if (
878
+ area === "published-artifacts" &&
879
+ sitesEnabled &&
880
+ !request.headers.get(SESSION_SCOPE_HEADER)
881
+ ) {
882
+ // Workspace Sites under Opengeni's own read authorization for this user.
883
+ const [siteId, ...siteOp] = tail as [string | undefined, ...string[]];
884
+ const op = siteOp.join("/");
885
+ if (method !== "GET") return errorJson(404, "route_not_allowed", "Not found.");
886
+ if (siteId === undefined) {
887
+ return json(
888
+ await client.requestJson(
889
+ "GET",
890
+ `${base}/published-artifacts`,
891
+ undefined,
892
+ pick(query, SITE_LIST_FIELDS) as Record<string, string>,
893
+ call,
894
+ ),
895
+ );
896
+ }
897
+ if (!SEGMENT.test(siteId) || (op !== "" && op !== "content" && op !== "html")) {
898
+ return errorJson(404, "route_not_allowed", "Not found.");
899
+ }
900
+ const versionId = query.versionId;
901
+ if (
902
+ versionId !== undefined &&
903
+ (typeof versionId !== "string" || !SEGMENT.test(versionId))
904
+ ) {
905
+ reject(400, "version_invalid", "versionId is invalid.");
906
+ }
907
+ if (op === "html") {
908
+ if (typeof versionId !== "string") {
909
+ reject(400, "version_required", "versionId is required.");
910
+ }
911
+ const html = await downloadSessionProxySiteHtml(client, workspaceId, siteId, {
912
+ versionId: versionId as string,
913
+ signal: request.signal,
914
+ });
915
+ return new Response(boundedSiteHtml(html.body, request.signal), {
916
+ headers: SITE_HTML_HEADERS,
917
+ });
918
+ }
919
+ const item = `${base}/published-artifacts/${siteId}`;
920
+ return json(
921
+ await client.requestJson(
922
+ "GET",
923
+ op === "content" ? `${item}/content` : item,
924
+ undefined,
925
+ op === "content" && typeof versionId === "string" ? { versionId } : {},
926
+ call,
927
+ ),
928
+ );
929
+ }
930
+ if (!artifactsEnabled) return errorJson(404, "route_not_allowed", "Not found.");
931
+ const [artifactId, ...artifactOp] = tail as [string | undefined, ...string[]];
932
+ const route = `${method} ${artifactOp.join("/")}`;
933
+ const editable = area === "editable-artifacts";
934
+ const allowed = editable
935
+ ? route === "GET " || route === "POST live-ticket"
936
+ : route === "GET " || route === "GET html";
937
+ if (!artifactId || !SEGMENT.test(artifactId) || !allowed) {
938
+ return errorJson(404, "route_not_allowed", "Not found.");
939
+ }
940
+ const sessionId = request.headers.get(SESSION_SCOPE_HEADER)?.trim() ?? "";
941
+ if (!SEGMENT.test(sessionId)) {
942
+ reject(400, "session_scope_required", "Artifact reads must name their session.");
943
+ }
944
+ if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
945
+ return errorJson(404, "session_not_found", "Session not found.");
946
+ }
947
+ // No positive authorization cache: revocation must take effect even
948
+ // between detail, HTML and ticket requests for the same artifact.
949
+ await getSessionProxyArtifactAssociation(
950
+ client,
951
+ workspaceId,
952
+ sessionId,
953
+ editable ? "editable" : "site",
954
+ artifactId,
955
+ call,
956
+ );
957
+ const item = `${base}/${area}/${artifactId}`;
958
+ if (route === "GET ") {
959
+ const replicaId = editable ? query.replicaId : undefined;
960
+ return json(
961
+ await client.requestJson(
962
+ "GET",
963
+ item,
964
+ undefined,
965
+ typeof replicaId === "string" ? { replicaId } : {},
966
+ call,
967
+ ),
968
+ );
969
+ }
970
+ if (route === "POST live-ticket") {
971
+ const body = await readJsonBody(request, maxBodyBytes);
972
+ return json(
973
+ await client.requestJson("POST", `${item}/live-ticket`, {
974
+ ...pick(body, TICKET_FIELDS),
975
+ sourceSessionId: sessionId,
976
+ }),
977
+ 201,
978
+ );
979
+ }
980
+ const versionId = query.versionId;
981
+ if (typeof versionId !== "string" || !SEGMENT.test(versionId)) {
982
+ reject(400, "version_required", "versionId is required.");
983
+ }
984
+ const html = await downloadSessionProxySiteHtml(client, workspaceId, artifactId, {
985
+ versionId,
986
+ signal: request.signal,
987
+ });
988
+ return new Response(boundedSiteHtml(html.body, request.signal), {
989
+ headers: SITE_HTML_HEADERS,
990
+ });
991
+ }
992
+
993
+ if (area !== "sessions") return errorJson(404, "route_not_allowed", "Not found.");
994
+
995
+ if (tail.length === 0 && method === "GET") {
996
+ if (!sessionList) return errorJson(404, "route_not_allowed", "Not found.");
997
+ if (query.view !== "page") {
998
+ reject(400, "page_view_required", "List sessions with listSessionPage.");
999
+ }
1000
+ const listQuery: Record<string, string> = { ...query };
1001
+ if (sessionList === "mine") {
1002
+ // Server-enforced creator filter; the browser cannot widen it.
1003
+ listQuery.createdByKind = "subject";
1004
+ listQuery.createdBySubjectId = await subjectOf(client, `${source}\u0000${resolved.user}`);
1005
+ }
1006
+ const page = await client.requestJson<Record<string, unknown>>(
1007
+ "GET",
1008
+ `${base}/sessions`,
1009
+ undefined,
1010
+ listQuery,
1011
+ call,
1012
+ );
1013
+ // Pins are a separate personal projection this proxy does not manage.
1014
+ return json(sessionList === "mine" ? { ...page, pinned: [] } : page);
1015
+ }
1016
+ if (tail.length === 0) {
1017
+ if (method !== "POST" || !options.createSession) {
1018
+ return errorJson(404, "route_not_allowed", "Not found.");
1019
+ }
1020
+ const input = createInput(await readJsonBody(request, maxBodyBytes), modelSelection);
1021
+ if (input.startMode === "realtime" && !realtimeVoiceEnabled) {
1022
+ return errorJson(404, "route_not_allowed", "Not found.");
1023
+ }
1024
+ const hooked = await options.createSession(input, context);
1025
+ if (hooked instanceof Response) return hooked;
1026
+ const created = withBrowserCreateChoices(
1027
+ toolServer ? withToolServer(hooked, toolServer, await toolToken()) : hooked,
1028
+ input,
1029
+ );
1030
+ const extras = await messageExtras({
1031
+ delivery: "create",
1032
+ ...(input.startMode ? { startMode: input.startMode } : {}),
1033
+ });
1034
+ if (extras instanceof Response) return extras;
1035
+ const modelContext = joinContext(extras?.modelContext, created.modelContext);
1036
+ return json(
1037
+ await client.createSession(workspaceId, {
1038
+ ...created,
1039
+ visibility: created.visibility ?? defaults.visibility,
1040
+ agentAccess: created.agentAccess ?? defaults.agentAccess,
1041
+ memoryScope: created.memoryScope ?? defaults.memoryScope,
1042
+ ...(modelContext ? { modelContext } : {}),
1043
+ }),
1044
+ );
1045
+ }
1046
+
1047
+ const [sessionId, ...op] = tail as [string, ...string[]];
1048
+ if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
1049
+ return errorJson(404, "session_not_found", "Session not found.");
1050
+ }
1051
+ const session = `${base}/sessions/${sessionId}`;
1052
+ const route = `${method} ${op.join("/")}`;
1053
+ const body = method === "GET" ? undefined : await readJsonBody(request, maxBodyBytes, true);
1054
+ const sanitize = (value: unknown) => sanitizeMessage(value, modelSelection);
1055
+ const saveDraftPolicy = async (message: Record<string, unknown>) => {
1056
+ if (modelSelection) return message;
1057
+ // Save requires explicit policy fields. Use this actor's durable draft,
1058
+ // not browser choices. The API's expectedRevision fence rejects races.
1059
+ const draft = await client.getComposerDraft(workspaceId, sessionId, call);
1060
+ return {
1061
+ ...message,
1062
+ model: draft.model,
1063
+ reasoningEffort: draft.reasoningEffort,
1064
+ latencyMode: draft.latencyMode,
1065
+ };
1066
+ };
1067
+ const forward = async (path: string, payload: Record<string, unknown> | Response) =>
1068
+ payload instanceof Response
1069
+ ? payload
1070
+ : json(await client.requestJson("POST", path, payload));
1071
+
1072
+ if (op[0] === "realtime") {
1073
+ if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
1074
+ const realtime = realtimeRoute(method, op.slice(1));
1075
+ if (!realtime) return errorJson(404, "route_not_allowed", "Not found.");
1076
+ if (!body) reject(400, "invalid_body", "A JSON object body is required.");
1077
+ const path = `${session}/${op.join("/")}`;
1078
+ if (realtime === "begin") {
1079
+ // The host may refuse a call; MCP credentials (including the tool
1080
+ // server's per-user token) are refreshed before voice can delegate.
1081
+ const extras = await messageExtras({
1082
+ sessionId,
1083
+ delivery: "realtime",
1084
+ });
1085
+ if (extras instanceof Response) return extras;
1086
+ await refreshRealtimeCredentials(sessionId, extras?.mcpCredentialUpdates ?? []);
1087
+ }
1088
+ let payload: Record<string, unknown> = body;
1089
+ if (realtime === "sync" && options.beforeForwardMessage && hasRealtimeMessages(body)) {
1090
+ const extras = await options.beforeForwardMessage(
1091
+ { sessionId, delivery: "realtime" },
1092
+ context,
1093
+ );
1094
+ if (extras instanceof Response) return extras;
1095
+ payload = withRealtimeContext(body, extras?.modelContext);
1096
+ }
1097
+ return json(await client.requestJson(method, path, payload));
1098
+ }
1099
+
1100
+ switch (route) {
1101
+ case "GET ":
1102
+ return await read(session);
1103
+ case "PUT archive": {
1104
+ if (!archiveEnabled) return errorJson(404, "route_not_allowed", "Not found.");
1105
+ const archived = body?.archived;
1106
+ const expectedVersion = body?.expectedVersion;
1107
+ if (typeof archived !== "boolean") reject(400, "invalid_body", "archived is required.");
1108
+ return json(
1109
+ await client.requestJson("PUT", `${session}/archive`, {
1110
+ archived,
1111
+ ...(typeof expectedVersion === "number" ? { expectedVersion } : {}),
1112
+ }),
1113
+ );
1114
+ }
1115
+ case "PATCH ": {
1116
+ const title = body?.title;
1117
+ if (typeof title !== "string" || Object.keys(body ?? {}).length !== 1) {
1118
+ reject(400, "invalid_body", "Only { title } may be updated.");
1119
+ }
1120
+ return json(await client.requestJson("PATCH", session, { title }));
1121
+ }
1122
+ case "GET events":
1123
+ return await listEvents(client, `${session}/events`, query, call);
1124
+ case "GET events/stream":
1125
+ // Surface authorization failures as HTTP status before streaming.
1126
+ await client.getSession(workspaceId, sessionId, call);
1127
+ return proxySessionEventStream(client, workspaceId, sessionId, {
1128
+ after: request,
1129
+ signal: request.signal,
1130
+ heartbeatMs,
1131
+ });
1132
+ case "POST events": {
1133
+ const event = clientEvent(body);
1134
+ const payload =
1135
+ event.type === "user.message"
1136
+ ? await forwardMessage(event.payload, {
1137
+ sessionId,
1138
+ delivery: "send",
1139
+ })
1140
+ : await forwardResponse(event.payload, {
1141
+ sessionId,
1142
+ delivery: "send",
1143
+ });
1144
+ return await forward(
1145
+ `${session}/events`,
1146
+ payload instanceof Response ? payload : { ...event, payload },
1147
+ );
1148
+ }
1149
+ case "POST steer":
1150
+ return await forward(
1151
+ `${session}/steer`,
1152
+ await forwardMessage(body, { sessionId, delivery: "steer" }),
1153
+ );
1154
+ case "GET queue":
1155
+ return await read(`${session}/queue`);
1156
+ case "GET composer-draft":
1157
+ return await read(`${session}/composer-draft`);
1158
+ case "PUT composer-draft":
1159
+ return json(
1160
+ await client.requestJson(
1161
+ "PUT",
1162
+ `${session}/composer-draft`,
1163
+ await saveDraftPolicy(sanitize(body)),
1164
+ ),
1165
+ );
1166
+ case "POST composer-draft/submit": {
1167
+ const message = await forwardMessage(body, {
1168
+ sessionId,
1169
+ delivery: "submit",
1170
+ });
1171
+ return await forward(`${session}/composer-draft/submit`, message);
1172
+ }
1173
+ case "POST control": {
1174
+ if (body?.action !== "pause" && body?.action !== "resume") {
1175
+ reject(403, "control_not_allowed", "Only pause and resume are available.");
1176
+ }
1177
+ return json(await client.requestJson("POST", `${session}/control`, body));
1178
+ }
1179
+ case "GET human-input-requests":
1180
+ return await read(`${session}/human-input-requests`);
1181
+ case "GET goal":
1182
+ return await read(`${session}/goal`);
1183
+ case "PATCH goal": {
1184
+ // Pause and resume only: the objective, limits and completion stay
1185
+ // with the agent and the product's own server. A browser rationale is
1186
+ // dropped, so end-user text never reaches the goal record.
1187
+ const status = body?.status;
1188
+ if (
1189
+ (status !== "paused" && status !== "active") ||
1190
+ Object.keys(body ?? {}).some((key) => key !== "status" && key !== "rationale")
1191
+ ) {
1192
+ reject(
1193
+ 403,
1194
+ "goal_update_not_allowed",
1195
+ "Only { status: paused | active } is available.",
1196
+ );
1197
+ }
1198
+ return json(await client.requestJson("PATCH", `${session}/goal`, { status }));
1199
+ }
1200
+ case "DELETE goal":
1201
+ await client.deleteGoal(workspaceId, sessionId);
1202
+ return new Response(null, { status: 204 });
1203
+ case "POST fs/read":
1204
+ case "POST fs/read-workspace": {
1205
+ if (!sandboxFilesEnabled) return errorJson(404, "route_not_allowed", "Not found.");
1206
+ // A distinct route fails closed on older APIs that would strip the
1207
+ // additive workspaceOnly field and otherwise permit machine-wide reads.
1208
+ return json(
1209
+ await client.requestJson("POST", `${session}/fs/read-workspace`, sandboxRead(body)),
1210
+ );
1211
+ }
1212
+ }
1213
+ if (op[0] === "artifacts" && (op.length === 2 || op[2] === "content") && method === "GET") {
1214
+ // Retained screenshots: the API matches the artifact to this session;
1215
+ // the proxy first proves the user can read the session itself.
1216
+ if (!filesEnabled || op.length > 3) {
1217
+ return errorJson(404, "route_not_allowed", "Not found.");
1218
+ }
1219
+ await client.getSession(workspaceId, sessionId, call);
1220
+ if (op.length === 2) return await read(`${session}/artifacts/${op[1]}`);
1221
+ return retainedContent(
1222
+ await client.getSessionRetainedArtifactContent(workspaceId, sessionId, op[1]!, {
1223
+ ...retainedRange(request),
1224
+ signal: request.signal,
1225
+ }),
1226
+ );
1227
+ }
1228
+ if (op.length === 2 && op[0] === "human-input-requests" && method === "GET") {
1229
+ return await read(`${session}/human-input-requests/${op[1]}`);
1230
+ }
1231
+ if (
1232
+ op.length === 3 &&
1233
+ op[0] === "queue" &&
1234
+ QUEUE_OPERATIONS.has(op[2]!) &&
1235
+ method === "POST"
1236
+ ) {
1237
+ return json(await client.requestJson("POST", `${session}/queue/${op[1]}/${op[2]}`, body));
1238
+ }
1239
+ return errorJson(404, "route_not_allowed", "Not found.");
1240
+ } catch (error) {
1241
+ return errorResponse(error, rejectedKeyNotice);
1242
+ }
1243
+ };
1244
+ }
1245
+
1246
+ /**
1247
+ * Methods any forwarded route uses. Module-level so the public API inventory
1248
+ * attributes a verb only to the routes that actually forward it.
1249
+ */
1250
+ const PROXY_METHODS: readonly string[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
1251
+
1252
+ const UPLOAD_FIELDS = ["scope", "filename", "contentType", "sizeBytes", "sha256"] as const;
1253
+
1254
+ type RealtimeRoute = "begin" | "connect" | "heartbeat" | "end" | "activate" | "sync";
1255
+ const REALTIME_CONNECT_ROUTES: ReadonlySet<string> = new Set(["webrtc", "gateway", "supergrok"]);
1256
+ /** Ledger entries that become (or join) messages and so accept `modelContext`. */
1257
+ const REALTIME_MESSAGE_KINDS: ReadonlySet<string> = new Set([
1258
+ "delegation_call",
1259
+ "user_transcript",
1260
+ "assistant_transcript",
1261
+ ]);
1262
+
1263
+ /**
1264
+ * The live voice routes a browser call uses, after `realtime/`: begin, provider
1265
+ * connect, heartbeat, end, connection activation, and transcript sync. Bodies
1266
+ * pass to Opengeni, which validates them and binds the call to the acting user.
1267
+ */
1268
+ function realtimeRoute(method: string, rest: string[]): RealtimeRoute | null {
1269
+ if (rest.length === 0) return method === "POST" ? "begin" : null;
1270
+ if (rest.length === 1) {
1271
+ if (REALTIME_CONNECT_ROUTES.has(rest[0]!)) return method === "POST" ? "connect" : null;
1272
+ return method === "DELETE" ? "end" : null;
1273
+ }
1274
+ if (rest.length === 2 && rest[1] === "heartbeat") return method === "PATCH" ? "heartbeat" : null;
1275
+ if (rest.length === 2 && rest[1] === "sync") return method === "POST" ? "sync" : null;
1276
+ if (rest.length === 4 && rest[1] === "connections" && rest[3] === "activate") {
1277
+ return method === "POST" ? "activate" : null;
1278
+ }
1279
+ return null;
1280
+ }
1281
+
1282
+ function hasRealtimeMessages(body: Record<string, unknown>): boolean {
1283
+ return (
1284
+ Array.isArray(body.entries) &&
1285
+ body.entries.some(
1286
+ (entry) =>
1287
+ entry !== null &&
1288
+ typeof entry === "object" &&
1289
+ REALTIME_MESSAGE_KINDS.has((entry as { kind?: unknown }).kind as string),
1290
+ )
1291
+ );
1292
+ }
1293
+
1294
+ /** Server context goes before the browser's on each message-bearing entry. */
1295
+ function withRealtimeContext(
1296
+ body: Record<string, unknown>,
1297
+ serverContext: string | undefined,
1298
+ ): Record<string, unknown> {
1299
+ if (!serverContext?.trim() || !Array.isArray(body.entries)) return body;
1300
+ return {
1301
+ ...body,
1302
+ entries: body.entries.map((entry: unknown) => {
1303
+ if (entry === null || typeof entry !== "object") return entry;
1304
+ const record = entry as Record<string, unknown>;
1305
+ if (!REALTIME_MESSAGE_KINDS.has(record.kind as string)) return entry;
1306
+ const modelContext = joinContext(
1307
+ serverContext,
1308
+ typeof record.modelContext === "string" ? record.modelContext : undefined,
1309
+ );
1310
+ return modelContext ? { ...record, modelContext } : entry;
1311
+ }),
1312
+ };
1313
+ }
1314
+
1315
+ type NormalizedToolServer = SessionProxyToolServer & {
1316
+ url: string;
1317
+ id: string;
1318
+ secret: string;
1319
+ };
1320
+
1321
+ function normalizeToolServer(toolServer: SessionProxyToolServer): NormalizedToolServer {
1322
+ const configured =
1323
+ toolServer.url ??
1324
+ (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
1325
+ ?.OPENGENI_TOOL_SERVER_URL;
1326
+ let url: URL;
1327
+ try {
1328
+ url = new URL(configured ?? "");
1329
+ } catch {
1330
+ throw new TypeError(
1331
+ "toolServer.url (or OPENGENI_TOOL_SERVER_URL) must be an absolute https:// URL.",
1332
+ );
1333
+ }
1334
+ if (url.protocol !== "https:") {
1335
+ // Opengeni calls the tool server from its own network; use a tunnel locally.
1336
+ throw new TypeError("toolServer.url must be an absolute https:// URL.");
1337
+ }
1338
+ const id = toolServer.id ?? "app";
1339
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) {
1340
+ throw new TypeError("toolServer.id may contain only letters, digits, _ and -.");
1341
+ }
1342
+ const ask = toolServer.approvals?.ask;
1343
+ if (ask !== undefined && ask !== true && !(Array.isArray(ask) && ask.every(isToolName))) {
1344
+ throw new TypeError("toolServer.approvals.ask must be true or a list of tool names.");
1345
+ }
1346
+ // Resolve now so a missing secret fails at startup, not on the first chat.
1347
+ return {
1348
+ ...toolServer,
1349
+ url: configured!,
1350
+ id,
1351
+ secret: resolveToolTokenSecret(toolServer.secret),
1352
+ };
1353
+ }
1354
+
1355
+ function isToolName(value: unknown): value is string {
1356
+ return typeof value === "string" && value.length > 0;
1357
+ }
1358
+
1359
+ /** Attach the product tool server with a fresh per-user token. */
1360
+ function withToolServer(
1361
+ created: CreateSessionRequest,
1362
+ toolServer: NormalizedToolServer,
1363
+ token: string,
1364
+ ): CreateSessionRequest {
1365
+ const servers = created.mcpServers ?? [];
1366
+ if (servers.some((server) => server.id === toolServer.id)) {
1367
+ throw new TypeError(
1368
+ `createSession already attaches an MCP server with the toolServer id "${toolServer.id}".`,
1369
+ );
1370
+ }
1371
+ const ask = toolServer.approvals?.ask;
1372
+ const tools = created.tools;
1373
+ return {
1374
+ ...created,
1375
+ mcpServers: [
1376
+ ...servers,
1377
+ {
1378
+ id: toolServer.id,
1379
+ ...(toolServer.name ? { name: toolServer.name } : {}),
1380
+ url: toolServer.url,
1381
+ headers: { Authorization: `Bearer ${token}` },
1382
+ ...(ask === true ? { requireApproval: true } : ask?.length ? { requireApproval: ask } : {}),
1383
+ },
1384
+ ],
1385
+ // Omitted tools keep workspace defaults; the attachment alone selects the
1386
+ // server. An explicit allow-list gets the server's tools on the first request.
1387
+ ...(Array.isArray(tools) && !tools.some((tool) => tool.id === toolServer.id)
1388
+ ? {
1389
+ tools: [...tools, { kind: "mcp" as const, id: toolServer.id, eager: true }],
1390
+ }
1391
+ : {}),
1392
+ };
1393
+ }
1394
+
1395
+ const EDITABLE_ARTIFACT_LIVE_PATH = "/v1/editable-artifacts/live";
1396
+ const SITE_LIST_FIELDS = ["limit", "cursor", "status"] as const;
1397
+ const TICKET_FIELDS = [
1398
+ "replicaId",
1399
+ "modality",
1400
+ "liveProtocolVersion",
1401
+ "kernelVersion",
1402
+ "modelSchemaVersion",
1403
+ "snapshotVersion",
1404
+ "commandProtocolVersion",
1405
+ "committedTransactionProtocolVersion",
1406
+ ] as const;
1407
+ // Same delivery contract as Opengeni's own route: the browser fetches the
1408
+ // HTML and renders it in a sandboxed frame; opening the URL downloads it.
1409
+ const SITE_HTML_HEADERS = {
1410
+ "Content-Type": "text/html; charset=utf-8",
1411
+ "Cache-Control": "private, no-store",
1412
+ "Content-Security-Policy": "sandbox allow-scripts",
1413
+ "Cross-Origin-Resource-Policy": "same-origin",
1414
+ "X-Content-Type-Options": "nosniff",
1415
+ "Content-Disposition": 'attachment; filename="site.html"',
1416
+ } as const;
1417
+
1418
+ function liveSocketUrl(value: string): string {
1419
+ const url = new URL(value);
1420
+ if (url.protocol === "https:") url.protocol = "wss:";
1421
+ else if (url.protocol === "http:") url.protocol = "ws:";
1422
+ if (url.protocol !== "wss:" && url.protocol !== "ws:") {
1423
+ throw new TypeError("editableLiveUrl must be an HTTP(S) or WS(S) URL");
1424
+ }
1425
+ return url.href;
1426
+ }
1427
+
1428
+ function boundedSiteHtml(
1429
+ body: ReadableStream<Uint8Array> | null,
1430
+ signal: AbortSignal,
1431
+ ): ReadableStream<Uint8Array> {
1432
+ const reader = body?.getReader();
1433
+ let bytes = 0;
1434
+ let settled = false;
1435
+ let controller: ReadableStreamDefaultController<Uint8Array>;
1436
+ const cleanup = () => signal.removeEventListener("abort", abort);
1437
+ const cancel = async (reason: unknown) => {
1438
+ if (settled) return;
1439
+ settled = true;
1440
+ cleanup();
1441
+ await reader?.cancel(reason).catch(() => undefined);
1442
+ reader?.releaseLock();
1443
+ };
1444
+ const abort = () => {
1445
+ if (settled) return;
1446
+ controller.error(signal.reason);
1447
+ void cancel(signal.reason);
1448
+ };
1449
+ return new ReadableStream<Uint8Array>(
1450
+ {
1451
+ start(value) {
1452
+ controller = value;
1453
+ if (signal.aborted) abort();
1454
+ else signal.addEventListener("abort", abort, { once: true });
1455
+ },
1456
+ async pull(value) {
1457
+ if (settled) return;
1458
+ try {
1459
+ const chunk = await reader?.read();
1460
+ if (settled) return;
1461
+ if (!chunk || chunk.done) {
1462
+ settled = true;
1463
+ cleanup();
1464
+ reader?.releaseLock();
1465
+ value.close();
1466
+ return;
1467
+ }
1468
+ bytes += chunk.value.byteLength;
1469
+ if (bytes > SESSION_PROXY_SITE_HTML_MAX_BYTES) {
1470
+ const error = new SessionProxySiteHtmlTooLargeError();
1471
+ value.error(error);
1472
+ await cancel(error);
1473
+ return;
1474
+ }
1475
+ value.enqueue(chunk.value);
1476
+ } catch (error) {
1477
+ if (settled) return;
1478
+ value.error(error);
1479
+ await cancel(error);
1480
+ }
1481
+ },
1482
+ cancel,
1483
+ },
1484
+ // Read only when the downstream consumer requests a chunk.
1485
+ { highWaterMark: 0 },
1486
+ );
1487
+ }
1488
+
1489
+ /**
1490
+ * The client-config `artifacts` capability a custom host proxy reports so
1491
+ * `SessionArtifactViewer` can open editable artifacts: the live socket URL
1492
+ * (ticket-authenticated, reached directly) and the proxied user's browser cache
1493
+ * partition. `client` acts as that user (for example `asUser(...)`).
1494
+ */
1495
+ export async function artifactViewerCapability(input: {
1496
+ client: Pick<ProxyClient, "getAccessContext" | "apiUrl"> &
1497
+ Partial<Pick<ProxyClient, "requestJson">>;
1498
+ workspaceId: string;
1499
+ /** Stable namespace of the host's user identities, mixed into the partition. */
1500
+ source?: string | undefined;
1501
+ editableLiveUrl?: string | undefined;
1502
+ }): Promise<{
1503
+ editableLiveUrl: string;
1504
+ cachePartition: {
1505
+ accountId: string;
1506
+ principalId: string;
1507
+ authorizationEpoch: string;
1508
+ };
1509
+ }> {
1510
+ return {
1511
+ editableLiveUrl: liveSocketUrl(
1512
+ input.editableLiveUrl ?? input.client.apiUrl(EDITABLE_ARTIFACT_LIVE_PATH),
1513
+ ),
1514
+ cachePartition: await cachePartition(
1515
+ input.client.requestJson
1516
+ ? await getSessionProxyWorkspaceGrant(
1517
+ input.client as Pick<ProxyClient, "requestJson">,
1518
+ input.workspaceId,
1519
+ )
1520
+ : await legacyViewerGrant(input.client, input.workspaceId),
1521
+ input.workspaceId,
1522
+ input.source ?? "default",
1523
+ ),
1524
+ };
1525
+ }
1526
+
1527
+ /** The editor's browser cache partition for this proxied user and workspace. */
1528
+ async function cachePartition(
1529
+ grant: import("./types").AccessGrant,
1530
+ workspaceId: string,
1531
+ source: string,
1532
+ ): Promise<{
1533
+ accountId: string;
1534
+ principalId: string;
1535
+ authorizationEpoch: string;
1536
+ }> {
1537
+ if (grant.workspaceId !== workspaceId) {
1538
+ reject(403, "workspace_not_allowed", "This workspace is not available.");
1539
+ }
1540
+ const digest = await crypto.subtle.digest(
1541
+ "SHA-256",
1542
+ new TextEncoder().encode(
1543
+ JSON.stringify({
1544
+ source,
1545
+ subjectId: grant.subjectId,
1546
+ accountId: grant.accountId,
1547
+ workspaceId,
1548
+ permissions: [...grant.permissions].sort(),
1549
+ // Effective external grants carry live identity/link revisions. Include
1550
+ // them even when a revoke/regrant restores an identical permission set.
1551
+ externalActor: grant.metadata?.externalActor ?? null,
1552
+ }),
1553
+ ),
1554
+ );
1555
+ return {
1556
+ accountId: grant.accountId,
1557
+ principalId: grant.subjectId,
1558
+ authorizationEpoch: `sha256:${[...new Uint8Array(digest)]
1559
+ .map((byte) => byte.toString(16).padStart(2, "0"))
1560
+ .join("")}`,
1561
+ };
1562
+ }
1563
+
1564
+ /** Preserve custom capability clients that supplied only the original two methods. */
1565
+ async function legacyViewerGrant(
1566
+ client: Pick<ProxyClient, "getAccessContext">,
1567
+ workspaceId: string,
1568
+ ): Promise<import("./types").AccessGrant> {
1569
+ const access = await client.getAccessContext();
1570
+ const grant = access.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId);
1571
+ if (!grant) reject(403, "workspace_not_allowed", "This workspace is not available.");
1572
+ return grant;
1573
+ }
1574
+
1575
+ /** The browser's byte range, passed through unchanged when well formed. */
1576
+ function retainedRange(request: Request): { range?: string } {
1577
+ const range = request.headers.get("range");
1578
+ if (range === null) return {};
1579
+ if (range.length > 128 || /[^\x20-\x7e]/.test(range)) {
1580
+ reject(400, "invalid_range", "Range must be at most 128 printable ASCII bytes.");
1581
+ }
1582
+ return { range };
1583
+ }
1584
+
1585
+ /** One bounded retained-artifact page, with the range facts the browser SDK verifies. */
1586
+ function retainedContent(content: RetainedArtifactContent): Response {
1587
+ return new Response(content.bytes as Uint8Array<ArrayBuffer>, {
1588
+ status: content.status,
1589
+ headers: {
1590
+ "Content-Type": content.contentType,
1591
+ "Content-Length": String(content.contentLength),
1592
+ ...(content.contentRange ? { "Content-Range": content.contentRange } : {}),
1593
+ "Accept-Ranges": "bytes",
1594
+ "Cache-Control": "private, no-store",
1595
+ // Bytes for the conversation's own reader; never rendered on this origin.
1596
+ "Content-Security-Policy": "sandbox",
1597
+ "Content-Disposition": "attachment",
1598
+ "Cross-Origin-Resource-Policy": "same-origin",
1599
+ "X-Content-Type-Options": "nosniff",
1600
+ },
1601
+ });
1602
+ }
1603
+
1604
+ /** Sandbox link download: one path, no route/target override. */
1605
+ function sandboxRead(body: Record<string, unknown> | undefined): Record<string, unknown> {
1606
+ const path = body?.path;
1607
+ const encoding = body?.encoding;
1608
+ const maxBytes = body?.maxBytes;
1609
+ if (typeof path !== "string" || !path || path.length > 4096) {
1610
+ reject(400, "invalid_body", "path is required.");
1611
+ }
1612
+ if (encoding !== undefined && encoding !== "utf8" && encoding !== "base64") {
1613
+ reject(400, "invalid_body", "encoding must be utf8 or base64.");
1614
+ }
1615
+ if (
1616
+ maxBytes !== undefined &&
1617
+ (typeof maxBytes !== "number" ||
1618
+ !Number.isSafeInteger(maxBytes) ||
1619
+ maxBytes < 1 ||
1620
+ maxBytes > 25 * 1024 * 1024)
1621
+ ) {
1622
+ reject(400, "invalid_body", "maxBytes must be an integer from 1 to 26214400.");
1623
+ }
1624
+ return {
1625
+ path,
1626
+ workspaceOnly: true,
1627
+ ...(encoding === undefined ? {} : { encoding }),
1628
+ ...(maxBytes === undefined ? {} : { maxBytes }),
1629
+ };
1630
+ }
1631
+
1632
+ /** Native path segments after the mount prefix, starting at `v1`'s child; null when not a proxied path. */
1633
+ function routeSegments(pathname: string, basePath: string | undefined): string[] | null {
1634
+ let rest: string;
1635
+ if (basePath !== undefined) {
1636
+ const prefix = basePath.replace(/\/+$/, "");
1637
+ if (!pathname.startsWith(`${prefix}/`)) return null;
1638
+ rest = pathname.slice(prefix.length);
1639
+ } else {
1640
+ const index = pathname.indexOf("/v1/");
1641
+ if (index < 0) return null;
1642
+ rest = pathname.slice(index);
1643
+ }
1644
+ const raw = rest.replace(/\/+$/, "").split("/").slice(1);
1645
+ if (raw[0] !== "v1" || raw.length < 2) return null;
1646
+ const segments: string[] = [];
1647
+ for (const part of raw.slice(1)) {
1648
+ let decoded: string;
1649
+ try {
1650
+ decoded = decodeURIComponent(part);
1651
+ } catch {
1652
+ return null;
1653
+ }
1654
+ // Rejects empty, dot, and encoded-separator segments before any SDK path is built.
1655
+ if (!SEGMENT.test(decoded)) return null;
1656
+ segments.push(decoded);
1657
+ }
1658
+ return segments;
1659
+ }
1660
+
1661
+ async function readJsonBody(
1662
+ request: Request,
1663
+ maxBytes: number,
1664
+ optional = false,
1665
+ ): Promise<Record<string, unknown> | undefined> {
1666
+ const bytes = await readBoundedBytes(request, maxBytes);
1667
+ if (bytes.byteLength === 0) {
1668
+ if (optional) return undefined;
1669
+ reject(400, "invalid_body", "A JSON object body is required.");
1670
+ }
1671
+ const contentType = request.headers.get("content-type") ?? "";
1672
+ if (!/^application\/json\b/i.test(contentType)) {
1673
+ reject(415, "unsupported_media_type", "Send application/json.");
1674
+ }
1675
+ let parsed: unknown;
1676
+ try {
1677
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
1678
+ } catch {
1679
+ reject(400, "invalid_body", "Body is not valid JSON.");
1680
+ }
1681
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1682
+ reject(400, "invalid_body", "A JSON object body is required.");
1683
+ }
1684
+ return parsed as Record<string, unknown>;
1685
+ }
1686
+
1687
+ async function readBoundedBytes(
1688
+ request: Request,
1689
+ maxBytes: number,
1690
+ ): Promise<Uint8Array<ArrayBuffer>> {
1691
+ const declared = Number(request.headers.get("content-length") ?? "0");
1692
+ if (declared > maxBytes) reject(413, "body_too_large", "Request body is too large.");
1693
+ const reader = request.body?.getReader();
1694
+ const chunks: Uint8Array[] = [];
1695
+ let total = 0;
1696
+ if (reader) {
1697
+ for (;;) {
1698
+ const { done, value } = await reader.read();
1699
+ if (done) break;
1700
+ total += value.byteLength;
1701
+ if (total > maxBytes) {
1702
+ void reader.cancel().catch(() => undefined);
1703
+ reject(413, "body_too_large", "Request body is too large.");
1704
+ }
1705
+ chunks.push(value);
1706
+ }
1707
+ }
1708
+ const bytes = new Uint8Array(total);
1709
+ let offset = 0;
1710
+ for (const chunk of chunks) {
1711
+ bytes.set(chunk, offset);
1712
+ offset += chunk.byteLength;
1713
+ }
1714
+ return bytes;
1715
+ }
1716
+
1717
+ function pick(
1718
+ body: Record<string, unknown> | undefined,
1719
+ fields: readonly string[],
1720
+ ): Record<string, unknown> {
1721
+ const picked: Record<string, unknown> = {};
1722
+ for (const field of fields) {
1723
+ if (body && Object.hasOwn(body, field)) picked[field] = body[field];
1724
+ }
1725
+ return picked;
1726
+ }
1727
+
1728
+ function createInput(
1729
+ body: Record<string, unknown> | undefined,
1730
+ modelSelection: boolean,
1731
+ ): SessionProxyCreateInput {
1732
+ for (const key of Object.keys(body ?? {})) {
1733
+ if (!CREATE_FIELDS.has(key) && !(modelSelection && isModelField(key))) {
1734
+ reject(
1735
+ 400,
1736
+ "create_field_not_allowed",
1737
+ `The server chooses session configuration; "${key}" cannot be set from the browser.`,
1738
+ );
1739
+ }
1740
+ }
1741
+ const initialMessage = body?.initialMessage;
1742
+ const idempotencyKey = body?.idempotencyKey;
1743
+ const startMode = body?.startMode;
1744
+ if (startMode !== undefined && startMode !== "realtime") {
1745
+ reject(400, "invalid_start_mode", "startMode must be realtime when supplied.");
1746
+ }
1747
+ if (startMode === "realtime" && initialMessage !== undefined && initialMessage !== "") {
1748
+ reject(
1749
+ 400,
1750
+ "initial_message_not_allowed",
1751
+ "Voice-first creation does not send an initial message.",
1752
+ );
1753
+ }
1754
+ if (startMode !== "realtime" && (typeof initialMessage !== "string" || !initialMessage.trim())) {
1755
+ reject(400, "initial_message_required", "initialMessage is required.");
1756
+ }
1757
+ if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || !idempotencyKey)) {
1758
+ reject(400, "invalid_idempotency_key", "idempotencyKey must be a non-empty string.");
1759
+ }
1760
+ const resources = createResources(body?.resources);
1761
+ if (startMode === "realtime" && resources.length > 0) {
1762
+ reject(400, "resource_not_allowed", "Keep attachments in the draft until a message is sent.");
1763
+ }
1764
+ const policy: Pick<SessionProxyCreateInput, "model" | "reasoningEffort" | "latencyMode"> = {};
1765
+ for (const field of MODEL_FIELDS) {
1766
+ const value = body?.[field];
1767
+ if (value === undefined) continue;
1768
+ if (typeof value !== "string" || !value) {
1769
+ reject(400, "invalid_model_policy", `${field} must be a non-empty string.`);
1770
+ }
1771
+ (policy as Record<string, string>)[field] = value;
1772
+ }
1773
+ return {
1774
+ initialMessage: startMode === "realtime" ? "" : (initialMessage as string),
1775
+ ...(startMode === "realtime" ? { startMode } : {}),
1776
+ ...(idempotencyKey ? { idempotencyKey } : {}),
1777
+ ...(resources.length > 0 ? { resources } : {}),
1778
+ ...policy,
1779
+ };
1780
+ }
1781
+
1782
+ function isModelField(key: string): boolean {
1783
+ return (MODEL_FIELDS as readonly string[]).includes(key);
1784
+ }
1785
+
1786
+ /** First-message attachments: file references only, nothing else on them. */
1787
+ function createResources(value: unknown): FileResourceRef[] {
1788
+ if (value === undefined) return [];
1789
+ if (!Array.isArray(value)) {
1790
+ reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
1791
+ }
1792
+ return value.map((resource: unknown) => {
1793
+ const file = resource as Partial<FileResourceRef> | null;
1794
+ if (
1795
+ !file ||
1796
+ typeof file !== "object" ||
1797
+ file.kind !== "file" ||
1798
+ typeof file.fileId !== "string" ||
1799
+ !file.fileId ||
1800
+ (file.mountPath !== undefined && typeof file.mountPath !== "string")
1801
+ ) {
1802
+ reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
1803
+ }
1804
+ return {
1805
+ kind: "file",
1806
+ fileId: file.fileId,
1807
+ ...(file.mountPath !== undefined ? { mountPath: file.mountPath } : {}),
1808
+ };
1809
+ });
1810
+ }
1811
+
1812
+ /** Browser attachments are added; explicit browser model choices win, as for messages. */
1813
+ function withBrowserCreateChoices(
1814
+ created: CreateSessionRequest,
1815
+ input: SessionProxyCreateInput,
1816
+ ): CreateSessionRequest {
1817
+ const existing = created.resources ?? [];
1818
+ const attached = new Set(
1819
+ existing.flatMap((resource) => (resource.kind === "file" ? [resource.fileId] : [])),
1820
+ );
1821
+ const added = (input.resources ?? []).filter((resource) => !attached.has(resource.fileId));
1822
+ return {
1823
+ ...created,
1824
+ // Preserve browser retry identity even when a simple host hook omits it;
1825
+ // an explicit server-owned key continues to take precedence.
1826
+ ...(created.idempotencyKey === undefined && input.idempotencyKey
1827
+ ? { idempotencyKey: input.idempotencyKey }
1828
+ : {}),
1829
+ ...(input.startMode === "realtime" ? { startMode: "realtime", initialMessage: undefined } : {}),
1830
+ ...(added.length > 0 ? { resources: [...existing, ...added] } : {}),
1831
+ ...(input.model ? { model: input.model } : {}),
1832
+ ...(input.reasoningEffort ? { reasoningEffort: input.reasoningEffort } : {}),
1833
+ ...(input.latencyMode ? { latencyMode: input.latencyMode } : {}),
1834
+ };
1835
+ }
1836
+
1837
+ /** One multipart recording: only the audio, its MIME type, and its duration pass. */
1838
+ async function transcriptionInput(request: Request): Promise<{
1839
+ audio: File;
1840
+ mimeType: string;
1841
+ durationSeconds?: number;
1842
+ signal: AbortSignal;
1843
+ }> {
1844
+ const contentType = request.headers.get("content-type") ?? "";
1845
+ if (!/^multipart\/form-data\b/i.test(contentType)) {
1846
+ reject(415, "unsupported_media_type", "Send multipart/form-data.");
1847
+ }
1848
+ const bytes = await readBoundedBytes(request, MAX_TRANSCRIPTION_BODY_BYTES);
1849
+ // Only the fields this route reads; structural, so DOM and React Native
1850
+ // typings of FormData both satisfy it.
1851
+ let form: { get(name: string): File | string | null };
1852
+ try {
1853
+ form = (await new Response(bytes, {
1854
+ headers: { "Content-Type": contentType },
1855
+ }).formData()) as unknown as { get(name: string): File | string | null };
1856
+ } catch {
1857
+ reject(400, "invalid_audio", "The recording could not be read.");
1858
+ }
1859
+ const audio = form.get("audio");
1860
+ const mimeType = form.get("mimeType");
1861
+ const duration = form.get("durationSeconds");
1862
+ if (!(audio instanceof File)) reject(400, "invalid_audio", "Audio file is required.");
1863
+ const durationSeconds = typeof duration === "string" && duration ? Number(duration) : undefined;
1864
+ if (
1865
+ durationSeconds !== undefined &&
1866
+ !(Number.isFinite(durationSeconds) && durationSeconds >= 0)
1867
+ ) {
1868
+ reject(400, "invalid_audio", "durationSeconds must be a non-negative number.");
1869
+ }
1870
+ return {
1871
+ audio,
1872
+ mimeType: typeof mimeType === "string" && mimeType ? mimeType : audio.type,
1873
+ ...(durationSeconds !== undefined ? { durationSeconds } : {}),
1874
+ signal: request.signal,
1875
+ };
1876
+ }
1877
+
1878
+ /** Browser payloads cannot supply server-owned credential rotations. */
1879
+ function browserPayload(value: unknown): Record<string, unknown> {
1880
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1881
+ reject(400, "invalid_body", "A JSON object body is required.");
1882
+ }
1883
+ const input = { ...(value as Record<string, unknown>) };
1884
+ if (Object.hasOwn(input, "mcpCredentialUpdates")) {
1885
+ reject(403, "credential_update_not_allowed", "MCP credentials are server-owned.");
1886
+ }
1887
+ return input;
1888
+ }
1889
+
1890
+ /** Browser message/draft input: no credential rotation, file resources only, optional model lock. */
1891
+ function sanitizeMessage(value: unknown, modelSelection: boolean): Record<string, unknown> {
1892
+ const input = browserPayload(value);
1893
+ if (input.resources !== undefined) {
1894
+ if (
1895
+ !Array.isArray(input.resources) ||
1896
+ input.resources.some(
1897
+ (resource) =>
1898
+ !resource ||
1899
+ typeof resource !== "object" ||
1900
+ (resource as { kind?: unknown }).kind !== "file",
1901
+ )
1902
+ ) {
1903
+ reject(403, "resource_not_allowed", "Only file attachments may be added from the browser.");
1904
+ }
1905
+ }
1906
+ if (!modelSelection) {
1907
+ for (const field of MODEL_FIELDS) delete input[field];
1908
+ }
1909
+ return input;
1910
+ }
1911
+
1912
+ function clientEvent(body: Record<string, unknown> | undefined): Record<string, unknown> & {
1913
+ type: string;
1914
+ } {
1915
+ switch (body?.type) {
1916
+ case "user.message":
1917
+ case "user.approvalDecision":
1918
+ case "user.humanInputResponse":
1919
+ return body as Record<string, unknown> & { type: string };
1920
+ default:
1921
+ return reject(403, "event_not_allowed", "This session event type is not available.");
1922
+ }
1923
+ }
1924
+
1925
+ function joinContext(...parts: Array<string | undefined>): string | undefined {
1926
+ const joined = parts
1927
+ .map((part) => part?.trim())
1928
+ .filter(Boolean)
1929
+ .join("\n\n");
1930
+ return joined || undefined;
1931
+ }
1932
+
1933
+ async function listEvents(
1934
+ client: ProxyClient,
1935
+ path: string,
1936
+ query: Record<string, string>,
1937
+ call: { signal: AbortSignal },
1938
+ ): Promise<Response> {
1939
+ if (query.mode === "forensic") {
1940
+ reject(403, "forensic_events_not_allowed", "Forensic event reads are not proxied.");
1941
+ }
1942
+ const upstream = await client.requestJsonResponse(path, query, call);
1943
+ const headers: Record<string, string> = {};
1944
+ upstream.headers.forEach((value, name) => {
1945
+ const lower = name.toLowerCase();
1946
+ // Paging metadata only; the upstream contract revision stays behind the proxy.
1947
+ if (lower.startsWith("x-opengeni-") && lower !== OPENGENI_API_CONTRACT_HEADER) {
1948
+ headers[name] = value;
1949
+ }
1950
+ });
1951
+ return new Response(await upstream.text(), {
1952
+ headers: {
1953
+ "Content-Type": "application/json; charset=utf-8",
1954
+ "Cache-Control": "no-store",
1955
+ ...headers,
1956
+ },
1957
+ });
1958
+ }
1959
+
1960
+ /** Re-emit the workspace control/interaction stream the React provider subscribes to. */
1961
+ function workspaceLiveStream(
1962
+ client: ProxyClient,
1963
+ workspaceId: string,
1964
+ params: URLSearchParams,
1965
+ request: Request,
1966
+ heartbeatMs: number,
1967
+ ): Response {
1968
+ const cursor = (name: string): number => {
1969
+ const value = Number(params.get(name) ?? "0");
1970
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
1971
+ };
1972
+ const upstream = new AbortController();
1973
+ if (request.signal.aborted) upstream.abort();
1974
+ else
1975
+ request.signal.addEventListener("abort", () => upstream.abort(), {
1976
+ once: true,
1977
+ });
1978
+ const events = client.streamWorkspaceLiveEvents(workspaceId, {
1979
+ controlAfter: cursor("controlAfter"),
1980
+ interactionAfter: cursor("interactionAfter"),
1981
+ signal: upstream.signal,
1982
+ });
1983
+ const iterator = events[Symbol.asyncIterator]();
1984
+ const encoder = new TextEncoder();
1985
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
1986
+ const stop = () => {
1987
+ if (heartbeat !== undefined) clearInterval(heartbeat);
1988
+ heartbeat = undefined;
1989
+ };
1990
+ const body = new ReadableStream<Uint8Array>({
1991
+ start: (controller) => {
1992
+ heartbeat = setInterval(() => {
1993
+ try {
1994
+ controller.enqueue(encoder.encode(": ping\n\n"));
1995
+ } catch {
1996
+ stop();
1997
+ }
1998
+ }, heartbeatMs);
1999
+ },
2000
+ pull: async (controller) => {
2001
+ const next = await iterator.next().catch((error: unknown) => {
2002
+ stop();
2003
+ throw error;
2004
+ });
2005
+ if (next.done) {
2006
+ stop();
2007
+ controller.close();
2008
+ return;
2009
+ }
2010
+ const event = next.value;
2011
+ controller.enqueue(
2012
+ encoder.encode(
2013
+ `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
2014
+ ),
2015
+ );
2016
+ },
2017
+ cancel: () => {
2018
+ stop();
2019
+ upstream.abort();
2020
+ void Promise.resolve(iterator.return?.(undefined)).catch(() => undefined);
2021
+ },
2022
+ });
2023
+ return new Response(body, {
2024
+ headers: {
2025
+ "Content-Type": "text/event-stream; charset=utf-8",
2026
+ "Cache-Control": "no-cache, no-transform",
2027
+ Connection: "keep-alive",
2028
+ },
2029
+ });
2030
+ }
2031
+
2032
+ /**
2033
+ * The proxy always calls Opengeni with the server's own key, so an upstream
2034
+ * 401 means that key is wrong, expired or revoked, never that the end user is
2035
+ * signed out. Tell the developer once, in the server log, what to fix.
2036
+ */
2037
+ function warnRejectedApiKey(error: OpenGeniApiError, notice: { sent: boolean }): void {
2038
+ if (notice.sent) return;
2039
+ notice.sent = true;
2040
+ console.warn(
2041
+ `[@opengeni/sdk] Opengeni rejected the session proxy's API key (401${
2042
+ error.correlationId ? `, reference ${error.correlationId}` : ""
2043
+ }). It may be expired, revoked or mistyped: create a new key in Opengeni under ` +
2044
+ "Organization settings > Developer and update OPENGENI_API_KEY on the server.",
2045
+ );
2046
+ }
2047
+
2048
+ /** Preserve Opengeni's error envelope so the browser SDK keeps codes, retryability, and outcome facts. */
2049
+ function errorResponse(error: unknown, rejectedKeyNotice: { sent: boolean }): Response {
2050
+ if (error instanceof ProxyRejection) return errorJson(error.status, error.code, error.message);
2051
+ if (error instanceof OpenGeniSetupError) {
2052
+ return json(
2053
+ {
2054
+ error: {
2055
+ code: error.code,
2056
+ message: error.message,
2057
+ retryable: false,
2058
+ ...(error.correlationId ? { requestId: error.correlationId } : {}),
2059
+ },
2060
+ },
2061
+ error.status,
2062
+ );
2063
+ }
2064
+ if (error instanceof OpenGeniApiError) {
2065
+ if (error.status === 401) warnRejectedApiKey(error, rejectedKeyNotice);
2066
+ const status = error.status >= 400 && error.status <= 599 ? error.status : 502;
2067
+ // A decoded upstream envelope is forwarded verbatim; the SDK only retains decodable bodies.
2068
+ if (error.body) {
2069
+ return new Response(error.body, {
2070
+ status,
2071
+ headers: {
2072
+ "Content-Type": "application/json; charset=utf-8",
2073
+ "Cache-Control": "no-store",
2074
+ },
2075
+ });
2076
+ }
2077
+ return json(
2078
+ {
2079
+ error: {
2080
+ code: error.code ?? "opengeni_api_error",
2081
+ message: error.message,
2082
+ retryable: error.retryable,
2083
+ outcomeUnknown: error.outcomeUnknown,
2084
+ ...(error.correlationId ? { requestId: error.correlationId } : {}),
2085
+ ...(error.details ? { details: error.details } : {}),
2086
+ },
2087
+ },
2088
+ status,
2089
+ );
2090
+ }
2091
+ if (error instanceof Error && error.name === "AbortError") {
2092
+ return errorJson(499, "aborted", "The request was aborted.");
2093
+ }
2094
+ // The browser gets no details; the host's server log needs the cause
2095
+ // (for example a missing OPENGENI_API_KEY).
2096
+ console.error("[@opengeni/sdk] Session proxy request failed:", error);
2097
+ return errorJson(500, "proxy_error", "Request could not be completed.");
2098
+ }