@agent-native/dispatch 0.27.21 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/actions/update-workspace-app-metadata.js +1 -1
  2. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  3. package/dist/components/layout/Layout.d.ts.map +1 -1
  4. package/dist/components/layout/Layout.js +2 -7
  5. package/dist/components/layout/Layout.js.map +1 -1
  6. package/dist/components/workspace-app-host.d.ts +17 -0
  7. package/dist/components/workspace-app-host.d.ts.map +1 -1
  8. package/dist/components/workspace-app-host.js +90 -8
  9. package/dist/components/workspace-app-host.js.map +1 -1
  10. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  11. package/dist/server/lib/app-creation-store.js +0 -1
  12. package/dist/server/lib/app-creation-store.js.map +1 -1
  13. package/dist/server/lib/workspace-app-chat-proxy.d.ts +52 -0
  14. package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -0
  15. package/dist/server/lib/workspace-app-chat-proxy.js +306 -0
  16. package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -0
  17. package/dist/server/plugins/core-routes.d.ts +3 -2
  18. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  19. package/dist/server/plugins/core-routes.js +12 -4
  20. package/dist/server/plugins/core-routes.js.map +1 -1
  21. package/dist/shared/workspace-app-chat.d.ts +23 -0
  22. package/dist/shared/workspace-app-chat.d.ts.map +1 -0
  23. package/dist/shared/workspace-app-chat.js +45 -0
  24. package/dist/shared/workspace-app-chat.js.map +1 -0
  25. package/package.json +2 -2
  26. package/src/actions/update-workspace-app-metadata.ts +1 -1
  27. package/src/components/layout/Layout.app-chat.spec.ts +22 -13
  28. package/src/components/layout/Layout.tsx +5 -18
  29. package/src/components/workspace-app-chat-rail.spec.tsx +182 -0
  30. package/src/components/workspace-app-host.tsx +172 -21
  31. package/src/server/lib/app-creation-store.spec.ts +32 -20
  32. package/src/server/lib/app-creation-store.ts +0 -1
  33. package/src/server/lib/workspace-app-chat-proxy.spec.ts +322 -0
  34. package/src/server/lib/workspace-app-chat-proxy.ts +389 -0
  35. package/src/server/plugins/core-routes.ts +21 -4
  36. package/src/shared/workspace-app-chat.ts +45 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Server-side agent-chat proxy for an app opened inside Dispatch.
