@cosmoops/connect-sdk 0.7.0 → 0.15.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 (154) hide show
  1. package/CHANGELOG.md +662 -0
  2. package/README.md +376 -124
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +59 -5
  6. package/dist/cjs/client/components/ai-format.js +135 -0
  7. package/dist/cjs/client/components/ai-widgets.js +232 -0
  8. package/dist/cjs/client/components/app-shell.js +80 -45
  9. package/dist/cjs/client/components/apps-grid.js +3 -2
  10. package/dist/cjs/client/components/ask-ai.js +353 -0
  11. package/dist/cjs/client/components/confirm-dialog.js +40 -0
  12. package/dist/cjs/client/components/cursor-pagination.js +22 -0
  13. package/dist/cjs/client/components/filter-tabs.js +24 -0
  14. package/dist/cjs/client/components/grievance-notice.js +30 -0
  15. package/dist/cjs/client/components/link-button.js +17 -10
  16. package/dist/cjs/client/components/logo.js +4 -3
  17. package/dist/cjs/client/components/nav.js +42 -1
  18. package/dist/cjs/client/components/plan-pricing.js +27 -0
  19. package/dist/cjs/client/components/plans.js +1 -1
  20. package/dist/cjs/client/components/platform-gate.js +48 -0
  21. package/dist/cjs/client/components/shell-context.js +149 -0
  22. package/dist/cjs/client/components/side-nav.js +50 -24
  23. package/dist/cjs/client/components/sidebar.js +127 -0
  24. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  25. package/dist/cjs/client/components/subscription.js +1 -1
  26. package/dist/cjs/client/components/user-menu.js +122 -40
  27. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  28. package/dist/cjs/client/components/workspace-type-gate.js +48 -0
  29. package/dist/cjs/client/index.js +98 -3
  30. package/dist/cjs/client/provider.js +100 -2
  31. package/dist/cjs/client/use-checkout.js +9 -5
  32. package/dist/cjs/errors.js +1 -1
  33. package/dist/cjs/index.js +11 -1
  34. package/dist/cjs/next/index.js +2 -2
  35. package/dist/cjs/server/client.js +25 -2
  36. package/dist/cjs/server/handler.js +237 -16
  37. package/dist/cjs/server/http.js +52 -26
  38. package/dist/cjs/server/resources/acting-as.js +16 -0
  39. package/dist/cjs/server/resources/ai.js +15 -2
  40. package/dist/cjs/server/resources/data.js +374 -0
  41. package/dist/cjs/server/resources/entitlements.js +41 -0
  42. package/dist/cjs/server/resources/identity.js +9 -3
  43. package/dist/cjs/server/resources/kv.js +84 -0
  44. package/dist/cjs/server/resources/platform.js +43 -0
  45. package/dist/cjs/server/resources/sessions.js +1 -1
  46. package/dist/cjs/server/resources/workspaces.js +74 -0
  47. package/dist/cjs/store.js +152 -0
  48. package/dist/cjs/ui/empty-state.js +28 -0
  49. package/dist/cjs/ui/index.js +44 -0
  50. package/dist/cjs/ui/page-header.js +27 -0
  51. package/dist/cjs/ui/page-shell.js +44 -0
  52. package/dist/cjs/ui/section.js +20 -0
  53. package/dist/cjs/ui/setting-row.js +14 -0
  54. package/dist/cjs/ui/status-chip.js +17 -0
  55. package/dist/esm/cli/doctor.js +3 -3
  56. package/dist/esm/cli/init.js +3 -3
  57. package/dist/esm/client/components/access-gate.js +60 -6
  58. package/dist/esm/client/components/ai-format.js +129 -0
  59. package/dist/esm/client/components/ai-widgets.js +223 -0
  60. package/dist/esm/client/components/app-shell.js +85 -50
  61. package/dist/esm/client/components/apps-grid.js +3 -2
  62. package/dist/esm/client/components/ask-ai.js +350 -0
  63. package/dist/esm/client/components/confirm-dialog.js +37 -0
  64. package/dist/esm/client/components/cursor-pagination.js +19 -0
  65. package/dist/esm/client/components/filter-tabs.js +21 -0
  66. package/dist/esm/client/components/grievance-notice.js +27 -0
  67. package/dist/esm/client/components/link-button.js +17 -10
  68. package/dist/esm/client/components/logo.js +4 -3
  69. package/dist/esm/client/components/nav.js +41 -1
  70. package/dist/esm/client/components/plan-pricing.js +25 -0
  71. package/dist/esm/client/components/plans.js +2 -2
  72. package/dist/esm/client/components/platform-gate.js +45 -0
  73. package/dist/esm/client/components/shell-context.js +141 -0
  74. package/dist/esm/client/components/side-nav.js +49 -22
  75. package/dist/esm/client/components/sidebar.js +120 -0
  76. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  77. package/dist/esm/client/components/subscription.js +2 -2
  78. package/dist/esm/client/components/user-menu.js +121 -41
  79. package/dist/esm/client/components/workspace-switcher.js +97 -0
  80. package/dist/esm/client/components/workspace-type-gate.js +45 -0
  81. package/dist/esm/client/index.js +58 -5
  82. package/dist/esm/client/provider.js +94 -2
  83. package/dist/esm/client/use-checkout.js +9 -5
  84. package/dist/esm/errors.js +1 -1
  85. package/dist/esm/index.js +1 -0
  86. package/dist/esm/next/index.js +2 -2
  87. package/dist/esm/server/client.js +25 -2
  88. package/dist/esm/server/handler.js +237 -16
  89. package/dist/esm/server/http.js +52 -26
  90. package/dist/esm/server/resources/acting-as.js +13 -0
  91. package/dist/esm/server/resources/ai.js +15 -2
  92. package/dist/esm/server/resources/data.js +369 -0
  93. package/dist/esm/server/resources/entitlements.js +41 -0
  94. package/dist/esm/server/resources/identity.js +9 -3
  95. package/dist/esm/server/resources/kv.js +80 -0
  96. package/dist/esm/server/resources/platform.js +39 -0
  97. package/dist/esm/server/resources/sessions.js +1 -1
  98. package/dist/esm/server/resources/workspaces.js +70 -0
  99. package/dist/esm/store.js +141 -0
  100. package/dist/esm/ui/empty-state.js +25 -0
  101. package/dist/esm/ui/index.js +34 -0
  102. package/dist/esm/ui/page-header.js +24 -0
  103. package/dist/esm/ui/page-shell.js +41 -0
  104. package/dist/esm/ui/section.js +17 -0
  105. package/dist/esm/ui/setting-row.js +11 -0
  106. package/dist/esm/ui/status-chip.js +14 -0
  107. package/dist/theme.css +87 -0
  108. package/dist/types/client/components/access-gate.d.ts +17 -3
  109. package/dist/types/client/components/ai-format.d.ts +24 -0
  110. package/dist/types/client/components/ai-widgets.d.ts +42 -0
  111. package/dist/types/client/components/app-shell.d.ts +85 -31
  112. package/dist/types/client/components/apps-grid.d.ts +11 -2
  113. package/dist/types/client/components/ask-ai.d.ts +73 -0
  114. package/dist/types/client/components/confirm-dialog.d.ts +34 -0
  115. package/dist/types/client/components/cursor-pagination.d.ts +27 -0
  116. package/dist/types/client/components/filter-tabs.d.ts +26 -0
  117. package/dist/types/client/components/grievance-notice.d.ts +31 -0
  118. package/dist/types/client/components/link-button.d.ts +32 -9
  119. package/dist/types/client/components/logo.d.ts +18 -2
  120. package/dist/types/client/components/nav.d.ts +27 -3
  121. package/dist/types/client/components/plan-pricing.d.ts +10 -0
  122. package/dist/types/client/components/platform-gate.d.ts +53 -0
  123. package/dist/types/client/components/shell-context.d.ts +116 -0
  124. package/dist/types/client/components/side-nav.d.ts +28 -14
  125. package/dist/types/client/components/sidebar.d.ts +95 -0
  126. package/dist/types/client/components/user-menu.d.ts +70 -23
  127. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  128. package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
  129. package/dist/types/client/index.d.ts +36 -7
  130. package/dist/types/client/provider.d.ts +149 -3
  131. package/dist/types/client/use-checkout.d.ts +8 -4
  132. package/dist/types/errors.d.ts +1 -1
  133. package/dist/types/index.d.ts +2 -0
  134. package/dist/types/next/index.d.ts +1 -1
  135. package/dist/types/server/client.d.ts +25 -2
  136. package/dist/types/server/http.d.ts +14 -3
  137. package/dist/types/server/index.d.ts +4 -0
  138. package/dist/types/server/resources/acting-as.d.ts +11 -0
  139. package/dist/types/server/resources/ai.d.ts +9 -1
  140. package/dist/types/server/resources/data.d.ts +258 -0
  141. package/dist/types/server/resources/entitlements.d.ts +33 -1
  142. package/dist/types/server/resources/kv.d.ts +63 -0
  143. package/dist/types/server/resources/platform.d.ts +32 -0
  144. package/dist/types/server/resources/workspaces.d.ts +55 -0
  145. package/dist/types/store.d.ts +96 -0
  146. package/dist/types/types.d.ts +491 -13
  147. package/dist/types/ui/empty-state.d.ts +34 -0
  148. package/dist/types/ui/index.d.ts +40 -0
  149. package/dist/types/ui/page-header.d.ts +34 -0
  150. package/dist/types/ui/page-shell.d.ts +39 -0
  151. package/dist/types/ui/section.d.ts +26 -0
  152. package/dist/types/ui/setting-row.d.ts +18 -0
  153. package/dist/types/ui/status-chip.d.ts +36 -0
  154. package/package.json +13 -3
