@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.
- package/CHANGELOG.md +662 -0
- package/README.md +376 -124
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +59 -5
- package/dist/cjs/client/components/ai-format.js +135 -0
- package/dist/cjs/client/components/ai-widgets.js +232 -0
- package/dist/cjs/client/components/app-shell.js +80 -45
- package/dist/cjs/client/components/apps-grid.js +3 -2
- package/dist/cjs/client/components/ask-ai.js +353 -0
- package/dist/cjs/client/components/confirm-dialog.js +40 -0
- package/dist/cjs/client/components/cursor-pagination.js +22 -0
- package/dist/cjs/client/components/filter-tabs.js +24 -0
- package/dist/cjs/client/components/grievance-notice.js +30 -0
- package/dist/cjs/client/components/link-button.js +17 -10
- package/dist/cjs/client/components/logo.js +4 -3
- package/dist/cjs/client/components/nav.js +42 -1
- package/dist/cjs/client/components/plan-pricing.js +27 -0
- package/dist/cjs/client/components/plans.js +1 -1
- package/dist/cjs/client/components/platform-gate.js +48 -0
- package/dist/cjs/client/components/shell-context.js +149 -0
- package/dist/cjs/client/components/side-nav.js +50 -24
- package/dist/cjs/client/components/sidebar.js +127 -0
- package/dist/cjs/client/components/subscribe-dialog.js +2 -2
- package/dist/cjs/client/components/subscription.js +1 -1
- package/dist/cjs/client/components/user-menu.js +122 -40
- package/dist/cjs/client/components/workspace-switcher.js +100 -0
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +98 -3
- package/dist/cjs/client/provider.js +100 -2
- package/dist/cjs/client/use-checkout.js +9 -5
- package/dist/cjs/errors.js +1 -1
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/next/index.js +2 -2
- package/dist/cjs/server/client.js +25 -2
- package/dist/cjs/server/handler.js +237 -16
- package/dist/cjs/server/http.js +52 -26
- package/dist/cjs/server/resources/acting-as.js +16 -0
- package/dist/cjs/server/resources/ai.js +15 -2
- package/dist/cjs/server/resources/data.js +374 -0
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +9 -3
- package/dist/cjs/server/resources/kv.js +84 -0
- package/dist/cjs/server/resources/platform.js +43 -0
- package/dist/cjs/server/resources/sessions.js +1 -1
- package/dist/cjs/server/resources/workspaces.js +74 -0
- package/dist/cjs/store.js +152 -0
- package/dist/cjs/ui/empty-state.js +28 -0
- package/dist/cjs/ui/index.js +44 -0
- package/dist/cjs/ui/page-header.js +27 -0
- package/dist/cjs/ui/page-shell.js +44 -0
- package/dist/cjs/ui/section.js +20 -0
- package/dist/cjs/ui/setting-row.js +14 -0
- package/dist/cjs/ui/status-chip.js +17 -0
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +60 -6
- package/dist/esm/client/components/ai-format.js +129 -0
- package/dist/esm/client/components/ai-widgets.js +223 -0
- package/dist/esm/client/components/app-shell.js +85 -50
- package/dist/esm/client/components/apps-grid.js +3 -2
- package/dist/esm/client/components/ask-ai.js +350 -0
- package/dist/esm/client/components/confirm-dialog.js +37 -0
- package/dist/esm/client/components/cursor-pagination.js +19 -0
- package/dist/esm/client/components/filter-tabs.js +21 -0
- package/dist/esm/client/components/grievance-notice.js +27 -0
- package/dist/esm/client/components/link-button.js +17 -10
- package/dist/esm/client/components/logo.js +4 -3
- package/dist/esm/client/components/nav.js +41 -1
- package/dist/esm/client/components/plan-pricing.js +25 -0
- package/dist/esm/client/components/plans.js +2 -2
- package/dist/esm/client/components/platform-gate.js +45 -0
- package/dist/esm/client/components/shell-context.js +141 -0
- package/dist/esm/client/components/side-nav.js +49 -22
- package/dist/esm/client/components/sidebar.js +120 -0
- package/dist/esm/client/components/subscribe-dialog.js +3 -3
- package/dist/esm/client/components/subscription.js +2 -2
- package/dist/esm/client/components/user-menu.js +121 -41
- package/dist/esm/client/components/workspace-switcher.js +97 -0
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +58 -5
- package/dist/esm/client/provider.js +94 -2
- package/dist/esm/client/use-checkout.js +9 -5
- package/dist/esm/errors.js +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/next/index.js +2 -2
- package/dist/esm/server/client.js +25 -2
- package/dist/esm/server/handler.js +237 -16
- package/dist/esm/server/http.js +52 -26
- package/dist/esm/server/resources/acting-as.js +13 -0
- package/dist/esm/server/resources/ai.js +15 -2
- package/dist/esm/server/resources/data.js +369 -0
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +9 -3
- package/dist/esm/server/resources/kv.js +80 -0
- package/dist/esm/server/resources/platform.js +39 -0
- package/dist/esm/server/resources/sessions.js +1 -1
- package/dist/esm/server/resources/workspaces.js +70 -0
- package/dist/esm/store.js +141 -0
- package/dist/esm/ui/empty-state.js +25 -0
- package/dist/esm/ui/index.js +34 -0
- package/dist/esm/ui/page-header.js +24 -0
- package/dist/esm/ui/page-shell.js +41 -0
- package/dist/esm/ui/section.js +17 -0
- package/dist/esm/ui/setting-row.js +11 -0
- package/dist/esm/ui/status-chip.js +14 -0
- package/dist/theme.css +87 -0
- package/dist/types/client/components/access-gate.d.ts +17 -3
- package/dist/types/client/components/ai-format.d.ts +24 -0
- package/dist/types/client/components/ai-widgets.d.ts +42 -0
- package/dist/types/client/components/app-shell.d.ts +85 -31
- package/dist/types/client/components/apps-grid.d.ts +11 -2
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/confirm-dialog.d.ts +34 -0
- package/dist/types/client/components/cursor-pagination.d.ts +27 -0
- package/dist/types/client/components/filter-tabs.d.ts +26 -0
- package/dist/types/client/components/grievance-notice.d.ts +31 -0
- package/dist/types/client/components/link-button.d.ts +32 -9
- package/dist/types/client/components/logo.d.ts +18 -2
- package/dist/types/client/components/nav.d.ts +27 -3
- package/dist/types/client/components/plan-pricing.d.ts +10 -0
- package/dist/types/client/components/platform-gate.d.ts +53 -0
- package/dist/types/client/components/shell-context.d.ts +116 -0
- package/dist/types/client/components/side-nav.d.ts +28 -14
- package/dist/types/client/components/sidebar.d.ts +95 -0
- package/dist/types/client/components/user-menu.d.ts +70 -23
- package/dist/types/client/components/workspace-switcher.d.ts +49 -0
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +36 -7
- package/dist/types/client/provider.d.ts +149 -3
- package/dist/types/client/use-checkout.d.ts +8 -4
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/next/index.d.ts +1 -1
- package/dist/types/server/client.d.ts +25 -2
- package/dist/types/server/http.d.ts +14 -3
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/resources/acting-as.d.ts +11 -0
- package/dist/types/server/resources/ai.d.ts +9 -1
- package/dist/types/server/resources/data.d.ts +258 -0
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/server/resources/kv.d.ts +63 -0
- package/dist/types/server/resources/platform.d.ts +32 -0
- package/dist/types/server/resources/workspaces.d.ts +55 -0
- package/dist/types/store.d.ts +96 -0
- package/dist/types/types.d.ts +491 -13
- package/dist/types/ui/empty-state.d.ts +34 -0
- package/dist/types/ui/index.d.ts +40 -0
- package/dist/types/ui/page-header.d.ts +34 -0
- package/dist/types/ui/page-shell.d.ts +39 -0
- package/dist/types/ui/section.d.ts +26 -0
- package/dist/types/ui/setting-row.d.ts +18 -0
- package/dist/types/ui/status-chip.d.ts +36 -0
- package/package.json +13 -3
|
@@ -167,41 +167,253 @@ 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
|
-
|
|
176
|
-
|
|
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 {
|
|
275
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
181
276
|
const [readiness, subscription] = await Promise.all([
|
|
182
|
-
client.entitlements.getReadiness(
|
|
183
|
-
client.entitlements.getSubscription(
|
|
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 {
|
|
193
|
-
return json(await client.billing.getAddonUsage(
|
|
301
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
302
|
+
return json(await client.billing.getAddonUsage(workspaceId));
|
|
194
303
|
},
|
|
195
304
|
"GET /apps": async (request) => {
|
|
196
|
-
const {
|
|
197
|
-
return json({ apps: await client.apps.list(
|
|
305
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
306
|
+
return json({ apps: await client.apps.list(workspaceId) });
|
|
307
|
+
},
|
|
308
|
+
/**
|
|
309
|
+
* Ask AI. The one streaming route here, and the only one that does not
|
|
310
|
+
* answer JSON - it forwards Connect's server-sent event stream through
|
|
311
|
+
* untouched so `<ConnectAskAi>` can render tokens as they arrive.
|
|
312
|
+
*
|
|
313
|
+
* Same rule as every other route: the workspace comes from the caller's
|
|
314
|
+
* own session. It matters more here than anywhere else, because the
|
|
315
|
+
* answers are *retrieved from that workspace's Context Index* - a
|
|
316
|
+
* client-supplied `workspaceId` would be a read of another tenant's
|
|
317
|
+
* documents, laundered through a chat reply.
|
|
318
|
+
*
|
|
319
|
+
* Messages are forwarded as-is (part-based `UIMessage`s), and only their
|
|
320
|
+
* shape is checked: the conversation is the caller's own, and Connect
|
|
321
|
+
* validates it again on arrival.
|
|
322
|
+
*/
|
|
323
|
+
"POST /ai/chat": async (request) => {
|
|
324
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
325
|
+
const body = (await request.json().catch(() => null));
|
|
326
|
+
if (!Array.isArray(body?.messages)) {
|
|
327
|
+
throw new errors_js_1.ConnectError({
|
|
328
|
+
status: 400,
|
|
329
|
+
code: "validation_error",
|
|
330
|
+
message: "messages is required",
|
|
331
|
+
endpoint: "POST /ai/chat",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const upstream = await client.ai.chat({
|
|
335
|
+
workspaceId: workspaceId,
|
|
336
|
+
messages: body.messages,
|
|
337
|
+
// Connect re-derives the workspace from this token, so the line above
|
|
338
|
+
// is belt to its braces - the browser cannot reach another tenant's
|
|
339
|
+
// Context Index even if this route were changed to trust the request.
|
|
340
|
+
asUser: await requireToken(request),
|
|
341
|
+
});
|
|
342
|
+
// `upstream.body` is piped, never buffered - awaiting it would collect
|
|
343
|
+
// the whole answer server-side and deliver it in one lump, which is the
|
|
344
|
+
// only thing streaming was for. The browser cancelling its read
|
|
345
|
+
// propagates down this pipe and stops generation upstream.
|
|
346
|
+
return new Response(upstream.body, {
|
|
347
|
+
status: upstream.status,
|
|
348
|
+
headers: {
|
|
349
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
350
|
+
"cache-control": "no-store",
|
|
351
|
+
"x-content-type-options": "nosniff",
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
/**
|
|
356
|
+
* The store, forwarded.
|
|
357
|
+
*
|
|
358
|
+
* These are the only bridge routes with a *dynamic* path, and the only
|
|
359
|
+
* ones that forward a browser-supplied body to Connect. Both are safe for
|
|
360
|
+
* the same reason the rest of the bridge is: the caller's session token
|
|
361
|
+
* goes with the request as `asUser`, and Connect derives the user, their
|
|
362
|
+
* workspace and their permissions from it - so a browser cannot name
|
|
363
|
+
* another tenant, cannot claim a permission it does not hold, and cannot
|
|
364
|
+
* reach a collection this application never declared.
|
|
365
|
+
*
|
|
366
|
+
* `workspaceId` is still sent because the wire contract requires one, but
|
|
367
|
+
* Connect ignores it whenever a session token is present. It is filled
|
|
368
|
+
* from `whoami()` rather than the request either way, so this route never
|
|
369
|
+
* relays a client-supplied workspace even to be discarded.
|
|
370
|
+
*/
|
|
371
|
+
"STORE": async (request) => storeRoute(request),
|
|
372
|
+
/**
|
|
373
|
+
* The workspace switcher's three reads, forwarded with the caller's own
|
|
374
|
+
* session token.
|
|
375
|
+
*
|
|
376
|
+
* `POST /workspaces/switch` is the only bridge route that *changes the
|
|
377
|
+
* caller's session*, so it writes the new token back through the same
|
|
378
|
+
* `session.write` the OAuth callback uses. Handing the raw token to the
|
|
379
|
+
* browser instead would put a credential in JavaScript's hands, which is
|
|
380
|
+
* exactly what this bridge exists to avoid - the browser gets the new
|
|
381
|
+
* workspace's name and a `Set-Cookie`, never the token itself.
|
|
382
|
+
*
|
|
383
|
+
* A partner with no `session.write` cannot re-persist a rotated token, so
|
|
384
|
+
* switching is simply absent for them rather than half-working - the same
|
|
385
|
+
* treatment `/signin` and `/callback` already get.
|
|
386
|
+
*/
|
|
387
|
+
"GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
|
|
388
|
+
"GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
|
|
389
|
+
"POST /workspaces/switch": async (request) => {
|
|
390
|
+
const token = await requireToken(request);
|
|
391
|
+
const body = (await request.json().catch(() => null));
|
|
392
|
+
if (typeof body?.workspaceId !== "string") {
|
|
393
|
+
throw new errors_js_1.ConnectError({
|
|
394
|
+
status: 400,
|
|
395
|
+
code: "validation_error",
|
|
396
|
+
message: "workspaceId is required",
|
|
397
|
+
endpoint: "POST /workspaces/switch",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const result = await client.workspaces.switch({ workspaceId: body.workspaceId }, { asUser: token });
|
|
401
|
+
// The response is built first so `session.write` has something to set
|
|
402
|
+
// cookies on, matching how `/callback` does it.
|
|
403
|
+
const response = json({
|
|
404
|
+
workspaceId: result.workspaceId,
|
|
405
|
+
workspaceName: result.workspaceName,
|
|
406
|
+
workspaceType: result.workspaceType,
|
|
407
|
+
});
|
|
408
|
+
await session.write?.(request, response, result.sessionToken);
|
|
409
|
+
return response;
|
|
198
410
|
},
|
|
199
411
|
"GET /integrations": async (request) => {
|
|
200
|
-
const {
|
|
201
|
-
return json({ toolkits: await client.integrations.list(
|
|
412
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
413
|
+
return json({ toolkits: await client.integrations.list(workspaceId) });
|
|
202
414
|
},
|
|
203
415
|
"POST /integrations/connect": async (request) => {
|
|
204
|
-
const {
|
|
416
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
205
417
|
const body = (await request.json().catch(() => null));
|
|
206
418
|
if (!body?.toolkitSlug || !body?.returnUrl) {
|
|
207
419
|
throw new errors_js_1.ConnectError({
|
|
@@ -211,10 +423,10 @@ function createConnectHandler(options) {
|
|
|
211
423
|
endpoint: "POST /integrations/connect",
|
|
212
424
|
});
|
|
213
425
|
}
|
|
214
|
-
return json(await client.integrations.initiateConnect(
|
|
426
|
+
return json(await client.integrations.initiateConnect(workspaceId, body.toolkitSlug, body.returnUrl));
|
|
215
427
|
},
|
|
216
428
|
"POST /integrations/disconnect": async (request) => {
|
|
217
|
-
const {
|
|
429
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
218
430
|
const body = (await request.json().catch(() => null));
|
|
219
431
|
if (!body?.toolkitSlug) {
|
|
220
432
|
throw new errors_js_1.ConnectError({
|
|
@@ -224,7 +436,7 @@ function createConnectHandler(options) {
|
|
|
224
436
|
endpoint: "POST /integrations/disconnect",
|
|
225
437
|
});
|
|
226
438
|
}
|
|
227
|
-
await client.integrations.disconnect(
|
|
439
|
+
await client.integrations.disconnect(workspaceId, body.toolkitSlug);
|
|
228
440
|
return json({ ok: true });
|
|
229
441
|
},
|
|
230
442
|
// Account-wide, not scoped to this application - see `SessionsResource`'s
|
|
@@ -367,11 +579,20 @@ function createConnectHandler(options) {
|
|
|
367
579
|
if (!session.write) {
|
|
368
580
|
delete routes["GET /signin"];
|
|
369
581
|
delete routes["GET /callback"];
|
|
582
|
+
// A switch rotates the session token. Without somewhere to persist the new
|
|
583
|
+
// one, the old one would be revoked and the user silently signed out - so
|
|
584
|
+
// the route does not exist rather than working once and breaking.
|
|
585
|
+
delete routes["POST /workspaces/switch"];
|
|
370
586
|
}
|
|
371
587
|
return async function handle(request) {
|
|
372
588
|
const path = new URL(request.url).pathname;
|
|
373
589
|
const route = (path.startsWith(basePath) ? path.slice(basePath.length) : path).replace(/\/+$/, "") || "/";
|
|
374
|
-
const handler = routes[`${request.method} ${route}`]
|
|
590
|
+
const handler = routes[`${request.method} ${route}`] ??
|
|
591
|
+
// Store routes carry ids in the path, so they cannot be keyed by an
|
|
592
|
+
// exact string. Matched by prefix, and only this closed set - the bridge
|
|
593
|
+
// is deliberately not a generic proxy, and a broader fallback here is
|
|
594
|
+
// exactly how it would become one.
|
|
595
|
+
(isStoreRoute(route) ? routes.STORE : undefined);
|
|
375
596
|
if (!handler) {
|
|
376
597
|
return json({ error: { code: "not_found", message: `No Connect bridge route for ${route}` } }, 404);
|
|
377
598
|
}
|
package/dist/cjs/server/http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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++) {
|
|
@@ -128,33 +132,55 @@ async function request(options, spec) {
|
|
|
128
132
|
// request for minutes.
|
|
129
133
|
await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
|
|
130
134
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
// A cancellable timer rather than `AbortSignal.timeout`, because the
|
|
136
|
+
// timeout has to be *stopped* once this attempt is done reading - and a
|
|
137
|
+
// timeout signal cannot be cleared.
|
|
138
|
+
//
|
|
139
|
+
// That distinction is the whole point for a streaming endpoint. `fetch`
|
|
140
|
+
// resolves the moment the headers land, but its signal keeps governing
|
|
141
|
+
// the body: an `AbortSignal.timeout(20_000)` handed to `ai.chat` tears the
|
|
142
|
+
// SSE stream down mid-answer, twenty seconds in, on every conversation
|
|
143
|
+
// long enough to matter. Clearing the timer in `finally` bounds the
|
|
144
|
+
// request the same way it always did (a `raw` caller still gets its
|
|
145
|
+
// timeout on *reaching* Connect, and a JSON caller still gets it across
|
|
146
|
+
// the body read, which happens inside the `try`) while leaving a stream
|
|
147
|
+
// that has actually started to run as long as it needs. A caller that
|
|
148
|
+
// wants to bound the stream itself passes `spec.signal`.
|
|
149
|
+
const timeoutController = new AbortController();
|
|
150
|
+
const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
151
|
+
const signal = spec.signal
|
|
152
|
+
? AbortSignal.any([spec.signal, timeoutController.signal])
|
|
153
|
+
: timeoutController.signal;
|
|
134
154
|
try {
|
|
135
|
-
response
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
|
|
158
|
+
}
|
|
159
|
+
catch (cause) {
|
|
160
|
+
// A caller-supplied abort is a decision, not a fault - never retried.
|
|
161
|
+
if (spec.signal?.aborted)
|
|
162
|
+
throw cause;
|
|
163
|
+
lastError = new errors_js_1.ConnectError({
|
|
164
|
+
status: 0,
|
|
165
|
+
code: "network_error",
|
|
166
|
+
message: `${endpoint} could not reach Connect`,
|
|
167
|
+
endpoint,
|
|
168
|
+
cause,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (response.ok) {
|
|
173
|
+
if (spec.raw)
|
|
174
|
+
return response;
|
|
175
|
+
return (await readBody(response));
|
|
176
|
+
}
|
|
177
|
+
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
178
|
+
if (!RETRYABLE_STATUS.has(response.status))
|
|
179
|
+
throw lastError;
|
|
136
180
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (spec.signal?.aborted)
|
|
140
|
-
throw cause;
|
|
141
|
-
lastError = new errors_js_1.ConnectError({
|
|
142
|
-
status: 0,
|
|
143
|
-
code: "network_error",
|
|
144
|
-
message: `${endpoint} could not reach Connect`,
|
|
145
|
-
endpoint,
|
|
146
|
-
cause,
|
|
147
|
-
});
|
|
148
|
-
continue;
|
|
181
|
+
finally {
|
|
182
|
+
clearTimeout(timer);
|
|
149
183
|
}
|
|
150
|
-
if (response.ok) {
|
|
151
|
-
if (spec.raw)
|
|
152
|
-
return response;
|
|
153
|
-
return (await readBody(response));
|
|
154
|
-
}
|
|
155
|
-
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
156
|
-
if (!RETRYABLE_STATUS.has(response.status))
|
|
157
|
-
throw lastError;
|
|
158
184
|
}
|
|
159
185
|
throw lastError;
|
|
160
186
|
}
|
|
@@ -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
|
-
/**
|
|
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,12 @@ class AiResource {
|
|
|
18
26
|
return (0, http_js_1.request)(this.transport, {
|
|
19
27
|
method: "POST",
|
|
20
28
|
path: "/ai/chat",
|
|
21
|
-
|
|
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: { workspaceId: input.workspaceId, messages: input.messages },
|
|
34
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
22
35
|
raw: true,
|
|
23
36
|
signal: options.signal,
|
|
24
37
|
});
|