3
+ *
4
+ * The Desktop app already solves this by pointing the rail's `apiUrl` at a
5
+ * loopback relay onto the target app's `/_agent-native/agent-chat`, so parity
6
+ * with the app's native chat holds structurally instead of by copying tools and
7
+ * instructions into Dispatch's agent. This is the web equivalent: Dispatch
8
+ * forwards the whole agent-chat subtree to the app's own server.
9
+ *
10
+ * Credential: the app's OWN embed session, minted through the existing paths in
11
+ * `mcp-gateway.ts` — the same credential the app pane's iframe already runs on.
12
+ * Dispatch consumes the one-time ticket server-side and reuses the resulting
13
+ * short-lived embed token as a bearer, which is an authentication path the
14
+ * framework already accepts (`resolveEmbedSessionFromRequest`). No new auth
15
+ * scheme, and no token gains scope it did not already have.
16
+ *
17
+ * A dedicated Nitro route rather than an action: agent-chat streams (SSE for
18
+ * `/runs/:id/events`, a token stream for the turn itself) and returns non-JSON
19
+ * bodies, which is the documented exception in CLAUDE.md to the
20
+ * actions-over-routes rule.
21
+ */
22
+ export interface WorkspaceAppChatSession {
23
+ /** Target app root, including any configured base path. */
24
+ appBaseUrl: string;
25
+ token: string;
26
+ embedTarget: string;
27
+ expiresAt: number;
28
+ }
29
+ /**
30
+ * Read the embed token's own `exp` claim. Guessing a TTL here would silently
31
+ * produce a session that the target has already rejected, so a token we cannot
32
+ * read an expiry from is an error rather than a default.
33
+ */
34
+ export declare function embedSessionTokenExpiry(token: string): number;
35
+ /**
36
+ * Exchange the one-time embed ticket for the reusable short-lived token. The
37
+ * target's embed-start route answers a redirect whose location carries both the
38
+ * token and the path the token is bound to.
39
+ */
40
+ export declare function exchangeEmbedStartUrl(startUrl: string, fetchImpl?: typeof fetch): Promise<WorkspaceAppChatSession>;
41
+ /** Test seam: drop cached app sessions so a spec starts from a cold proxy. */
42
+ export declare function clearWorkspaceAppChatSessions(): void;
43
+ /** h3 v2 request event: `url` is the parsed URL and `req` is the web Request. */
44
+ interface WorkspaceAppChatProxyEvent {
45
+ url: URL;
46
+ req: Request;
47
+ }
48
+ export declare function createWorkspaceAppChatProxyHandler(options?: {
49
+ fetchImpl?: typeof fetch;
50
+ }): (event: WorkspaceAppChatProxyEvent) => Promise<Response>;
51
+ export {};
52
+ //# sourceMappingURL=workspace-app-chat-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-app-chat-proxy.d.ts","sourceRoot":"","sources":["../../../src/server/lib/workspace-app-chat-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AA8DH,MAAM,WAAW,uBAAuB;IACtC,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAgBD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAc7D;AAuBD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,MAAM,EAChB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,uBAAuB,CAAC,CA4BlC;AA8CD,8EAA8E;AAC9E,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD;AAoCD,iFAAiF;AACjF,UAAU,0BAA0B;IAClC,GAAG,EAAE,GAAG,CAAC;IACT,GAAG,EAAE,OAAO,CAAC;CACd;AAED,wBAAgB,kCAAkC,CAChD,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;CAAO,WAGrB,0BAA0B,KAAG,OAAO,CAAC,QAAQ,CAAC,CA+GpE"}
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Server-side agent-chat proxy for an app opened inside Dispatch.
3
+ *
4
+ * The Desktop app already solves this by pointing the rail's `apiUrl` at a
5
+ * loopback relay onto the target app's `/_agent-native/agent-chat`, so parity
6
+ * with the app's native chat holds structurally instead of by copying tools and
7
+ * instructions into Dispatch's agent. This is the web equivalent: Dispatch
8
+ * forwards the whole agent-chat subtree to the app's own server.
9
+ *
10
+ * Credential: the app's OWN embed session, minted through the existing paths in
11
+ * `mcp-gateway.ts` — the same credential the app pane's iframe already runs on.
12
+ * Dispatch consumes the one-time ticket server-side and reuses the resulting
13
+ * short-lived embed token as a bearer, which is an authentication path the
14
+ * framework already accepts (`resolveEmbedSessionFromRequest`). No new auth
15
+ * scheme, and no token gains scope it did not already have.
16
+ *
17
+ * A dedicated Nitro route rather than an action: agent-chat streams (SSE for
18
+ * `/runs/:id/events`, a token stream for the turn itself) and returns non-JSON
19
+ * bodies, which is the documented exception in CLAUDE.md to the
20
+ * actions-over-routes rule.
21
+ */
22
+ import { getOrgContext } from "@agent-native/core/org";
23
+ import { getSession, runWithRequestContext } from "@agent-native/core/server";
24
+ import { EMBED_START_PATH, EMBED_TOKEN_QUERY_PARAM, } from "@agent-native/core/shared";
25
+ import { parseWorkspaceAppChatProxyPath } from "../../shared/workspace-app-chat.js";
26
+ import { createGrantedDispatchMcpEmbedSession, createWorkspaceSsoEmbedSession, } from "./mcp-gateway.js";
27
+ const TARGET_CHAT_PATH = "/_agent-native/agent-chat";
28
+ /**
29
+ * `X-Agent-Native-Embed-Target` names the path a bearer embed token was minted
30
+ * for. Without it the target only honours the token on requests whose own path
31
+ * matches, which an agent-chat call never does. Duplicated from
32
+ * `@agent-native/core/shared/embed-auth` because that constant is not part of
33
+ * the package's public export surface.
34
+ */
35
+ const EMBED_TARGET_HEADER = "x-agent-native-embed-target";
36
+ const CSRF_MARKER_HEADER = "x-agent-native-csrf";
37
+ /** Re-mint this far before the token's own expiry so a long turn cannot age out mid-stream. */
38
+ const SESSION_REFRESH_MARGIN_MS = 5 * 60 * 1000;
39
+ const HOP_BY_HOP_HEADERS = new Set([
40
+ "connection",
41
+ "keep-alive",
42
+ "proxy-authenticate",
43
+ "proxy-authorization",
44
+ "te",
45
+ "trailer",
46
+ "transfer-encoding",
47
+ "upgrade",
48
+ ]);
49
+ /** Headers Dispatch owns on the upstream call, or that must not cross origins. */
50
+ const DROPPED_REQUEST_HEADERS = new Set([
51
+ "accept-encoding",
52
+ "authorization",
53
+ "content-length",
54
+ "cookie",
55
+ "host",
56
+ "origin",
57
+ "referer",
58
+ EMBED_TARGET_HEADER,
59
+ ]);
60
+ /**
61
+ * `set-cookie` would land on Dispatch's origin, and the encoding/length headers
62
+ * describe the upstream wire body, which `fetch` has already decoded.
63
+ */
64
+ const DROPPED_RESPONSE_HEADERS = new Set([
65
+ "content-encoding",
66
+ "content-length",
67
+ "set-cookie",
68
+ "transfer-encoding",
69
+ ]);
70
+ const sessionCache = new Map();
71
+ function sessionCacheKey(input) {
72
+ return [input.userEmail, input.orgId ?? "", input.appId].join("\u0000");
73
+ }
74
+ function errorMessage(error) {
75
+ return error instanceof Error ? error.message : String(error ?? "");
76
+ }
77
+ /**
78
+ * Read the embed token's own `exp` claim. Guessing a TTL here would silently
79
+ * produce a session that the target has already rejected, so a token we cannot
80
+ * read an expiry from is an error rather than a default.
81
+ */
82
+ export function embedSessionTokenExpiry(token) {
83
+ const payload = token.split(".")[0];
84
+ if (!payload)
85
+ throw new Error("Embed session token is malformed.");
86
+ let claims;
87
+ try {
88
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
89
+ }
90
+ catch {
91
+ throw new Error("Embed session token payload could not be decoded.");
92
+ }
93
+ const exp = claims?.exp;
94
+ if (typeof exp !== "number" || !Number.isFinite(exp)) {
95
+ throw new Error("Embed session token has no usable expiry.");
96
+ }
97
+ return exp * 1000;
98
+ }
99
+ async function mintEmbedStartUrl(appId) {
100
+ const input = { app: appId, path: "/", chrome: "minimal" };
101
+ // Mirrors `WorkspaceAppFrame`: the workspace-sign-in mint when the rollout
102
+ // covers this app, the Dispatch MCP grant mint otherwise. Both return the
103
+ // target app's own embed session — neither is a fallback onto Dispatch's
104
+ // agent, which this proxy must never do.
105
+ try {
106
+ return (await createWorkspaceSsoEmbedSession(input)).startUrl;
107
+ }
108
+ catch (ssoError) {
109
+ try {
110
+ return (await createGrantedDispatchMcpEmbedSession(input)).startUrl;
111
+ }
112
+ catch (grantError) {
113
+ throw new Error(`Could not create an app session for "${appId}". ` +
114
+ `Workspace sign-in: ${errorMessage(ssoError)}. ` +
115
+ `Dispatch app grant: ${errorMessage(grantError)}.`);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Exchange the one-time embed ticket for the reusable short-lived token. The
121
+ * target's embed-start route answers a redirect whose location carries both the
122
+ * token and the path the token is bound to.
123
+ */
124
+ export async function exchangeEmbedStartUrl(startUrl, fetchImpl = fetch) {
125
+ const start = new URL(startUrl);
126
+ const startIndex = start.pathname.lastIndexOf(EMBED_START_PATH);
127
+ if (startIndex < 0) {
128
+ throw new Error("App session start URL is not an embed start route.");
129
+ }
130
+ const appBaseUrl = `${start.origin}${start.pathname.slice(0, startIndex)}`;
131
+ const response = await fetchImpl(start.toString(), { redirect: "manual" });
132
+ await response.body?.cancel().catch(() => undefined);
133
+ const location = response.headers.get("location");
134
+ if (response.status < 300 || response.status >= 400 || !location) {
135
+ throw new Error(`App session exchange failed with status ${response.status}.`);
136
+ }
137
+ const resolved = new URL(location, start);
138
+ const token = resolved.searchParams.get(EMBED_TOKEN_QUERY_PARAM);
139
+ if (!token) {
140
+ throw new Error("App session exchange returned no embed token.");
141
+ }
142
+ return {
143
+ appBaseUrl,
144
+ token,
145
+ embedTarget: resolved.pathname,
146
+ expiresAt: embedSessionTokenExpiry(token),
147
+ };
148
+ }
149
+ async function resolveWorkspaceAppChatSession(input) {
150
+ const key = sessionCacheKey(input);
151
+ const cached = sessionCache.get(key);
152
+ if (cached) {
153
+ try {
154
+ const session = await cached;
155
+ if (session.expiresAt - Date.now() > SESSION_REFRESH_MARGIN_MS) {
156
+ return session;
157
+ }
158
+ // coercion-ok: a mint that already failed is retried below, and that fresh
159
+ // attempt reports its own error rather than replaying a stale one.
160
+ }
161
+ catch {
162
+ void 0;
163
+ }
164
+ sessionCache.delete(key);
165
+ }
166
+ const pending = Promise.resolve(runWithRequestContext({
167
+ userEmail: input.userEmail,
168
+ ...(input.orgId ? { orgId: input.orgId } : {}),
169
+ }, async () => exchangeEmbedStartUrl(await mintEmbedStartUrl(input.appId), input.fetchImpl)));
170
+ sessionCache.set(key, pending);
171
+ try {
172
+ return await pending;
173
+ }
174
+ catch (error) {
175
+ sessionCache.delete(key);
176
+ throw error;
177
+ }
178
+ }
179
+ /** Test seam: drop cached app sessions so a spec starts from a cold proxy. */
180
+ export function clearWorkspaceAppChatSessions() {
181
+ sessionCache.clear();
182
+ }
183
+ function upstreamRequestHeaders(request, session) {
184
+ const headers = new Headers();
185
+ for (const [name, value] of request.headers) {
186
+ const lower = name.toLowerCase();
187
+ if (HOP_BY_HOP_HEADERS.has(lower) || DROPPED_REQUEST_HEADERS.has(lower)) {
188
+ continue;
189
+ }
190
+ headers.set(name, value);
191
+ }
192
+ headers.set("authorization", `Bearer ${session.token}`);
193
+ headers.set(EMBED_TARGET_HEADER, session.embedTarget);
194
+ // Documented same-origin marker (see packages/core/src/server/csrf.ts). The
195
+ // proxy sends no cookies, but stating first-party intent keeps the contract
196
+ // explicit rather than relying on the no-cookie shortcut.
197
+ headers.set(CSRF_MARKER_HEADER, "1");
198
+ return headers;
199
+ }
200
+ function proxyErrorResponse(status, message) {
201
+ // An unavailable proxy is a visible failure. Dispatch must never answer an
202
+ // app-scoped chat request with its own agent: that would look like it worked
203
+ // while silently supplying the wrong tools and instructions.
204
+ return new Response(JSON.stringify({ error: message }), {
205
+ status,
206
+ headers: {
207
+ "Content-Type": "application/json; charset=utf-8",
208
+ "Cache-Control": "no-store",
209
+ },
210
+ });
211
+ }
212
+ export function createWorkspaceAppChatProxyHandler(options = {}) {
213
+ const fetchImpl = options.fetchImpl ?? fetch;
214
+ return async (event) => {
215
+ const url = event.url;
216
+ const parsed = parseWorkspaceAppChatProxyPath(url.pathname);
217
+ if (!parsed) {
218
+ return proxyErrorResponse(404, "Workspace app chat route not found.");
219
+ }
220
+ // "Not signed in" and "the session store did not answer" are different
221
+ // failures. Telling a signed-in user to sign in during an outage is the
222
+ // confidently-wrong report this proxy must not produce.
223
+ let session;
224
+ let orgId;
225
+ try {
226
+ session = await getSession(event);
227
+ orgId = (await getOrgContext(event)).orgId ?? undefined;
228
+ }
229
+ catch (error) {
230
+ return proxyErrorResponse(503, `Dispatch could not read your workspace session: ${errorMessage(error)}`);
231
+ }
232
+ if (!session?.email) {
233
+ return proxyErrorResponse(401, "Sign in to Dispatch to use app chat.");
234
+ }
235
+ let appSession;
236
+ try {
237
+ appSession = await resolveWorkspaceAppChatSession({
238
+ appId: parsed.appId,
239
+ userEmail: session.email,
240
+ orgId,
241
+ fetchImpl,
242
+ });
243
+ }
244
+ catch (error) {
245
+ console.warn("[dispatch] workspace app chat proxy could not connect", {
246
+ app: parsed.appId,
247
+ error: errorMessage(error),
248
+ });
249
+ return proxyErrorResponse(502, `${parsed.appId} chat is unavailable: ${errorMessage(error)}`);
250
+ }
251
+ const request = event.req;
252
+ const target = new URL(`${appSession.appBaseUrl}${TARGET_CHAT_PATH}${parsed.targetSubPath}`);
253
+ target.search = url.search;
254
+ const method = request.method.toUpperCase();
255
+ // The turn body is a small JSON payload, so buffering the REQUEST costs
256
+ // nothing and avoids half-duplex streaming support varying by runtime. The
257
+ // RESPONSE is the streaming half and is piped through untouched below.
258
+ const body = method === "GET" || method === "HEAD"
259
+ ? undefined
260
+ : await request.arrayBuffer();
261
+ let upstream;
262
+ try {
263
+ upstream = await fetchImpl(target.toString(), {
264
+ method,
265
+ headers: upstreamRequestHeaders(request, appSession),
266
+ ...(body === undefined ? {} : { body }),
267
+ redirect: "manual",
268
+ });
269
+ }
270
+ catch (error) {
271
+ console.warn("[dispatch] workspace app chat proxy request failed", {
272
+ app: parsed.appId,
273
+ targetPath: target.pathname,
274
+ error: errorMessage(error),
275
+ });
276
+ return proxyErrorResponse(502, `${parsed.appId} chat did not respond: ${errorMessage(error)}`);
277
+ }
278
+ // A rejected embed session is not a transport failure: drop the cached
279
+ // credential so the next request re-mints instead of looping on a token the
280
+ // app has already stopped honouring.
281
+ if (upstream.status === 401 || upstream.status === 403) {
282
+ sessionCache.delete(sessionCacheKey({
283
+ appId: parsed.appId,
284
+ userEmail: session.email,
285
+ orgId,
286
+ }));
287
+ }
288
+ const headers = new Headers();
289
+ for (const [name, value] of upstream.headers) {
290
+ const lower = name.toLowerCase();
291
+ if (HOP_BY_HOP_HEADERS.has(lower) ||
292
+ DROPPED_RESPONSE_HEADERS.has(lower)) {
293
+ continue;
294
+ }
295
+ headers.set(name, value);
296
+ }
297
+ // `upstream.body` is the live stream: SSE run events and the turn's token
298
+ // stream reach the browser as the app emits them, never buffered here.
299
+ return new Response(upstream.body, {
300
+ status: upstream.status,
301
+ statusText: upstream.statusText,
302
+ headers,
303
+ });
304
+ };
305
+ }
306
+ //# sourceMappingURL=workspace-app-chat-proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-app-chat-proxy.js","sourceRoot":"","sources":["../../../src/server/lib/workspace-app-chat-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAC9E,OAAO,EACL,gBAAgB,EAChB,uBAAuB,GACxB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,8BAA8B,EAAE,MAAM,oCAAoC,CAAC;AACpF,OAAO,EACL,oCAAoC,EACpC,8BAA8B,GAC/B,MAAM,kBAAkB,CAAC;AAE1B,MAAM,gBAAgB,GAAG,2BAA2B,CAAC;AACrD;;;;;;GAMG;AACH,MAAM,mBAAmB,GAAG,6BAA6B,CAAC;AAC1D,MAAM,kBAAkB,GAAG,qBAAqB,CAAC;AACjD,+FAA+F;AAC/F,MAAM,yBAAyB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEhD,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC;IACjC,YAAY;IACZ,YAAY;IACZ,oBAAoB;IACpB,qBAAqB;IACrB,IAAI;IACJ,SAAS;IACT,mBAAmB;IACnB,SAAS;CACV,CAAC,CAAC;AAEH,kFAAkF;AAClF,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC;IACtC,iBAAiB;IACjB,eAAe;IACf,gBAAgB;IAChB,QAAQ;IACR,MAAM;IACN,QAAQ;IACR,SAAS;IACT,mBAAmB;CACpB,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,wBAAwB,GAAG,IAAI,GAAG,CAAC;IACvC,kBAAkB;IAClB,gBAAgB;IAChB,YAAY;IACZ,mBAAmB;CACpB,CAAC,CAAC;AAUH,MAAM,YAAY,GAAG,IAAI,GAAG,EAA4C,CAAC;AAEzE,SAAS,eAAe,CAAC,KAIxB;IACC,OAAO,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAAa;IACnD,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpC,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACnE,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1E,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACvE,CAAC;IACD,MAAM,GAAG,GAAI,MAAmC,EAAE,GAAG,CAAC;IACtD,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACrD,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,GAAG,GAAG,IAAI,CAAC;AACpB,CAAC;AAED,KAAK,UAAU,iBAAiB,CAAC,KAAa;IAC5C,MAAM,KAAK,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAW,CAAC;IACpE,2EAA2E;IAC3E,0EAA0E;IAC1E,yEAAyE;IACzE,yCAAyC;IACzC,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,8BAA8B,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;IAChE,CAAC;IAAC,OAAO,QAAQ,EAAE,CAAC;QAClB,IAAI,CAAC;YACH,OAAO,CAAC,MAAM,oCAAoC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC;QACtE,CAAC;QAAC,OAAO,UAAU,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CACb,wCAAwC,KAAK,KAAK;gBAChD,sBAAsB,YAAY,CAAC,QAAQ,CAAC,IAAI;gBAChD,uBAAuB,YAAY,CAAC,UAAU,CAAC,GAAG,CACrD,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,QAAgB,EAChB,SAAS,GAAiB,KAAK;IAE/B,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;IAChC,MAAM,UAAU,GAAG,KAAK,CAAC,QAAQ,CAAC,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAChE,IAAI,UAAU,GAAG,CAAC,EAAE,CAAC;QACnB,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,UAAU,GAAG,GAAG,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,CAAC;IAE3E,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC3E,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IACrD,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IAClD,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;QACjE,MAAM,IAAI,KAAK,CACb,2CAA2C,QAAQ,CAAC,MAAM,GAAG,CAC9D,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAC1C,MAAM,KAAK,GAAG,QAAQ,CAAC,YAAY,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACjE,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAC;IACnE,CAAC;IACD,OAAO;QACL,UAAU;QACV,KAAK;QACL,WAAW,EAAE,QAAQ,CAAC,QAAQ;QAC9B,SAAS,EAAE,uBAAuB,CAAC,KAAK,CAAC;KAC1C,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,8BAA8B,CAAC,KAK7C;IACC,MAAM,GAAG,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACrC,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC;YAC7B,IAAI,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,yBAAyB,EAAE,CAAC;gBAC/D,OAAO,OAAO,CAAC;YACjB,CAAC;YACD,2EAA2E;YAC3E,mEAAmE;QACrE,CAAC;QAAC,MAAM,CAAC;YACP,KAAK,CAAC,CAAC;QACT,CAAC;QACD,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAC7B,qBAAqB,CACnB;QACE,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/C,EACD,KAAK,IAAI,EAAE,CACT,qBAAqB,CACnB,MAAM,iBAAiB,CAAC,KAAK,CAAC,KAAK,CAAC,EACpC,KAAK,CAAC,SAAS,CAChB,CACJ,CACF,CAAC;IACF,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC/B,IAAI,CAAC;QACH,OAAO,MAAM,OAAO,CAAC;IACvB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACzB,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,6BAA6B;IAC3C,YAAY,CAAC,KAAK,EAAE,CAAC;AACvB,CAAC;AAED,SAAS,sBAAsB,CAC7B,OAAgB,EAChB,OAAgC;IAEhC,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QAC5C,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACjC,IAAI,kBAAkB,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,uBAAuB,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACxE,SAAS;QACX,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC3B,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;IACxD,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IACtD,4EAA4E;IAC5E,4EAA4E;IAC5E,0DAA0D;IAC1D,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;IACrC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,kBAAkB,CAAC,MAAc,EAAE,OAAe;IACzD,2EAA2E;IAC3E,6EAA6E;IAC7E,6DAA6D;IAC7D,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE;QACtD,MAAM;QACN,OAAO,EAAE;YACP,cAAc,EAAE,iCAAiC;YACjD,eAAe,EAAE,UAAU;SAC5B;KACF,CAAC,CAAC;AACL,CAAC;AAQD,MAAM,UAAU,kCAAkC,CAChD,OAAO,GAAiC,EAAE;IAE1C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,KAAK,CAAC;IAC7C,OAAO,KAAK,EAAE,KAAiC,EAAqB,EAAE;QACpE,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC;QACtB,MAAM,MAAM,GAAG,8BAA8B,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC5D,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,kBAAkB,CAAC,GAAG,EAAE,qCAAqC,CAAC,CAAC;QACxE,CAAC;QAED,uEAAuE;QACvE,wEAAwE;QACxE,wDAAwD;QACxD,IAAI,OAA+C,CAAC;QACpD,IAAI,KAAyB,CAAC;QAC9B,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,UAAU,CAAC,KAAc,CAAC,CAAC;YAC3C,KAAK,GAAG,CAAC,MAAM,aAAa,CAAC,KAAc,CAAC,CAAC,CAAC,KAAK,IAAI,SAAS,CAAC;QACnE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,kBAAkB,CACvB,GAAG,EACH,mDAAmD,YAAY,CAAC,KAAK,CAAC,EAAE,CACzE,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC;YACpB,OAAO,kBAAkB,CAAC,GAAG,EAAE,sCAAsC,CAAC,CAAC;QACzE,CAAC;QAED,IAAI,UAAmC,CAAC;QACxC,IAAI,CAAC;YACH,UAAU,GAAG,MAAM,8BAA8B,CAAC;gBAChD,KAAK,EAAE,MAAM,CAAC,KAAK;gBACnB,SAAS,EAAE,OAAO,CAAC,KAAK;gBACxB,KAAK;gBACL,SAAS;aACV,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,IAAI,CAAC,uDAAuD,EAAE;gBACpE,GAAG,EAAE,MAAM,CAAC,KAAK;gBACjB,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC;aAC3B,CAAC,CAAC;YACH,OAAO,kBAAkB,CACvB,GAAG,EACH,GAAG,MAAM,CAAC,KAAK,yBAAyB,YAAY,CAAC,KAAK,CAAC,EAAE,CAC9D,CAAC;QACJ,CAAC;QAED,MAAM,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC;QAC1B,MAAM,MAAM,GAAG,IAAI,GAAG,CACpB,GAAG,UAAU,CAAC,UAAU,GAAG,gBAAgB,GAAG,MAAM,CAAC,aAAa,EAAE,CACrE,CAAC;QACF,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;QAE3B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;QAC5C,wEAAwE;QACxE,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,IAAI,GACR,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM;YACnC,CAAC,CAAC,SAAS;YACX,CAAC,CAAC,MAAM,OAAO,CAAC,WAAW,EAAE,CAAC;QAElC,IAAI,QAAkB,CAAC;QACvB,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE;gBAC5C,MAAM;gBACN,OAAO,EAAE,sBAAsB,CAAC,OAAO,EAAE,UAAU,CAAC;gBACpD,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;gBACvC,QAAQ,EAAE,QAAQ;aACnB,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,IAAI,CAAC,oDAAoD,EAAE;gBACjE,GAAG,EAAE,MAAM,CAAC,KAAK;gBACjB,UAAU,EAAE,MAAM,CAAC,QAAQ;gBAC3B,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC;aAC3B,CAAC,CAAC;YACH,OAAO,kBAAkB,CACvB,GAAG,EACH,GAAG,MAAM,CAAC,KAAK,0BAA0B,YAAY,CAAC,KAAK,CAAC,EAAE,CAC/D,CAAC;QACJ,CAAC;QAED,uEAAuE;QACvE,4EAA4E;QAC5E,qCAAqC;QACrC,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvD,YAAY,CAAC,MAAM,CACjB,eAAe,CAAC;gBACd,KAAK,EAAE,MAAM,CAAC,KAAK;gBACnB,SAAS,EAAE,OAAO,CAAC,KAAK;gBACxB,KAAK;aACN,CAAC,CACH,CAAC;QACJ,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;QAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,QAAQ,CAAC,OAAO,EAAE,CAAC;YAC7C,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;YACjC,IACE,kBAAkB,CAAC,GAAG,CAAC,KAAK,CAAC;gBAC7B,wBAAwB,CAAC,GAAG,CAAC,KAAK,CAAC,EACnC,CAAC;gBACD,SAAS;YACX,CAAC;YACD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC3B,CAAC;QACD,0EAA0E;QAC1E,uEAAuE;QACvE,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE;YACjC,MAAM,EAAE,QAAQ,CAAC,MAAM;YACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC/B,OAAO;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Server-side agent-chat proxy for an app opened inside Dispatch.\n *\n * The Desktop app already solves this by pointing the rail's `apiUrl` at a\n * loopback relay onto the target app's `/_agent-native/agent-chat`, so parity\n * with the app's native chat holds structurally instead of by copying tools and\n * instructions into Dispatch's agent. This is the web equivalent: Dispatch\n * forwards the whole agent-chat subtree to the app's own server.\n *\n * Credential: the app's OWN embed session, minted through the existing paths in\n * `mcp-gateway.ts` — the same credential the app pane's iframe already runs on.\n * Dispatch consumes the one-time ticket server-side and reuses the resulting\n * short-lived embed token as a bearer, which is an authentication path the\n * framework already accepts (`resolveEmbedSessionFromRequest`). No new auth\n * scheme, and no token gains scope it did not already have.\n *\n * A dedicated Nitro route rather than an action: agent-chat streams (SSE for\n * `/runs/:id/events`, a token stream for the turn itself) and returns non-JSON\n * bodies, which is the documented exception in CLAUDE.md to the\n * actions-over-routes rule.\n */\n\nimport { getOrgContext } from \"@agent-native/core/org\";\nimport { getSession, runWithRequestContext } from \"@agent-native/core/server\";\nimport {\n EMBED_START_PATH,\n EMBED_TOKEN_QUERY_PARAM,\n} from \"@agent-native/core/shared\";\n\nimport { parseWorkspaceAppChatProxyPath } from \"../../shared/workspace-app-chat.js\";\nimport {\n createGrantedDispatchMcpEmbedSession,\n createWorkspaceSsoEmbedSession,\n} from \"./mcp-gateway.js\";\n\nconst TARGET_CHAT_PATH = \"/_agent-native/agent-chat\";\n/**\n * `X-Agent-Native-Embed-Target` names the path a bearer embed token was minted\n * for. Without it the target only honours the token on requests whose own path\n * matches, which an agent-chat call never does. Duplicated from\n * `@agent-native/core/shared/embed-auth` because that constant is not part of\n * the package's public export surface.\n */\nconst EMBED_TARGET_HEADER = \"x-agent-native-embed-target\";\nconst CSRF_MARKER_HEADER = \"x-agent-native-csrf\";\n/** Re-mint this far before the token's own expiry so a long turn cannot age out mid-stream. */\nconst SESSION_REFRESH_MARGIN_MS = 5 * 60 * 1000;\n\nconst HOP_BY_HOP_HEADERS = new Set([\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n]);\n\n/** Headers Dispatch owns on the upstream call, or that must not cross origins. */\nconst DROPPED_REQUEST_HEADERS = new Set([\n \"accept-encoding\",\n \"authorization\",\n \"content-length\",\n \"cookie\",\n \"host\",\n \"origin\",\n \"referer\",\n EMBED_TARGET_HEADER,\n]);\n\n/**\n * `set-cookie` would land on Dispatch's origin, and the encoding/length headers\n * describe the upstream wire body, which `fetch` has already decoded.\n */\nconst DROPPED_RESPONSE_HEADERS = new Set([\n \"content-encoding\",\n \"content-length\",\n \"set-cookie\",\n \"transfer-encoding\",\n]);\n\nexport interface WorkspaceAppChatSession {\n /** Target app root, including any configured base path. */\n appBaseUrl: string;\n token: string;\n embedTarget: string;\n expiresAt: number;\n}\n\nconst sessionCache = new Map<string, Promise<WorkspaceAppChatSession>>();\n\nfunction sessionCacheKey(input: {\n appId: string;\n userEmail: string;\n orgId: string | undefined;\n}): string {\n return [input.userEmail, input.orgId ?? \"\", input.appId].join(\"\\u0000\");\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error ?? \"\");\n}\n\n/**\n * Read the embed token's own `exp` claim. Guessing a TTL here would silently\n * produce a session that the target has already rejected, so a token we cannot\n * read an expiry from is an error rather than a default.\n */\nexport function embedSessionTokenExpiry(token: string): number {\n const payload = token.split(\".\")[0];\n if (!payload) throw new Error(\"Embed session token is malformed.\");\n let claims: unknown;\n try {\n claims = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n } catch {\n throw new Error(\"Embed session token payload could not be decoded.\");\n }\n const exp = (claims as { exp?: unknown } | null)?.exp;\n if (typeof exp !== \"number\" || !Number.isFinite(exp)) {\n throw new Error(\"Embed session token has no usable expiry.\");\n }\n return exp * 1000;\n}\n\nasync function mintEmbedStartUrl(appId: string): Promise<string> {\n const input = { app: appId, path: \"/\", chrome: \"minimal\" } as const;\n // Mirrors `WorkspaceAppFrame`: the workspace-sign-in mint when the rollout\n // covers this app, the Dispatch MCP grant mint otherwise. Both return the\n // target app's own embed session — neither is a fallback onto Dispatch's\n // agent, which this proxy must never do.\n try {\n return (await createWorkspaceSsoEmbedSession(input)).startUrl;\n } catch (ssoError) {\n try {\n return (await createGrantedDispatchMcpEmbedSession(input)).startUrl;\n } catch (grantError) {\n throw new Error(\n `Could not create an app session for \"${appId}\". ` +\n `Workspace sign-in: ${errorMessage(ssoError)}. ` +\n `Dispatch app grant: ${errorMessage(grantError)}.`,\n );\n }\n }\n}\n\n/**\n * Exchange the one-time embed ticket for the reusable short-lived token. The\n * target's embed-start route answers a redirect whose location carries both the\n * token and the path the token is bound to.\n */\nexport async function exchangeEmbedStartUrl(\n startUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<WorkspaceAppChatSession> {\n const start = new URL(startUrl);\n const startIndex = start.pathname.lastIndexOf(EMBED_START_PATH);\n if (startIndex < 0) {\n throw new Error(\"App session start URL is not an embed start route.\");\n }\n const appBaseUrl = `${start.origin}${start.pathname.slice(0, startIndex)}`;\n\n const response = await fetchImpl(start.toString(), { redirect: \"manual\" });\n await response.body?.cancel().catch(() => undefined);\n const location = response.headers.get(\"location\");\n if (response.status < 300 || response.status >= 400 || !location) {\n throw new Error(\n `App session exchange failed with status ${response.status}.`,\n );\n }\n\n const resolved = new URL(location, start);\n const token = resolved.searchParams.get(EMBED_TOKEN_QUERY_PARAM);\n if (!token) {\n throw new Error(\"App session exchange returned no embed token.\");\n }\n return {\n appBaseUrl,\n token,\n embedTarget: resolved.pathname,\n expiresAt: embedSessionTokenExpiry(token),\n };\n}\n\nasync function resolveWorkspaceAppChatSession(input: {\n appId: string;\n userEmail: string;\n orgId: string | undefined;\n fetchImpl: typeof fetch;\n}): Promise<WorkspaceAppChatSession> {\n const key = sessionCacheKey(input);\n const cached = sessionCache.get(key);\n if (cached) {\n try {\n const session = await cached;\n if (session.expiresAt - Date.now() > SESSION_REFRESH_MARGIN_MS) {\n return session;\n }\n // coercion-ok: a mint that already failed is retried below, and that fresh\n // attempt reports its own error rather than replaying a stale one.\n } catch {\n void 0;\n }\n sessionCache.delete(key);\n }\n\n const pending = Promise.resolve(\n runWithRequestContext(\n {\n userEmail: input.userEmail,\n ...(input.orgId ? { orgId: input.orgId } : {}),\n },\n async () =>\n exchangeEmbedStartUrl(\n await mintEmbedStartUrl(input.appId),\n input.fetchImpl,\n ),\n ),\n );\n sessionCache.set(key, pending);\n try {\n return await pending;\n } catch (error) {\n sessionCache.delete(key);\n throw error;\n }\n}\n\n/** Test seam: drop cached app sessions so a spec starts from a cold proxy. */\nexport function clearWorkspaceAppChatSessions(): void {\n sessionCache.clear();\n}\n\nfunction upstreamRequestHeaders(\n request: Request,\n session: WorkspaceAppChatSession,\n): Headers {\n const headers = new Headers();\n for (const [name, value] of request.headers) {\n const lower = name.toLowerCase();\n if (HOP_BY_HOP_HEADERS.has(lower) || DROPPED_REQUEST_HEADERS.has(lower)) {\n continue;\n }\n headers.set(name, value);\n }\n headers.set(\"authorization\", `Bearer ${session.token}`);\n headers.set(EMBED_TARGET_HEADER, session.embedTarget);\n // Documented same-origin marker (see packages/core/src/server/csrf.ts). The\n // proxy sends no cookies, but stating first-party intent keeps the contract\n // explicit rather than relying on the no-cookie shortcut.\n headers.set(CSRF_MARKER_HEADER, \"1\");\n return headers;\n}\n\nfunction proxyErrorResponse(status: number, message: string): Response {\n // An unavailable proxy is a visible failure. Dispatch must never answer an\n // app-scoped chat request with its own agent: that would look like it worked\n // while silently supplying the wrong tools and instructions.\n return new Response(JSON.stringify({ error: message }), {\n status,\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n },\n });\n}\n\n/** h3 v2 request event: `url` is the parsed URL and `req` is the web Request. */\ninterface WorkspaceAppChatProxyEvent {\n url: URL;\n req: Request;\n}\n\nexport function createWorkspaceAppChatProxyHandler(\n options: { fetchImpl?: typeof fetch } = {},\n) {\n const fetchImpl = options.fetchImpl ?? fetch;\n return async (event: WorkspaceAppChatProxyEvent): Promise<Response> => {\n const url = event.url;\n const parsed = parseWorkspaceAppChatProxyPath(url.pathname);\n if (!parsed) {\n return proxyErrorResponse(404, \"Workspace app chat route not found.\");\n }\n\n // \"Not signed in\" and \"the session store did not answer\" are different\n // failures. Telling a signed-in user to sign in during an outage is the\n // confidently-wrong report this proxy must not produce.\n let session: Awaited<ReturnType<typeof getSession>>;\n let orgId: string | undefined;\n try {\n session = await getSession(event as never);\n orgId = (await getOrgContext(event as never)).orgId ?? undefined;\n } catch (error) {\n return proxyErrorResponse(\n 503,\n `Dispatch could not read your workspace session: ${errorMessage(error)}`,\n );\n }\n if (!session?.email) {\n return proxyErrorResponse(401, \"Sign in to Dispatch to use app chat.\");\n }\n\n let appSession: WorkspaceAppChatSession;\n try {\n appSession = await resolveWorkspaceAppChatSession({\n appId: parsed.appId,\n userEmail: session.email,\n orgId,\n fetchImpl,\n });\n } catch (error) {\n console.warn(\"[dispatch] workspace app chat proxy could not connect\", {\n app: parsed.appId,\n error: errorMessage(error),\n });\n return proxyErrorResponse(\n 502,\n `${parsed.appId} chat is unavailable: ${errorMessage(error)}`,\n );\n }\n\n const request = event.req;\n const target = new URL(\n `${appSession.appBaseUrl}${TARGET_CHAT_PATH}${parsed.targetSubPath}`,\n );\n target.search = url.search;\n\n const method = request.method.toUpperCase();\n // The turn body is a small JSON payload, so buffering the REQUEST costs\n // nothing and avoids half-duplex streaming support varying by runtime. The\n // RESPONSE is the streaming half and is piped through untouched below.\n const body =\n method === \"GET\" || method === \"HEAD\"\n ? undefined\n : await request.arrayBuffer();\n\n let upstream: Response;\n try {\n upstream = await fetchImpl(target.toString(), {\n method,\n headers: upstreamRequestHeaders(request, appSession),\n ...(body === undefined ? {} : { body }),\n redirect: \"manual\",\n });\n } catch (error) {\n console.warn(\"[dispatch] workspace app chat proxy request failed\", {\n app: parsed.appId,\n targetPath: target.pathname,\n error: errorMessage(error),\n });\n return proxyErrorResponse(\n 502,\n `${parsed.appId} chat did not respond: ${errorMessage(error)}`,\n );\n }\n\n // A rejected embed session is not a transport failure: drop the cached\n // credential so the next request re-mints instead of looping on a token the\n // app has already stopped honouring.\n if (upstream.status === 401 || upstream.status === 403) {\n sessionCache.delete(\n sessionCacheKey({\n appId: parsed.appId,\n userEmail: session.email,\n orgId,\n }),\n );\n }\n\n const headers = new Headers();\n for (const [name, value] of upstream.headers) {\n const lower = name.toLowerCase();\n if (\n HOP_BY_HOP_HEADERS.has(lower) ||\n DROPPED_RESPONSE_HEADERS.has(lower)\n ) {\n continue;\n }\n headers.set(name, value);\n }\n // `upstream.body` is the live stream: SSE run events and the turn's token\n // stream reach the browser as the app emits them, never buffered here.\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers,\n });\n };\n}\n"]}
@@ -1,3 +1,4 @@
1
- declare const _default: (nitroApp: any) => void | Promise<void>;
2
- export default _default;
1
+ import { type NitroPluginDef } from "@agent-native/core/server";
2
+ declare const dispatchCoreRoutesPlugin: NitroPluginDef;
3
+ export default dispatchCoreRoutesPlugin;
3
4
  //# sourceMappingURL=core-routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"core-routes.d.ts","sourceRoot":"","sources":["../../../src/server/plugins/core-routes.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"core-routes.d.ts","sourceRoot":"","sources":["../../../src/server/plugins/core-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,2BAA2B,CAAC;AAanC,QAAA,MAAM,wBAAwB,EAAE,cAS/B,CAAC;eAEa,wBAAwB"}
@@ -1,10 +1,18 @@
1
- import { createCoreRoutesPlugin } from "@agent-native/core/server";
1
+ import { createCoreRoutesPlugin, getH3App, } from "@agent-native/core/server";
2
+ import { WORKSPACE_APP_CHAT_PROXY_PREFIX } from "../../shared/workspace-app-chat.js";
2
3
  import { envKeys } from "../lib/env-config.js";
3
4
  import { registerDispatchOnboardingSteps } from "../lib/onboarding-steps.js";
5
+ import { createWorkspaceAppChatProxyHandler } from "../lib/workspace-app-chat-proxy.js";
4
6
  // Register before the core plugin so "create your first app" (order 5) appears
5
7
  // above the auto-generated Slack/Telegram steps (order 60). Idempotent.
6
8
  registerDispatchOnboardingSteps();
7
- export default createCoreRoutesPlugin({
8
- envKeys,
9
- });
9
+ const corePlugin = createCoreRoutesPlugin({ envKeys });
10
+ const dispatchCoreRoutesPlugin = (nitroApp) => {
11
+ const coreInit = corePlugin(nitroApp);
12
+ // `getH3App` installs the framework CSRF middleware on its first call for
13
+ // this app, so any route registered through it is already behind that check.
14
+ getH3App(nitroApp).use(WORKSPACE_APP_CHAT_PROXY_PREFIX, createWorkspaceAppChatProxyHandler());
15
+ return coreInit;
16
+ };
17
+ export default dispatchCoreRoutesPlugin;
10
18
  //# sourceMappingURL=core-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"core-routes.js","sourceRoot":"","sources":["../../../src/server/plugins/core-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAEnE,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,+BAA+B,EAAE,MAAM,4BAA4B,CAAC;AAE7E,+EAA+E;AAC/E,wEAAwE;AACxE,+BAA+B,EAAE,CAAC;AAElC,eAAe,sBAAsB,CAAC;IACpC,OAAO;CACR,CAAC,CAAC","sourcesContent":["import { createCoreRoutesPlugin } from \"@agent-native/core/server\";\n\nimport { envKeys } from \"../lib/env-config.js\";\nimport { registerDispatchOnboardingSteps } from \"../lib/onboarding-steps.js\";\n\n// Register before the core plugin so \"create your first app\" (order 5) appears\n// above the auto-generated Slack/Telegram steps (order 60). Idempotent.\nregisterDispatchOnboardingSteps();\n\nexport default createCoreRoutesPlugin({\n envKeys,\n});\n"]}
1
+ {"version":3,"file":"core-routes.js","sourceRoot":"","sources":["../../../src/server/plugins/core-routes.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,QAAQ,GAET,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,+BAA+B,EAAE,MAAM,oCAAoC,CAAC;AACrF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,OAAO,EAAE,+BAA+B,EAAE,MAAM,4BAA4B,CAAC;AAC7E,OAAO,EAAE,kCAAkC,EAAE,MAAM,oCAAoC,CAAC;AAExF,+EAA+E;AAC/E,wEAAwE;AACxE,+BAA+B,EAAE,CAAC;AAElC,MAAM,UAAU,GAAG,sBAAsB,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;AAEvD,MAAM,wBAAwB,GAAmB,CAAC,QAAQ,EAAE,EAAE;IAC5D,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IACtC,0EAA0E;IAC1E,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,CAAC,GAAG,CACpB,+BAA+B,EAC/B,kCAAkC,EAAE,CACrC,CAAC;IACF,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAEF,eAAe,wBAAwB,CAAC","sourcesContent":["import {\n createCoreRoutesPlugin,\n getH3App,\n type NitroPluginDef,\n} from \"@agent-native/core/server\";\n\nimport { WORKSPACE_APP_CHAT_PROXY_PREFIX } from \"../../shared/workspace-app-chat.js\";\nimport { envKeys } from \"../lib/env-config.js\";\nimport { registerDispatchOnboardingSteps } from \"../lib/onboarding-steps.js\";\nimport { createWorkspaceAppChatProxyHandler } from \"../lib/workspace-app-chat-proxy.js\";\n\n// Register before the core plugin so \"create your first app\" (order 5) appears\n// above the auto-generated Slack/Telegram steps (order 60). Idempotent.\nregisterDispatchOnboardingSteps();\n\nconst corePlugin = createCoreRoutesPlugin({ envKeys });\n\nconst dispatchCoreRoutesPlugin: NitroPluginDef = (nitroApp) => {\n const coreInit = corePlugin(nitroApp);\n // `getH3App` installs the framework CSRF middleware on its first call for\n // this app, so any route registered through it is already behind that check.\n getH3App(nitroApp).use(\n WORKSPACE_APP_CHAT_PROXY_PREFIX,\n createWorkspaceAppChatProxyHandler(),\n );\n return coreInit;\n};\n\nexport default dispatchCoreRoutesPlugin;\n"]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Route contract for the Dispatch → workspace app agent-chat proxy.
3
+ *
4
+ * When an app is open in Dispatch, its chat rail must talk to that app's own
5
+ * agent (its tools, its AGENTS.md and skills, its org resources, its dev-mode
6
+ * surface) instead of to Dispatch's agent. The rail points `apiUrl` at this
7
+ * Dispatch-side prefix and the server forwards the whole agent-chat subtree to
8
+ * the app's `/_agent-native/agent-chat`, exactly as the Desktop loopback relay
9
+ * does. Shared so the browser can build the URL without importing server code.
10
+ */
11
+ export declare const WORKSPACE_APP_CHAT_PROXY_PREFIX = "/_agent-native/workspace-app-chat";
12
+ /** Dispatch-relative base URL the chat rail uses as its `apiUrl`. */
13
+ export declare function workspaceAppChatProxyPath(appId: string): string;
14
+ /**
15
+ * Split `/<appId>[/rest]` off a request that already matched the prefix.
16
+ * Returns null when there is no app segment, so the route fails loudly instead
17
+ * of proxying to an unresolved target.
18
+ */
19
+ export declare function parseWorkspaceAppChatProxyPath(pathname: string): {
20
+ appId: string;
21
+ targetSubPath: string;
22
+ } | null;
23
+ //# sourceMappingURL=workspace-app-chat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-app-chat.d.ts","sourceRoot":"","sources":["../../src/shared/workspace-app-chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,eAAO,MAAM,+BAA+B,sCACP,CAAC;AAEtC,qEAAqE;AACrE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,MAAM,GACf;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAkBjD"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Route contract for the Dispatch → workspace app agent-chat proxy.
3
+ *
4
+ * When an app is open in Dispatch, its chat rail must talk to that app's own
5
+ * agent (its tools, its AGENTS.md and skills, its org resources, its dev-mode
6
+ * surface) instead of to Dispatch's agent. The rail points `apiUrl` at this
7
+ * Dispatch-side prefix and the server forwards the whole agent-chat subtree to
8
+ * the app's `/_agent-native/agent-chat`, exactly as the Desktop loopback relay
9
+ * does. Shared so the browser can build the URL without importing server code.
10
+ */
11
+ export const WORKSPACE_APP_CHAT_PROXY_PREFIX = "/_agent-native/workspace-app-chat";
12
+ /** Dispatch-relative base URL the chat rail uses as its `apiUrl`. */
13
+ export function workspaceAppChatProxyPath(appId) {
14
+ return `${WORKSPACE_APP_CHAT_PROXY_PREFIX}/${encodeURIComponent(appId.trim())}`;
15
+ }
16
+ /**
17
+ * Split `/<appId>[/rest]` off a request that already matched the prefix.
18
+ * Returns null when there is no app segment, so the route fails loudly instead
19
+ * of proxying to an unresolved target.
20
+ */
21
+ export function parseWorkspaceAppChatProxyPath(pathname) {
22
+ const index = pathname.indexOf(WORKSPACE_APP_CHAT_PROXY_PREFIX);
23
+ if (index < 0)
24
+ return null;
25
+ const rest = pathname.slice(index + WORKSPACE_APP_CHAT_PROXY_PREFIX.length);
26
+ if (!rest.startsWith("/"))
27
+ return null;
28
+ const segments = rest.slice(1).split("/");
29
+ const rawAppId = segments.shift() ?? "";
30
+ let appId;
31
+ try {
32
+ appId = decodeURIComponent(rawAppId).trim();
33
+ }
34
+ catch {
35
+ // coercion-ok: malformed percent-encoding names no workspace app.
36
+ return null;
37
+ }
38
+ if (!appId)
39
+ return null;
40
+ const targetSubPath = segments.length > 0 ? `/${segments.join("/")}` : "";
41
+ if (targetSubPath.includes(".."))
42
+ return null;
43
+ return { appId, targetSubPath };
44
+ }
45
+ //# sourceMappingURL=workspace-app-chat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-app-chat.js","sourceRoot":"","sources":["../../src/shared/workspace-app-chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,CAAC,MAAM,+BAA+B,GAC1C,mCAAmC,CAAC;AAEtC,qEAAqE;AACrE,MAAM,UAAU,yBAAyB,CAAC,KAAa;IACrD,OAAO,GAAG,+BAA+B,IAAI,kBAAkB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;AAClF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,8BAA8B,CAC5C,QAAgB;IAEhB,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,+BAA+B,CAAC,CAAC;IAChE,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC3B,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,GAAG,+BAA+B,CAAC,MAAM,CAAC,CAAC;IAC5E,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1C,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;IACxC,IAAI,KAAa,CAAC;IAClB,IAAI,CAAC;QACH,KAAK,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,kEAAkE;QAClE,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1E,IAAI,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9C,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC;AAClC,CAAC","sourcesContent":["/**\n * Route contract for the Dispatch → workspace app agent-chat proxy.\n *\n * When an app is open in Dispatch, its chat rail must talk to that app's own\n * agent (its tools, its AGENTS.md and skills, its org resources, its dev-mode\n * surface) instead of to Dispatch's agent. The rail points `apiUrl` at this\n * Dispatch-side prefix and the server forwards the whole agent-chat subtree to\n * the app's `/_agent-native/agent-chat`, exactly as the Desktop loopback relay\n * does. Shared so the browser can build the URL without importing server code.\n */\n\nexport const WORKSPACE_APP_CHAT_PROXY_PREFIX =\n \"/_agent-native/workspace-app-chat\";\n\n/** Dispatch-relative base URL the chat rail uses as its `apiUrl`. */\nexport function workspaceAppChatProxyPath(appId: string): string {\n return `${WORKSPACE_APP_CHAT_PROXY_PREFIX}/${encodeURIComponent(appId.trim())}`;\n}\n\n/**\n * Split `/<appId>[/rest]` off a request that already matched the prefix.\n * Returns null when there is no app segment, so the route fails loudly instead\n * of proxying to an unresolved target.\n */\nexport function parseWorkspaceAppChatProxyPath(\n pathname: string,\n): { appId: string; targetSubPath: string } | null {\n const index = pathname.indexOf(WORKSPACE_APP_CHAT_PROXY_PREFIX);\n if (index < 0) return null;\n const rest = pathname.slice(index + WORKSPACE_APP_CHAT_PROXY_PREFIX.length);\n if (!rest.startsWith(\"/\")) return null;\n const segments = rest.slice(1).split(\"/\");\n const rawAppId = segments.shift() ?? \"\";\n let appId: string;\n try {\n appId = decodeURIComponent(rawAppId).trim();\n } catch {\n // coercion-ok: malformed percent-encoding names no workspace app.\n return null;\n }\n if (!appId) return null;\n const targetSubPath = segments.length > 0 ? `/${segments.join(\"/\")}` : \"\";\n if (targetSubPath.includes(\"..\")) return null;\n return { appId, targetSubPath };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.27.21",
3
+ "version": "0.28.0",
4
4
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -101,7 +101,7 @@
101
101
  "typescript-7": "npm:typescript@^7.0.2",
102
102
  "vite": "8.1.0",
103
103
  "vitest": "^4.1.5",
104
- "@agent-native/core": "0.160.0"
104
+ "@agent-native/core": "0.160.1"
105
105
  },
106
106
  "peerDependencies": {
107
107
  "@agent-native/core": ">=0.8.0",
@@ -6,7 +6,7 @@ import { updateWorkspaceAppMetadata } from "../server/lib/app-creation-store.js"
6
6
 
7
7
  export default defineAction({
8
8
  description:
9
- "Update the human-editable display name and description Dispatch uses for a workspace app. These details are also used as connected-agent/A2A context.",
9
+ "Update the human-editable display name and description Dispatch uses for a workspace app. Workspace members can edit these shared details, which are also used as connected-agent/A2A context.",
10
10
  schema: z.object({
11
11
  appId: z
12
12
  .string()
@@ -2,21 +2,30 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
- describe("Dispatch workspace app chat rail", () => {
6
- it("owns the iframe chat toggle and persists app-specific threads", () => {
7
- const source = readFileSync(
8
- new URL("./Layout.tsx", import.meta.url),
9
- "utf8",
10
- );
5
+ const layoutSource = readFileSync(
6
+ new URL("./Layout.tsx", import.meta.url),
7
+ "utf8",
8
+ );
9
+ const railSource = readFileSync(
10
+ new URL("../workspace-app-host.tsx", import.meta.url),
11
+ "utf8",
12
+ );
11
13
 
12
- expect(source).toContain('openStorageKey="dispatch-app-chat"');
13
- expect(source).toContain(
14
- "storageKey={`dispatch-app-chat:${workspaceAppId}`}",
15
- );
16
- expect(source).toContain("data-dispatch-workspace-app-frame");
17
- expect(source).toContain("chatSidebar");
18
- expect(source).toContain(
14
+ describe("Dispatch workspace app chat rail", () => {
15
+ it("routes the open-app rail through the shared app-chat component", () => {
16
+ // Both app surfaces must share one rail: a second inlined AgentSidebar is
17
+ // how one of them silently keeps talking to Dispatch's own agent.
18
+ expect(layoutSource).toContain("<WorkspaceAppChatRail");
19
+ expect(layoutSource).toContain("data-dispatch-workspace-app-frame");
20
+ expect(layoutSource).toContain(
19
21
  'window.dispatchEvent(new Event("agent-panel:toggle"))',
20
22
  );
23
+ expect(layoutSource).not.toContain('openStorageKey="dispatch-app-chat"');
24
+ });
25
+
26
+ it("persists app-specific threads and points the rail at the app's own agent", () => {
27
+ expect(railSource).toContain('openStorageKey="dispatch-app-chat"');
28
+ expect(railSource).toContain("storageKey={`dispatch-app-chat:${appId}`}");
29
+ expect(railSource).toContain("apiUrl={appChat.apiUrl}");
21
30
  });
22
31
  });