@cosmoops/connect-sdk 0.9.0 → 0.16.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 (155) hide show
  1. package/CHANGELOG.md +566 -0
  2. package/README.md +113 -42
  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 +20 -1
  6. package/dist/cjs/client/components/ai-screens.js +73 -0
  7. package/dist/cjs/client/components/ai-widgets.js +37 -0
  8. package/dist/cjs/client/components/app-shell.js +75 -41
  9. package/dist/cjs/client/components/apps-grid.js +3 -2
  10. package/dist/cjs/client/components/ask-ai.js +50 -5
  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 +41 -0
  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 +49 -23
  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 +96 -37
  27. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  28. package/dist/cjs/client/index.js +72 -2
  29. package/dist/cjs/client/provider.js +57 -2
  30. package/dist/cjs/client/use-checkout.js +9 -5
  31. package/dist/cjs/errors.js +1 -1
  32. package/dist/cjs/index.js +16 -1
  33. package/dist/cjs/mcp/index.js +90 -0
  34. package/dist/cjs/mcp/tools.js +369 -0
  35. package/dist/cjs/next/index.js +2 -2
  36. package/dist/cjs/server/client.js +25 -2
  37. package/dist/cjs/server/handler.js +203 -18
  38. package/dist/cjs/server/http.js +6 -2
  39. package/dist/cjs/server/resources/acting-as.js +16 -0
  40. package/dist/cjs/server/resources/ai.js +22 -2
  41. package/dist/cjs/server/resources/data.js +374 -0
  42. package/dist/cjs/server/resources/identity.js +7 -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-schema.js +66 -0
  48. package/dist/cjs/store.js +152 -0
  49. package/dist/cjs/ui/empty-state.js +28 -0
  50. package/dist/cjs/ui/index.js +44 -0
  51. package/dist/cjs/ui/page-header.js +27 -0
  52. package/dist/cjs/ui/page-shell.js +44 -0
  53. package/dist/cjs/ui/section.js +20 -0
  54. package/dist/cjs/ui/setting-row.js +14 -0
  55. package/dist/cjs/ui/status-chip.js +17 -0
  56. package/dist/esm/cli/doctor.js +3 -3
  57. package/dist/esm/cli/init.js +3 -3
  58. package/dist/esm/client/components/access-gate.js +20 -1
  59. package/dist/esm/client/components/ai-screens.js +69 -0
  60. package/dist/esm/client/components/ai-widgets.js +37 -1
  61. package/dist/esm/client/components/app-shell.js +81 -47
  62. package/dist/esm/client/components/apps-grid.js +3 -2
  63. package/dist/esm/client/components/ask-ai.js +51 -6
  64. package/dist/esm/client/components/confirm-dialog.js +37 -0
  65. package/dist/esm/client/components/cursor-pagination.js +19 -0
  66. package/dist/esm/client/components/filter-tabs.js +21 -0
  67. package/dist/esm/client/components/grievance-notice.js +27 -0
  68. package/dist/esm/client/components/link-button.js +17 -10
  69. package/dist/esm/client/components/logo.js +4 -3
  70. package/dist/esm/client/components/nav.js +40 -0
  71. package/dist/esm/client/components/plan-pricing.js +25 -0
  72. package/dist/esm/client/components/plans.js +2 -2
  73. package/dist/esm/client/components/platform-gate.js +45 -0
  74. package/dist/esm/client/components/shell-context.js +141 -0
  75. package/dist/esm/client/components/side-nav.js +48 -21
  76. package/dist/esm/client/components/sidebar.js +120 -0
  77. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  78. package/dist/esm/client/components/subscription.js +2 -2
  79. package/dist/esm/client/components/user-menu.js +95 -38
  80. package/dist/esm/client/components/workspace-switcher.js +97 -0
  81. package/dist/esm/client/index.js +47 -4
  82. package/dist/esm/client/provider.js +53 -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 +4 -0
  86. package/dist/esm/mcp/index.js +85 -0
  87. package/dist/esm/mcp/tools.js +366 -0
  88. package/dist/esm/next/index.js +2 -2
  89. package/dist/esm/server/client.js +25 -2
  90. package/dist/esm/server/handler.js +203 -18
  91. package/dist/esm/server/http.js +6 -2
  92. package/dist/esm/server/resources/acting-as.js +13 -0
  93. package/dist/esm/server/resources/ai.js +22 -2
  94. package/dist/esm/server/resources/data.js +369 -0
  95. package/dist/esm/server/resources/identity.js +7 -3
  96. package/dist/esm/server/resources/kv.js +80 -0
  97. package/dist/esm/server/resources/platform.js +39 -0
  98. package/dist/esm/server/resources/sessions.js +1 -1
  99. package/dist/esm/server/resources/workspaces.js +70 -0
  100. package/dist/esm/store-schema.js +62 -0
  101. package/dist/esm/store.js +141 -0
  102. package/dist/esm/ui/empty-state.js +25 -0
  103. package/dist/esm/ui/index.js +34 -0
  104. package/dist/esm/ui/page-header.js +24 -0
  105. package/dist/esm/ui/page-shell.js +41 -0
  106. package/dist/esm/ui/section.js +17 -0
  107. package/dist/esm/ui/setting-row.js +11 -0
  108. package/dist/esm/ui/status-chip.js +14 -0
  109. package/dist/types/client/components/ai-screens.d.ts +25 -0
  110. package/dist/types/client/components/ai-widgets.d.ts +34 -1
  111. package/dist/types/client/components/app-shell.d.ts +65 -27
  112. package/dist/types/client/components/apps-grid.d.ts +11 -2
  113. package/dist/types/client/components/ask-ai.d.ts +47 -1
  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 +24 -0
  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 +27 -13
  125. package/dist/types/client/components/sidebar.d.ts +95 -0
  126. package/dist/types/client/components/user-menu.d.ts +68 -22
  127. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  128. package/dist/types/client/index.d.ts +29 -5
  129. package/dist/types/client/provider.d.ts +109 -6
  130. package/dist/types/client/use-checkout.d.ts +8 -4
  131. package/dist/types/errors.d.ts +1 -1
  132. package/dist/types/index.d.ts +6 -0
  133. package/dist/types/mcp/index.d.ts +74 -0
  134. package/dist/types/mcp/tools.d.ts +107 -0
  135. package/dist/types/next/index.d.ts +1 -1
  136. package/dist/types/server/client.d.ts +25 -2
  137. package/dist/types/server/http.d.ts +8 -2
  138. package/dist/types/server/index.d.ts +4 -0
  139. package/dist/types/server/resources/acting-as.d.ts +11 -0
  140. package/dist/types/server/resources/ai.d.ts +20 -2
  141. package/dist/types/server/resources/data.d.ts +258 -0
  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-schema.d.ts +52 -0
  146. package/dist/types/store.d.ts +96 -0
  147. package/dist/types/types.d.ts +369 -16
  148. package/dist/types/ui/empty-state.d.ts +34 -0
  149. package/dist/types/ui/index.d.ts +40 -0
  150. package/dist/types/ui/page-header.d.ts +34 -0
  151. package/dist/types/ui/page-shell.d.ts +39 -0
  152. package/dist/types/ui/section.d.ts +26 -0
  153. package/dist/types/ui/setting-row.d.ts +18 -0
  154. package/dist/types/ui/status-chip.d.ts +36 -0
  155. package/package.json +15 -2