@@ -152,15 +152,19 @@ export function buildCancelUrl({ appUrl, subscriptionId }) {
152
152
  * The Connect URL that completes a subscribe or a plan change.
153
153
  *
154
154
  * Both land on a modal Connect renders over the relevant page (an intercepted
155
- * parallel route - `app/@modal/(.)applications/...` and
156
- * `app/@modal/(.)billing/...`), so the customer arrives already looking at the
157
- * confirmation for the plan they picked here, not at a catalogue they have to
158
- * find it in again.
155
+ * parallel route - `app/@modal/(.)apps/...` and `app/@modal/(.)billing/...`),
156
+ * so the customer arrives already looking at the confirmation for the plan
157
+ * they picked here, not at a catalogue they have to find it in again.
158
+ *
159
+ * The subscribe path is `/apps/...`, not `/applications/...`: the portal
160
+ * route customers see is `/apps`, and `/applications` is a Console-only
161
+ * prefix that has never served this page. Building the other one is what
162
+ * sent every first-time subscriber from a partner's pricing page to a 404.
159
163
  */
160
164
  export function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }) {
161
165
  const path = subscriptionId
162
166
  ? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
163
- : `/applications/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
167
+ : `/apps/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
164
168
  const url = new URL(path, appUrl);
165
169
  if (coupon?.trim())
166
170
  url.searchParams.set("coupon", coupon.trim());
@@ -55,7 +55,7 @@ export class ConnectError extends Error {
55
55
  * guessing keys. These two are the exception: reaching them required
56
56
  * presenting a key whose hash matched, so you already hold it, and what you
57
57
  * need to know is that no retry and no scope change will fix this - the key
58
- * has to be reissued from the application's Integration tab.
58
+ * has to be reissued from the application's Connect client section.
59
59
  *
60
60
  * Worth alerting on. It is the one failure that will not resolve itself and
61
61
  * takes down every call your integration makes.
package/dist/esm/index.js CHANGED
@@ -13,3 +13,4 @@
13
13
  */
14
14
  export { ConnectError, isConnectError } from "./errors.js";
15
15
  export { blockingBlockers, normalizePlanFeatures } from "./helpers.js";
16
+ export { arrayRemove, arrayUnion, deleteField, documentExists, increment, serverTimestamp, storeIndexFor, storeIndexSignature, unchangedSince, } from "./store.js";
@@ -38,7 +38,7 @@ import { createConnectHandler } from "../server/handler.js";
38
38
  * | `CONNECT_SESSION_COOKIE` | no | session cookie name |
39
39
  *
40
40
  * Register `{your origin}/api/connect/callback` as a redirect URI in the
41
- * application's Integration tab. That is the only step this cannot do for you.
41
+ * application's Connect client section. That is the only step this cannot do for you.
42
42
  *
43
43
  * No `next.config.ts` changes needed either, including `transpilePackages`:
44
44
  * `dist/` is already ES2022 with `"use client"` hoisted to the top of every
@@ -287,7 +287,7 @@ export async function getConnectSession(request, options = {}) {
287
287
  * A thin wrapper, not a framework of its own - reach for `getConnectSession`
288
288
  * plus your own check when "not signed in" isn't "redirect to sign-in"
289
289
  * (an API route returning 401, say). Role and entitlement checks stay
290
- * outside this function on purpose: `session.applicationRole.granted` and
290
+ * outside this function on purpose: `session.permissions` and
291
291
  * `connect.entitlements.*` are one line each on top of the session this
292
292
  * already gives you, and where that line redirects (a plans page, a
293
293
  * "forbidden" screen) is a decision only your app can make - see
@@ -1,6 +1,9 @@
1
1
  import { AccessResource } from "./resources/access.js";
2
2
  import { AppsResource } from "./resources/apps.js";
3
3
  import { AiResource } from "./resources/ai.js";
4
+ import { DataResource, storePassthrough } from "./resources/data.js";
5
+ import { WorkspacesResource } from "./resources/workspaces.js";
6
+ import { KvResource } from "./resources/kv.js";
4
7
  import { BillingResource } from "./resources/billing.js";
5
8
  import { DriveResource } from "./resources/drive.js";
6
9
  import { EntitlementsResource } from "./resources/entitlements.js";
@@ -10,6 +13,7 @@ import { LogsResource } from "./resources/logs.js";
10
13
  import { AnalyticsResource } from "./resources/analytics.js";
11
14
  import { OtpResource } from "./resources/otp.js";
12
15
  import { SessionsResource } from "./resources/sessions.js";
16
+ import { PlatformResource } from "./resources/platform.js";
13
17
  export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
14
18
  const APPLICATION_API_PREFIX = "/api/v1/application";
15
19
  /**
@@ -74,6 +78,10 @@ export class ConnectClient {
74
78
  drive;
75
79
  /** RAG chat and structured extraction. */
76
80
  ai;
81
+ /** Your application's own document store - see `DataResource`. */
82
+ data;
83
+ /** Your application's key/value store - see `KvResource`. */
84
+ kv;
77
85
  /** WhatsApp/SMS phone verification. */
78
86
  otp;
79
87
  /** Feature events into the Console's audit log, filterable to your application. */
@@ -84,10 +92,20 @@ export class ConnectClient {
84
92
  sessions;
85
93
  /** Composio-hosted third-party connections (Google Calendar, Slack, ...) a workspace has connected. */
86
94
  integrations;
95
+ /** Which workspaces the signed-in user belongs to, switching between them, and the team - see `WorkspacesResource`. */
96
+ workspaces;
97
+ /** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
98
+ platform;
99
+ /**
100
+ * Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
101
+ * store route and nothing else. See `storePassthrough`.
102
+ * @internal
103
+ */
104
+ storeRelay;
87
105
  constructor(options) {
88
106
  assertNotBrowser();
89
107
  if (!options.apiKey) {
90
- throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Integration tab.");
108
+ throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Connect client section.");
91
109
  }
92
110
  const appBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
93
111
  const transport = {
@@ -107,12 +125,17 @@ export class ConnectClient {
107
125
  this.billing = new BillingResource(transport);
108
126
  this.drive = new DriveResource(transport);
109
127
  this.ai = new AiResource(transport);
128
+ this.data = new DataResource(transport);
129
+ this.kv = new KvResource(transport);
110
130
  this.otp = new OtpResource(transport);
111
131
  this.logs = new LogsResource(transport);
112
132
  this.analytics = new AnalyticsResource(transport);
113
133
  this.sessions = new SessionsResource(transport, options.clientId ?? null);
114
134
  this.integrations = new IntegrationsResource(transport);
135
+ this.workspaces = new WorkspacesResource(transport);
136
+ this.platform = new PlatformResource(transport);
137
+ this.storeRelay = (spec) => storePassthrough(transport, spec);
115
138
  }
116
139
  }
117
140
  /** Kept in step with `package.json` by `sdk.test.ts`. */
118
- export const SDK_VERSION = "0.7.0";
141
+ export const SDK_VERSION = "0.14.0";
@@ -164,41 +164,253 @@ export function createConnectHandler(options) {
164
164
  async function whoami(request) {
165
165
  return client.identity.getUser(await requireToken(request));
166
166
  }
167
+ /**
168
+ * The caller's identity, plus the workspace every tenant-scoped route below
169
+ * needs - refusing the one session kind that has no workspace.
170
+ *
171
+ * A Connect **platform staff** session (`staff === true`) carries
172
+ * `workspaceId === null` by design: those accounts belong to no
173
+ * workspace, so entitlements, add-on balances, integrations, the AI context
174
+ * index and the data store have no tenant to read. Before this existed the
175
+ * type said `string` and the question never came up; now that it can, the
176
+ * only two answers are "refuse" and "let the request name a tenant" - and
177
+ * the second is precisely what this bridge exists to prevent.
178
+ *
179
+ * So: staff sign in, `/session` answers, and every workspace-scoped route
180
+ * refuses. An operator console reads `staff`/`role`/`permissions` and talks
181
+ * to your own backend for anything tenant-shaped.
182
+ */
183
+ async function requireWorkspace(request) {
184
+ const session = await whoami(request);
185
+ const workspaceId = session.user.workspaceId;
186
+ if (workspaceId === null) {
187
+ throw new ConnectError({
188
+ status: 403,
189
+ code: "no_workspace",
190
+ message: "This session has no workspace - platform staff sessions cannot read workspace data",
191
+ endpoint: new URL(request.url).pathname,
192
+ });
193
+ }
194
+ return { ...session, workspaceId };
195
+ }
196
+ /**
197
+ * The store paths a browser may reach through the bridge.
198
+ *
199
+ * Documents, the key/value store, aggregations and atomic commits.
200
+ * **`/data-scan/` is deliberately absent**: a scan crosses every tenant, and
201
+ * Connect refuses one made on behalf of a user - but a browser should not be
202
+ * able to *ask*, so the refusal happens here rather than a round trip away.
203
+ * `storePassthrough` enforces the same set at the other end.
204
+ */
205
+ function isStoreRoute(route) {
206
+ return (route.startsWith("/data/") ||
207
+ route.startsWith("/kv/") ||
208
+ route.startsWith("/data-aggregate/") ||
209
+ route === "/data-commit");
210
+ }
211
+ /**
212
+ * Forwards one store call to Connect on behalf of the caller.
213
+ *
214
+ * Deliberately a thin relay of *path and body*, with the two things that
215
+ * decide access supplied here and never taken from the request: the session
216
+ * token, read from the partner's own session store, and the workspace, read
217
+ * from that session's identity. Connect re-derives both from the token
218
+ * anyway - this bridge is not trusted - but sending a client-supplied
219
+ * workspace even as an ignored field is the kind of thing that stops being
220
+ * ignored after a refactor.
221
+ *
222
+ * Paths are matched by prefix upstream and passed through unparsed; Connect
223
+ * validates every segment against the application's declared schema, so
224
+ * there is nothing useful for this layer to check and nothing it could check
225
+ * more strictly.
226
+ */
227
+ async function storeRoute(request) {
228
+ const token = await requireToken(request);
229
+ // Same refusal as every other tenant-scoped route - the store is a
230
+ // workspace's own data, and a staff session names no workspace. Note the
231
+ // token is still read first: this must fail as "no workspace", not as
232
+ // "no session".
233
+ const { workspaceId } = await requireWorkspace(request);
234
+ const url = new URL(request.url);
235
+ const route = (url.pathname.startsWith(basePath)
236
+ ? url.pathname.slice(basePath.length)
237
+ : url.pathname).replace(/\/+$/, "");
238
+ const query = new URLSearchParams(url.search);
239
+ query.set("workspaceId", workspaceId);
240
+ const body = request.method === "GET" || request.method === "DELETE"
241
+ ? undefined
242
+ : { ...(await request.json().catch(() => ({}))), workspaceId };
243
+ return client.storeRelay({
244
+ method: request.method,
245
+ path: `${route}?${query.toString()}`,
246
+ json: body,
247
+ headers: {
248
+ "x-connect-session-token": token,
249
+ // Forwarded because it is the one request header that changes what the
250
+ // write means, and dropping it would silently turn every merge into a
251
+ // replace.
252
+ ...(request.headers.get("x-store-write-mode")
253
+ ? { "x-store-write-mode": request.headers.get("x-store-write-mode") }
254
+ : {}),
255
+ },
256
+ });
257
+ }
167
258
  const routes = {
168
259
  "GET /session": async (request) => {
169
260
  const token = await session.read(request);
170
261
  // Signed out is a normal state for a page rendering a Login button, not
171
262
  // an error - `user: null` rather than a 401 the client must catch.
172
- if (!token)
173
- return json({ user: null, subscription: null, applicationRole: null });
263
+ // Signed out carries the same three access fields as a live session, so
264
+ // the client never has to tell "signed out" from "signed in with no
265
+ // role" by which keys happen to be present.
266
+ if (!token) {
267
+ return json({ user: null, subscription: null, role: null, permissions: [], staff: false });
268
+ }
174
269
  return json(await client.identity.getUser(token));
175
270
  },
176
271
  "GET /entitlements": async (request) => {
177
- const { user } = await whoami(request);
272
+ const { workspaceId } = await requireWorkspace(request);
178
273
  const [readiness, subscription] = await Promise.all([
179
- client.entitlements.getReadiness(user.workspaceId),
180
- client.entitlements.getSubscription(user.workspaceId),
274
+ client.entitlements.getReadiness(workspaceId),
275
+ client.entitlements.getSubscription(workspaceId),
181
276
  ]);
182
277
  return json({ readiness, subscription });
183
278
  },
184
279
  "GET /plans": async () => json(await client.billing.listPlans()),
280
+ /**
281
+ * The platform's legal identity and grievance channel.
282
+ *
283
+ * The only route here that takes neither a session nor a workspace, and
284
+ * the only one where that is correct rather than a hole: the answer is a
285
+ * fact about the platform operator, identical for every caller, and
286
+ * already published on the public web. There is nothing to scope it to.
287
+ *
288
+ * Left unauthenticated on purpose. Requiring a session would mean a
289
+ * signed-out visitor reading your privacy page could not be shown the
290
+ * grievance officer to complain to - which is the one moment the notice
291
+ * most needs to be legible.
292
+ */
293
+ "GET /company-profile": async () => json({ companyProfile: await client.platform.getCompanyProfile() }),
185
294
  // Workspace comes from the caller's own session, same as every other
186
295
  // route here - a browser must not be able to ask what another workspace
187
296
  // has left.
188
297
  "GET /addons": async (request) => {
189
- const { user } = await whoami(request);
190
- return json(await client.billing.getAddonUsage(user.workspaceId));
298
+ const { workspaceId } = await requireWorkspace(request);
299
+ return json(await client.billing.getAddonUsage(workspaceId));
191
300
  },
192
301
  "GET /apps": async (request) => {
193
- const { user } = await whoami(request);
194
- return json({ apps: await client.apps.list(user.workspaceId) });
302
+ const { workspaceId } = await requireWorkspace(request);
303
+ return json({ apps: await client.apps.list(workspaceId) });
304
+ },
305
+ /**
306
+ * Ask AI. The one streaming route here, and the only one that does not
307
+ * answer JSON - it forwards Connect's server-sent event stream through
308
+ * untouched so `<ConnectAskAi>` can render tokens as they arrive.
309
+ *
310
+ * Same rule as every other route: the workspace comes from the caller's
311
+ * own session. It matters more here than anywhere else, because the
312
+ * answers are *retrieved from that workspace's Context Index* - a
313
+ * client-supplied `workspaceId` would be a read of another tenant's
314
+ * documents, laundered through a chat reply.
315
+ *
316
+ * Messages are forwarded as-is (part-based `UIMessage`s), and only their
317
+ * shape is checked: the conversation is the caller's own, and Connect
318
+ * validates it again on arrival.
319
+ */
320
+ "POST /ai/chat": async (request) => {
321
+ const { workspaceId } = await requireWorkspace(request);
322
+ const body = (await request.json().catch(() => null));
323
+ if (!Array.isArray(body?.messages)) {
324
+ throw new ConnectError({
325
+ status: 400,
326
+ code: "validation_error",
327
+ message: "messages is required",
328
+ endpoint: "POST /ai/chat",
329
+ });
330
+ }
331
+ const upstream = await client.ai.chat({
332
+ workspaceId: workspaceId,
333
+ messages: body.messages,
334
+ // Connect re-derives the workspace from this token, so the line above
335
+ // is belt to its braces - the browser cannot reach another tenant's
336
+ // Context Index even if this route were changed to trust the request.
337
+ asUser: await requireToken(request),
338
+ });
339
+ // `upstream.body` is piped, never buffered - awaiting it would collect
340
+ // the whole answer server-side and deliver it in one lump, which is the
341
+ // only thing streaming was for. The browser cancelling its read
342
+ // propagates down this pipe and stops generation upstream.
343
+ return new Response(upstream.body, {
344
+ status: upstream.status,
345
+ headers: {
346
+ "content-type": upstream.headers.get("content-type") ?? "text/event-stream",
347
+ "cache-control": "no-store",
348
+ "x-content-type-options": "nosniff",
349
+ },
350
+ });
351
+ },
352
+ /**
353
+ * The store, forwarded.
354
+ *
355
+ * These are the only bridge routes with a *dynamic* path, and the only
356
+ * ones that forward a browser-supplied body to Connect. Both are safe for
357
+ * the same reason the rest of the bridge is: the caller's session token
358
+ * goes with the request as `asUser`, and Connect derives the user, their
359
+ * workspace and their permissions from it - so a browser cannot name
360
+ * another tenant, cannot claim a permission it does not hold, and cannot
361
+ * reach a collection this application never declared.
362
+ *
363
+ * `workspaceId` is still sent because the wire contract requires one, but
364
+ * Connect ignores it whenever a session token is present. It is filled
365
+ * from `whoami()` rather than the request either way, so this route never
366
+ * relays a client-supplied workspace even to be discarded.
367
+ */
368
+ "STORE": async (request) => storeRoute(request),
369
+ /**
370
+ * The workspace switcher's three reads, forwarded with the caller's own
371
+ * session token.
372
+ *
373
+ * `POST /workspaces/switch` is the only bridge route that *changes the
374
+ * caller's session*, so it writes the new token back through the same
375
+ * `session.write` the OAuth callback uses. Handing the raw token to the
376
+ * browser instead would put a credential in JavaScript's hands, which is
377
+ * exactly what this bridge exists to avoid - the browser gets the new
378
+ * workspace's name and a `Set-Cookie`, never the token itself.
379
+ *
380
+ * A partner with no `session.write` cannot re-persist a rotated token, so
381
+ * switching is simply absent for them rather than half-working - the same
382
+ * treatment `/signin` and `/callback` already get.
383
+ */
384
+ "GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
385
+ "GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
386
+ "POST /workspaces/switch": async (request) => {
387
+ const token = await requireToken(request);
388
+ const body = (await request.json().catch(() => null));
389
+ if (typeof body?.workspaceId !== "string") {
390
+ throw new ConnectError({
391
+ status: 400,
392
+ code: "validation_error",
393
+ message: "workspaceId is required",
394
+ endpoint: "POST /workspaces/switch",
395
+ });
396
+ }
397
+ const result = await client.workspaces.switch({ workspaceId: body.workspaceId }, { asUser: token });
398
+ // The response is built first so `session.write` has something to set
399
+ // cookies on, matching how `/callback` does it.
400
+ const response = json({
401
+ workspaceId: result.workspaceId,
402
+ workspaceName: result.workspaceName,
403
+ workspaceType: result.workspaceType,
404
+ });
405
+ await session.write?.(request, response, result.sessionToken);
406
+ return response;
195
407
  },
196
408
  "GET /integrations": async (request) => {
197
- const { user } = await whoami(request);
198
- return json({ toolkits: await client.integrations.list(user.workspaceId) });
409
+ const { workspaceId } = await requireWorkspace(request);
410
+ return json({ toolkits: await client.integrations.list(workspaceId) });
199
411
  },
200
412
  "POST /integrations/connect": async (request) => {
201
- const { user } = await whoami(request);
413
+ const { workspaceId } = await requireWorkspace(request);
202
414
  const body = (await request.json().catch(() => null));
203
415
  if (!body?.toolkitSlug || !body?.returnUrl) {
204
416
  throw new ConnectError({
@@ -208,10 +420,10 @@ export function createConnectHandler(options) {
208
420
  endpoint: "POST /integrations/connect",
209
421
  });
210
422
  }
211
- return json(await client.integrations.initiateConnect(user.workspaceId, body.toolkitSlug, body.returnUrl));
423
+ return json(await client.integrations.initiateConnect(workspaceId, body.toolkitSlug, body.returnUrl));
212
424
  },
213
425
  "POST /integrations/disconnect": async (request) => {
214
- const { user } = await whoami(request);
426
+ const { workspaceId } = await requireWorkspace(request);
215
427
  const body = (await request.json().catch(() => null));
216
428
  if (!body?.toolkitSlug) {
217
429
  throw new ConnectError({
@@ -221,7 +433,7 @@ export function createConnectHandler(options) {
221
433
  endpoint: "POST /integrations/disconnect",
222
434
  });
223
435
  }
224
- await client.integrations.disconnect(user.workspaceId, body.toolkitSlug);
436
+ await client.integrations.disconnect(workspaceId, body.toolkitSlug);
225
437
  return json({ ok: true });
226
438
  },
227
439
  // Account-wide, not scoped to this application - see `SessionsResource`'s
@@ -364,11 +576,20 @@ export function createConnectHandler(options) {
364
576
  if (!session.write) {
365
577
  delete routes["GET /signin"];
366
578
  delete routes["GET /callback"];
579
+ // A switch rotates the session token. Without somewhere to persist the new
580
+ // one, the old one would be revoked and the user silently signed out - so
581
+ // the route does not exist rather than working once and breaking.
582
+ delete routes["POST /workspaces/switch"];
367
583
  }
368
584
  return async function handle(request) {
369
585
  const path = new URL(request.url).pathname;
370
586
  const route = (path.startsWith(basePath) ? path.slice(basePath.length) : path).replace(/\/+$/, "") || "/";
371
- const handler = routes[`${request.method} ${route}`];
587
+ const handler = routes[`${request.method} ${route}`] ??
588
+ // Store routes carry ids in the path, so they cannot be keyed by an
589
+ // exact string. Matched by prefix, and only this closed set - the bridge
590
+ // is deliberately not a generic proxy, and a broader fallback here is
591
+ // exactly how it would become one.
592
+ (isStoreRoute(route) ? routes.STORE : undefined);
372
593
  if (!handler) {
373
594
  return json({ error: { code: "not_found", message: `No Connect bridge route for ${route}` } }, 404);
374
595
  }
@@ -76,7 +76,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
76
76
  * `ConnectError`.
77
77
  *
78
78
  * **On retries.** Only idempotent requests are retried without a caller
79
- * opt-in: every `GET`, plus any `POST` carrying an `Idempotency-Key` (the
79
+ * opt-in: every `GET`, `PUT` and `DELETE`, plus any `POST` carrying an
80
+ * `Idempotency-Key` (the
80
81
  * resources that mutate - `billing.consumeAddon`, `otp.send` - always set
81
82
  * one). A bare `POST` is attempted exactly once, because a 429 or a 504 says
82
83
  * nothing about whether the server already did the work.
@@ -114,7 +115,10 @@ export async function request(options, spec) {
114
115
  headers["content-type"] = "application/json";
115
116
  body = JSON.stringify(spec.json);
116
117
  }
117
- const idempotent = spec.method === "GET" || headers["idempotency-key"] !== undefined;
118
+ // `PUT` and `DELETE` are idempotent by definition - replaying one lands the
119
+ // same end state - so they retry like `GET`. `POST` still needs an
120
+ // `Idempotency-Key` to earn it, because a replayed create is a second row.
121
+ const idempotent = spec.method !== "POST" || headers["idempotency-key"] !== undefined;
118
122
  const attempts = idempotent ? options.maxRetries + 1 : 1;
119
123
  let lastError;
120
124
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -124,33 +128,55 @@ export async function request(options, spec) {
124
128
  // request for minutes.
125
129
  await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
126
130
  }
127
- const timeout = AbortSignal.timeout(options.timeoutMs);
128
- const signal = spec.signal ? AbortSignal.any([spec.signal, timeout]) : timeout;
129
- let response;
131
+ // A cancellable timer rather than `AbortSignal.timeout`, because the
132
+ // timeout has to be *stopped* once this attempt is done reading - and a
133
+ // timeout signal cannot be cleared.
134
+ //
135
+ // That distinction is the whole point for a streaming endpoint. `fetch`
136
+ // resolves the moment the headers land, but its signal keeps governing
137
+ // the body: an `AbortSignal.timeout(20_000)` handed to `ai.chat` tears the
138
+ // SSE stream down mid-answer, twenty seconds in, on every conversation
139
+ // long enough to matter. Clearing the timer in `finally` bounds the
140
+ // request the same way it always did (a `raw` caller still gets its
141
+ // timeout on *reaching* Connect, and a JSON caller still gets it across
142
+ // the body read, which happens inside the `try`) while leaving a stream
143
+ // that has actually started to run as long as it needs. A caller that
144
+ // wants to bound the stream itself passes `spec.signal`.
145
+ const timeoutController = new AbortController();
146
+ const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
147
+ const signal = spec.signal
148
+ ? AbortSignal.any([spec.signal, timeoutController.signal])
149
+ : timeoutController.signal;
130
150
  try {
131
- response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
151
+ let response;
152
+ try {
153
+ response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
154
+ }
155
+ catch (cause) {
156
+ // A caller-supplied abort is a decision, not a fault - never retried.
157
+ if (spec.signal?.aborted)
158
+ throw cause;
159
+ lastError = new ConnectError({
160
+ status: 0,
161
+ code: "network_error",
162
+ message: `${endpoint} could not reach Connect`,
163
+ endpoint,
164
+ cause,
165
+ });
166
+ continue;
167
+ }
168
+ if (response.ok) {
169
+ if (spec.raw)
170
+ return response;
171
+ return (await readBody(response));
172
+ }
173
+ lastError = toConnectError(response, await readBody(response), endpoint);
174
+ if (!RETRYABLE_STATUS.has(response.status))
175
+ throw lastError;
132
176
  }
133
- catch (cause) {
134
- // A caller-supplied abort is a decision, not a fault - never retried.
135
- if (spec.signal?.aborted)
136
- throw cause;
137
- lastError = new ConnectError({
138
- status: 0,
139
- code: "network_error",
140
- message: `${endpoint} could not reach Connect`,
141
- endpoint,
142
- cause,
143
- });
144
- continue;
177
+ finally {
178
+ clearTimeout(timer);
145
179
  }
146
- if (response.ok) {
147
- if (spec.raw)
148
- return response;
149
- return (await readBody(response));
150
- }
151
- lastError = toConnectError(response, await readBody(response), endpoint);
152
- if (!RETRYABLE_STATUS.has(response.status))
153
- throw lastError;
154
180
  }
155
181
  throw lastError;
156
182
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The header that turns a server-to-server store call into one made *on behalf
3
+ * of* a signed-in user.
4
+ *
5
+ * Connect resolves the token to a user and a workspace itself and checks the
6
+ * collection's declared permission against that member's live roles - so a
7
+ * caller supplying this is asking for a *narrower* check than an application
8
+ * call, never a wider one. An unresolvable token is refused rather than
9
+ * quietly downgraded, precisely because the downgrade would be an escalation.
10
+ */
11
+ export function actingAs(sessionToken) {
12
+ return sessionToken ? { "x-connect-session-token": sessionToken } : {};
13
+ }
@@ -1,5 +1,13 @@
1
+ import { actingAs } from "./acting-as.js";
1
2
  import { request } from "../http.js";
2
- /** RAG chat and structured extraction against a workspace's data. */
3
+ /**
4
+ * RAG chat and structured extraction against a workspace's data.
5
+ *
6
+ * `asUser` on `chat` behaves as it does on the store resources: pass a
7
+ * signed-in user's `sessionToken` and Connect resolves the workspace from it,
8
+ * ignoring `workspaceId`. Without it you are the application, naming the
9
+ * workspace yourself.
10
+ */
3
11
  export class AiResource {
4
12
  transport;
5
13
  constructor(transport) {
@@ -15,7 +23,12 @@ export class AiResource {
15
23
  return request(this.transport, {
16
24
  method: "POST",
17
25
  path: "/ai/chat",
18
- json: input,
26
+ // `asUser` never reaches the body - it is a credential, and Connect reads
27
+ // it from the header. Passing a signed-in user's session token makes
28
+ // Connect derive the workspace from *that* rather than from
29
+ // `workspaceId`, which is what the bridge relies on.
30
+ json: { workspaceId: input.workspaceId, messages: input.messages },
31
+ headers: actingAs(input.asUser),
19
32
  raw: true,
20
33
  signal: options.signal,
21
34
  });