@@ -167,34 +167,143 @@ function createConnectHandler(options) {
167
167
  async function whoami(request) {
168
168
  return client.identity.getUser(await requireToken(request));
169
169
  }
170
+ /**
171
+ * The caller's identity, plus the workspace every tenant-scoped route below
172
+ * needs - refusing the one session kind that has no workspace.
173
+ *
174
+ * A Connect **platform staff** session (`staff === true`) carries
175
+ * `workspaceId === null` by design: those accounts belong to no
176
+ * workspace, so entitlements, add-on balances, integrations, the AI context
177
+ * index and the data store have no tenant to read. Before this existed the
178
+ * type said `string` and the question never came up; now that it can, the
179
+ * only two answers are "refuse" and "let the request name a tenant" - and
180
+ * the second is precisely what this bridge exists to prevent.
181
+ *
182
+ * So: staff sign in, `/session` answers, and every workspace-scoped route
183
+ * refuses. An operator console reads `staff`/`role`/`permissions` and talks
184
+ * to your own backend for anything tenant-shaped.
185
+ */
186
+ async function requireWorkspace(request) {
187
+ const session = await whoami(request);
188
+ const workspaceId = session.user.workspaceId;
189
+ if (workspaceId === null) {
190
+ throw new errors_js_1.ConnectError({
191
+ status: 403,
192
+ code: "no_workspace",
193
+ message: "This session has no workspace - platform staff sessions cannot read workspace data",
194
+ endpoint: new URL(request.url).pathname,
195
+ });
196
+ }
197
+ return { ...session, workspaceId };
198
+ }
199
+ /**
200
+ * The store paths a browser may reach through the bridge.
201
+ *
202
+ * Documents, the key/value store, aggregations and atomic commits.
203
+ * **`/data-scan/` is deliberately absent**: a scan crosses every tenant, and
204
+ * Connect refuses one made on behalf of a user - but a browser should not be
205
+ * able to *ask*, so the refusal happens here rather than a round trip away.
206
+ * `storePassthrough` enforces the same set at the other end.
207
+ */
208
+ function isStoreRoute(route) {
209
+ return (route.startsWith("/data/") ||
210
+ route.startsWith("/kv/") ||
211
+ route.startsWith("/data-aggregate/") ||
212
+ route === "/data-commit");
213
+ }
214
+ /**
215
+ * Forwards one store call to Connect on behalf of the caller.
216
+ *
217
+ * Deliberately a thin relay of *path and body*, with the two things that
218
+ * decide access supplied here and never taken from the request: the session
219
+ * token, read from the partner's own session store, and the workspace, read
220
+ * from that session's identity. Connect re-derives both from the token
221
+ * anyway - this bridge is not trusted - but sending a client-supplied
222
+ * workspace even as an ignored field is the kind of thing that stops being
223
+ * ignored after a refactor.
224
+ *
225
+ * Paths are matched by prefix upstream and passed through unparsed; Connect
226
+ * validates every segment against the application's declared schema, so
227
+ * there is nothing useful for this layer to check and nothing it could check
228
+ * more strictly.
229
+ */
230
+ async function storeRoute(request) {
231
+ const token = await requireToken(request);
232
+ // Same refusal as every other tenant-scoped route - the store is a
233
+ // workspace's own data, and a staff session names no workspace. Note the
234
+ // token is still read first: this must fail as "no workspace", not as
235
+ // "no session".
236
+ const { workspaceId } = await requireWorkspace(request);
237
+ const url = new URL(request.url);
238
+ const route = (url.pathname.startsWith(basePath)
239
+ ? url.pathname.slice(basePath.length)
240
+ : url.pathname).replace(/\/+$/, "");
241
+ const query = new URLSearchParams(url.search);
242
+ query.set("workspaceId", workspaceId);
243
+ const body = request.method === "GET" || request.method === "DELETE"
244
+ ? undefined
245
+ : { ...(await request.json().catch(() => ({}))), workspaceId };
246
+ return client.storeRelay({
247
+ method: request.method,
248
+ path: `${route}?${query.toString()}`,
249
+ json: body,
250
+ headers: {
251
+ "x-connect-session-token": token,
252
+ // Forwarded because it is the one request header that changes what the
253
+ // write means, and dropping it would silently turn every merge into a
254
+ // replace.
255
+ ...(request.headers.get("x-store-write-mode")
256
+ ? { "x-store-write-mode": request.headers.get("x-store-write-mode") }
257
+ : {}),
258
+ },
259
+ });
260
+ }
170
261
  const routes = {
171
262
  "GET /session": async (request) => {
172
263
  const token = await session.read(request);
173
264
  // Signed out is a normal state for a page rendering a Login button, not
174
265
  // an error - `user: null` rather than a 401 the client must catch.
175
- if (!token)
176
- return json({ user: null, subscription: null, applicationRole: null });
266
+ // Signed out carries the same three access fields as a live session, so
267
+ // the client never has to tell "signed out" from "signed in with no
268
+ // role" by which keys happen to be present.
269
+ if (!token) {
270
+ return json({ user: null, subscription: null, role: null, permissions: [], staff: false });
271
+ }
177
272
  return json(await client.identity.getUser(token));
178
273
  },
179
274
  "GET /entitlements": async (request) => {
180
- const { user } = await whoami(request);
275
+ const { workspaceId } = await requireWorkspace(request);
181
276
  const [readiness, subscription] = await Promise.all([
182
- client.entitlements.getReadiness(user.workspaceId),
183
- client.entitlements.getSubscription(user.workspaceId),
277
+ client.entitlements.getReadiness(workspaceId),
278
+ client.entitlements.getSubscription(workspaceId),
184
279
  ]);
185
280
  return json({ readiness, subscription });
186
281
  },
187
282
  "GET /plans": async () => json(await client.billing.listPlans()),
283
+ /**
284
+ * The platform's legal identity and grievance channel.
285
+ *
286
+ * The only route here that takes neither a session nor a workspace, and
287
+ * the only one where that is correct rather than a hole: the answer is a
288
+ * fact about the platform operator, identical for every caller, and
289
+ * already published on the public web. There is nothing to scope it to.
290
+ *
291
+ * Left unauthenticated on purpose. Requiring a session would mean a
292
+ * signed-out visitor reading your privacy page could not be shown the
293
+ * grievance officer to complain to - which is the one moment the notice
294
+ * most needs to be legible.
295
+ */
296
+ "GET /company-profile": async () => json({ companyProfile: await client.platform.getCompanyProfile() }),
188
297
  // Workspace comes from the caller's own session, same as every other
189
298
  // route here - a browser must not be able to ask what another workspace
190
299
  // has left.
191
300
  "GET /addons": async (request) => {
192
- const { user } = await whoami(request);
193
- return json(await client.billing.getAddonUsage(user.workspaceId));
301
+ const { workspaceId } = await requireWorkspace(request);
302
+ return json(await client.billing.getAddonUsage(workspaceId));
194
303
  },
195
304
  "GET /apps": async (request) => {
196
- const { user } = await whoami(request);
197
- return json({ apps: await client.apps.list(user.workspaceId) });
305
+ const { workspaceId } = await requireWorkspace(request);
306
+ return json({ apps: await client.apps.list(workspaceId) });
198
307
  },
199
308
  /**
200
309
  * Ask AI. The one streaming route here, and the only one that does not
@@ -212,7 +321,7 @@ function createConnectHandler(options) {
212
321
  * validates it again on arrival.
213
322
  */
214
323
  "POST /ai/chat": async (request) => {
215
- const { user } = await whoami(request);
324
+ const { workspaceId } = await requireWorkspace(request);
216
325
  const body = (await request.json().catch(() => null));
217
326
  if (!Array.isArray(body?.messages)) {
218
327
  throw new errors_js_1.ConnectError({
@@ -223,8 +332,19 @@ function createConnectHandler(options) {
223
332
  });
224
333
  }
225
334
  const upstream = await client.ai.chat({
226
- workspaceId: user.workspaceId,
335
+ workspaceId: workspaceId,
227
336
  messages: body.messages,
337
+ // Forwarded unvalidated on purpose. These are your application's own
338
+ // description of itself and of where the user is - Connect bounds and
339
+ // sanitises them on arrival, and uses them only to write the answer and
340
+ // to validate action links. Nothing here reaches an authorization
341
+ // decision, so there is nothing for this layer to check more strictly.
342
+ screenContext: body.screenContext,
343
+ screens: body.screens,
344
+ // Connect re-derives the workspace from this token, so the line above
345
+ // is belt to its braces - the browser cannot reach another tenant's
346
+ // Context Index even if this route were changed to trust the request.
347
+ asUser: await requireToken(request),
228
348
  });
229
349
  // `upstream.body` is piped, never buffered - awaiting it would collect
230
350
  // the whole answer server-side and deliver it in one lump, which is the
@@ -239,12 +359,68 @@ function createConnectHandler(options) {
239
359
  },
240
360
  });
241
361
  },
362
+ /**
363
+ * The store, forwarded.
364
+ *
365
+ * These are the only bridge routes with a *dynamic* path, and the only
366
+ * ones that forward a browser-supplied body to Connect. Both are safe for
367
+ * the same reason the rest of the bridge is: the caller's session token
368
+ * goes with the request as `asUser`, and Connect derives the user, their
369
+ * workspace and their permissions from it - so a browser cannot name
370
+ * another tenant, cannot claim a permission it does not hold, and cannot
371
+ * reach a collection this application never declared.
372
+ *
373
+ * `workspaceId` is still sent because the wire contract requires one, but
374
+ * Connect ignores it whenever a session token is present. It is filled
375
+ * from `whoami()` rather than the request either way, so this route never
376
+ * relays a client-supplied workspace even to be discarded.
377
+ */
378
+ "STORE": async (request) => storeRoute(request),
379
+ /**
380
+ * The workspace switcher's three reads, forwarded with the caller's own
381
+ * session token.
382
+ *
383
+ * `POST /workspaces/switch` is the only bridge route that *changes the
384
+ * caller's session*, so it writes the new token back through the same
385
+ * `session.write` the OAuth callback uses. Handing the raw token to the
386
+ * browser instead would put a credential in JavaScript's hands, which is
387
+ * exactly what this bridge exists to avoid - the browser gets the new
388
+ * workspace's name and a `Set-Cookie`, never the token itself.
389
+ *
390
+ * A partner with no `session.write` cannot re-persist a rotated token, so
391
+ * switching is simply absent for them rather than half-working - the same
392
+ * treatment `/signin` and `/callback` already get.
393
+ */
394
+ "GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
395
+ "GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
396
+ "POST /workspaces/switch": async (request) => {
397
+ const token = await requireToken(request);
398
+ const body = (await request.json().catch(() => null));
399
+ if (typeof body?.workspaceId !== "string") {
400
+ throw new errors_js_1.ConnectError({
401
+ status: 400,
402
+ code: "validation_error",
403
+ message: "workspaceId is required",
404
+ endpoint: "POST /workspaces/switch",
405
+ });
406
+ }
407
+ const result = await client.workspaces.switch({ workspaceId: body.workspaceId }, { asUser: token });
408
+ // The response is built first so `session.write` has something to set
409
+ // cookies on, matching how `/callback` does it.
410
+ const response = json({
411
+ workspaceId: result.workspaceId,
412
+ workspaceName: result.workspaceName,
413
+ workspaceType: result.workspaceType,
414
+ });
415
+ await session.write?.(request, response, result.sessionToken);
416
+ return response;
417
+ },
242
418
  "GET /integrations": async (request) => {
243
- const { user } = await whoami(request);
244
- return json({ toolkits: await client.integrations.list(user.workspaceId) });
419
+ const { workspaceId } = await requireWorkspace(request);
420
+ return json({ toolkits: await client.integrations.list(workspaceId) });
245
421
  },
246
422
  "POST /integrations/connect": async (request) => {
247
- const { user } = await whoami(request);
423
+ const { workspaceId } = await requireWorkspace(request);
248
424
  const body = (await request.json().catch(() => null));
249
425
  if (!body?.toolkitSlug || !body?.returnUrl) {
250
426
  throw new errors_js_1.ConnectError({
@@ -254,10 +430,10 @@ function createConnectHandler(options) {
254
430
  endpoint: "POST /integrations/connect",
255
431
  });
256
432
  }
257
- return json(await client.integrations.initiateConnect(user.workspaceId, body.toolkitSlug, body.returnUrl));
433
+ return json(await client.integrations.initiateConnect(workspaceId, body.toolkitSlug, body.returnUrl));
258
434
  },
259
435
  "POST /integrations/disconnect": async (request) => {
260
- const { user } = await whoami(request);
436
+ const { workspaceId } = await requireWorkspace(request);
261
437
  const body = (await request.json().catch(() => null));
262
438
  if (!body?.toolkitSlug) {
263
439
  throw new errors_js_1.ConnectError({
@@ -267,7 +443,7 @@ function createConnectHandler(options) {
267
443
  endpoint: "POST /integrations/disconnect",
268
444
  });
269
445
  }
270
- await client.integrations.disconnect(user.workspaceId, body.toolkitSlug);
446
+ await client.integrations.disconnect(workspaceId, body.toolkitSlug);
271
447
  return json({ ok: true });
272
448
  },
273
449
  // Account-wide, not scoped to this application - see `SessionsResource`'s
@@ -410,11 +586,20 @@ function createConnectHandler(options) {
410
586
  if (!session.write) {
411
587
  delete routes["GET /signin"];
412
588
  delete routes["GET /callback"];
589
+ // A switch rotates the session token. Without somewhere to persist the new
590
+ // one, the old one would be revoked and the user silently signed out - so
591
+ // the route does not exist rather than working once and breaking.
592
+ delete routes["POST /workspaces/switch"];
413
593
  }
414
594
  return async function handle(request) {
415
595
  const path = new URL(request.url).pathname;
416
596
  const route = (path.startsWith(basePath) ? path.slice(basePath.length) : path).replace(/\/+$/, "") || "/";
417
- const handler = routes[`${request.method} ${route}`];
597
+ const handler = routes[`${request.method} ${route}`] ??
598
+ // Store routes carry ids in the path, so they cannot be keyed by an
599
+ // exact string. Matched by prefix, and only this closed set - the bridge
600
+ // is deliberately not a generic proxy, and a broader fallback here is
601
+ // exactly how it would become one.
602
+ (isStoreRoute(route) ? routes.STORE : undefined);
418
603
  if (!handler) {
419
604
  return json({ error: { code: "not_found", message: `No Connect bridge route for ${route}` } }, 404);
420
605
  }
@@ -80,7 +80,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
80
80
  * `ConnectError`.
81
81
  *
82
82
  * **On retries.** Only idempotent requests are retried without a caller
83
- * opt-in: every `GET`, plus any `POST` carrying an `Idempotency-Key` (the
83
+ * opt-in: every `GET`, `PUT` and `DELETE`, plus any `POST` carrying an
84
+ * `Idempotency-Key` (the
84
85
  * resources that mutate - `billing.consumeAddon`, `otp.send` - always set
85
86
  * one). A bare `POST` is attempted exactly once, because a 429 or a 504 says
86
87
  * nothing about whether the server already did the work.
@@ -118,7 +119,10 @@ async function request(options, spec) {
118
119
  headers["content-type"] = "application/json";
119
120
  body = JSON.stringify(spec.json);
120
121
  }
121
- const idempotent = spec.method === "GET" || headers["idempotency-key"] !== undefined;
122
+ // `PUT` and `DELETE` are idempotent by definition - replaying one lands the
123
+ // same end state - so they retry like `GET`. `POST` still needs an
124
+ // `Idempotency-Key` to earn it, because a replayed create is a second row.
125
+ const idempotent = spec.method !== "POST" || headers["idempotency-key"] !== undefined;
122
126
  const attempts = idempotent ? options.maxRetries + 1 : 1;
123
127
  let lastError;
124
128
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.actingAs = actingAs;
4
+ /**
5
+ * The header that turns a server-to-server store call into one made *on behalf
6
+ * of* a signed-in user.
7
+ *
8
+ * Connect resolves the token to a user and a workspace itself and checks the
9
+ * collection's declared permission against that member's live roles - so a
10
+ * caller supplying this is asking for a *narrower* check than an application
11
+ * call, never a wider one. An unresolvable token is refused rather than
12
+ * quietly downgraded, precisely because the downgrade would be an escalation.
13
+ */
14
+ function actingAs(sessionToken) {
15
+ return sessionToken ? { "x-connect-session-token": sessionToken } : {};
16
+ }
@@ -1,8 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AiResource = void 0;
4
+ const acting_as_js_1 = require("./acting-as.js");
4
5
  const http_js_1 = require("../http.js");
5
- /** RAG chat and structured extraction against a workspace's data. */
6
+ /**
7
+ * RAG chat and structured extraction against a workspace's data.
8
+ *
9
+ * `asUser` on `chat` behaves as it does on the store resources: pass a
10
+ * signed-in user's `sessionToken` and Connect resolves the workspace from it,
11
+ * ignoring `workspaceId`. Without it you are the application, naming the
12
+ * workspace yourself.
13
+ */
6
14
  class AiResource {
7
15
  transport;
8
16
  constructor(transport) {
@@ -18,7 +26,19 @@ class AiResource {
18
26
  return (0, http_js_1.request)(this.transport, {
19
27
  method: "POST",
20
28
  path: "/ai/chat",
21
- json: input,
29
+ // `asUser` never reaches the body - it is a credential, and Connect reads
30
+ // it from the header. Passing a signed-in user's session token makes
31
+ // Connect derive the workspace from *that* rather than from
32
+ // `workspaceId`, which is what the bridge relies on.
33
+ json: {
34
+ workspaceId: input.workspaceId,
35
+ messages: input.messages,
36
+ // Omitted entirely when absent, rather than sent as `undefined` - an
37
+ // application that describes no screens should send no field.
38
+ ...(input.screens ? { screens: input.screens } : {}),
39
+ ...(input.screenContext ? { screenContext: input.screenContext } : {}),
40
+ },
41
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
22
42
  raw: true,
23
43
  signal: options.signal,
24
44
  });