@cosmoops/connect-sdk 0.7.0 → 0.9.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 (51) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +273 -91
  3. package/dist/cjs/client/components/access-gate.js +39 -4
  4. package/dist/cjs/client/components/ai-format.js +135 -0
  5. package/dist/cjs/client/components/ai-widgets.js +232 -0
  6. package/dist/cjs/client/components/app-shell.js +7 -6
  7. package/dist/cjs/client/components/ask-ai.js +334 -0
  8. package/dist/cjs/client/components/nav.js +1 -1
  9. package/dist/cjs/client/components/side-nav.js +1 -1
  10. package/dist/cjs/client/components/user-menu.js +28 -5
  11. package/dist/cjs/client/components/workspace-type-gate.js +48 -0
  12. package/dist/cjs/client/index.js +31 -3
  13. package/dist/cjs/client/provider.js +45 -2
  14. package/dist/cjs/server/client.js +1 -1
  15. package/dist/cjs/server/handler.js +43 -0
  16. package/dist/cjs/server/http.js +46 -24
  17. package/dist/cjs/server/resources/entitlements.js +41 -0
  18. package/dist/cjs/server/resources/identity.js +2 -0
  19. package/dist/esm/client/components/access-gate.js +40 -5
  20. package/dist/esm/client/components/ai-format.js +129 -0
  21. package/dist/esm/client/components/ai-widgets.js +223 -0
  22. package/dist/esm/client/components/app-shell.js +6 -5
  23. package/dist/esm/client/components/ask-ai.js +331 -0
  24. package/dist/esm/client/components/nav.js +1 -1
  25. package/dist/esm/client/components/side-nav.js +1 -1
  26. package/dist/esm/client/components/user-menu.js +28 -5
  27. package/dist/esm/client/components/workspace-type-gate.js +45 -0
  28. package/dist/esm/client/index.js +13 -2
  29. package/dist/esm/client/provider.js +43 -2
  30. package/dist/esm/server/client.js +1 -1
  31. package/dist/esm/server/handler.js +43 -0
  32. package/dist/esm/server/http.js +46 -24
  33. package/dist/esm/server/resources/entitlements.js +41 -0
  34. package/dist/esm/server/resources/identity.js +2 -0
  35. package/dist/theme.css +87 -0
  36. package/dist/types/client/components/access-gate.d.ts +17 -3
  37. package/dist/types/client/components/ai-format.d.ts +24 -0
  38. package/dist/types/client/components/ai-widgets.d.ts +42 -0
  39. package/dist/types/client/components/app-shell.d.ts +21 -5
  40. package/dist/types/client/components/ask-ai.d.ts +73 -0
  41. package/dist/types/client/components/nav.d.ts +3 -3
  42. package/dist/types/client/components/side-nav.d.ts +1 -1
  43. package/dist/types/client/components/user-menu.d.ts +2 -1
  44. package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
  45. package/dist/types/client/index.d.ts +10 -3
  46. package/dist/types/client/provider.d.ts +44 -1
  47. package/dist/types/server/client.d.ts +1 -1
  48. package/dist/types/server/http.d.ts +6 -1
  49. package/dist/types/server/resources/entitlements.d.ts +33 -1
  50. package/dist/types/types.d.ts +174 -0
  51. package/package.json +8 -2
@@ -34,7 +34,7 @@ function NavRow({ item, isActive, hasActiveChild, collapsed, depth, renderLink,
34
34
  * The sidebar's nav column on its own - sections, items, one level of
35
35
  * children, and the active highlight.
36
36
  *
37
- * `ConnectAppShell` renders this inside its rail and its mobile drawer, and it
37
+ * `AppShell` renders this inside its rail and its mobile drawer, and it
38
38
  * is exported for a host that wants the shell's navigation without its
39
39
  * chrome: the same items, the same active resolution, dropped into a layout
40
40
  * you already have.
@@ -19,6 +19,28 @@ function initials(name, email) {
19
19
  function openExternal(url) {
20
20
  window.open(url, "_blank", "noopener,noreferrer");
21
21
  }
22
+ /**
23
+ * Connect's profile page, with the context it needs to offer a way back here.
24
+ *
25
+ * Connect answers `/profile` for any signed-in user, so the bare URL works on
26
+ * its own - these two params only add the "Back to <your app>" banner. It
27
+ * validates `return_to` against this `client_id`'s registered redirect
28
+ * origins and silently drops the banner if they don't match, so a wrong or
29
+ * stale `appUrl`/`clientId` pairing degrades to what it did before rather
30
+ * than erroring.
31
+ *
32
+ * `window.location.href` rather than a configured home URL: the point is to
33
+ * come back to the page they left, and only the browser knows which one that
34
+ * is.
35
+ */
36
+ function profileUrl(appUrl, clientId) {
37
+ const url = new URL("/profile", appUrl);
38
+ url.searchParams.set("client_id", clientId);
39
+ if (typeof window !== "undefined") {
40
+ url.searchParams.set("return_to", window.location.href);
41
+ }
42
+ return url.toString();
43
+ }
22
44
  /**
23
45
  * The member's photo, with their initials underneath it.
24
46
  *
@@ -114,7 +136,7 @@ function DestinationTile({ icon, label, url, }) {
114
136
  * the modal programmatically; sign-out uses `slot="close"` instead.
115
137
  */
116
138
  export function ConnectUserMenu({ compact = false, onSignOut, className, theme, onThemeChange, }) {
117
- const { session, isLoading, signOut, appUrl } = useConnect();
139
+ const { session, isLoading, signOut, appUrl, clientId } = useConnect();
118
140
  if (isLoading) {
119
141
  return (_jsxs("div", { className: `flex items-center gap-2 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), !compact && _jsx(Skeleton, { className: "h-4 w-24" })] }));
120
142
  }
@@ -128,7 +150,7 @@ export function ConnectUserMenu({ compact = false, onSignOut, className, theme,
128
150
  // `pl-1` against the default right padding is optical, not sloppy: a
129
151
  // circular avatar needs less inset than a text label to look evenly
130
152
  // seated in the button.
131
- className: `gap-2 pl-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: `${appUrl}/profile` }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
153
+ className: `gap-2 pl-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: profileUrl(appUrl, clientId) }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
132
154
  const [next] = [...keys];
133
155
  if (typeof next === "string")
134
156
  onThemeChange(next);
@@ -167,13 +189,14 @@ export function ConnectInvoicesButton({ className }) {
167
189
  * `ConnectHomeButton`.
168
190
  *
169
191
  * It was reachable only from inside `ConnectUserMenu`, which meant an app that
170
- * builds its own account menu had to hardcode `${appUrl}/profile` to offer it.
192
+ * builds its own account menu had to hardcode `${appUrl}/profile` to offer it -
193
+ * and got no `return_to`, so the user had no way back.
171
194
  */
172
195
  export function ConnectProfileButton({ className }) {
173
- const { session, appUrl } = useConnect();
196
+ const { session, appUrl, clientId } = useConnect();
174
197
  if (!session?.user)
175
198
  return null;
176
- return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(`${appUrl}/profile`), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
199
+ return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(profileUrl(appUrl, clientId)), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
177
200
  }
178
201
  /** A plain "Open Connect" button, for a footer or a help menu. */
179
202
  export function ConnectHomeButton({ className }) {
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ import { useConnect } from "../provider.js";
4
+ /**
5
+ * Renders `children` only for a workspace of the given type.
6
+ *
7
+ * ```tsx
8
+ * <ConnectWorkspaceTypeGate type="business">
9
+ * <TeamMembersTable />
10
+ * </ConnectWorkspaceTypeGate>
11
+ * ```
12
+ *
13
+ * The point is dead controls, not access: an `"individual"` workspace has
14
+ * exactly one seat and can never have another, so a members table, an invite
15
+ * button and a seat meter are all things that can only ever fail there.
16
+ * Hiding them is a better answer than letting someone press one and read an
17
+ * error about an account shape they did not choose.
18
+ *
19
+ * This is emphatically **not** the audience gate. It does not know what your
20
+ * application is sold to and will happily render a business-only subtree to a
21
+ * business workspace that has no business being in your application at all -
22
+ * that is `<ConnectAccessGate>`'s job, off the `audience_mismatch` readiness
23
+ * blocker. Use this one *inside* an application both audiences can reach, to
24
+ * vary what each sees.
25
+ *
26
+ * Like every gate in this package it is a **UX gate, not a security
27
+ * boundary**: the browser can ignore it. Anything that must not happen for an
28
+ * individual workspace has to be refused on your server too.
29
+ *
30
+ * Renders `loading` - not `fallback` - while the session is unknown, so a
31
+ * business account never flickers through the individual-account layout.
32
+ */
33
+ export function ConnectWorkspaceTypeGate({ type, children, fallback = null, loading = null, }) {
34
+ const { session, isLoading } = useConnect();
35
+ const workspaceType = session?.user?.workspaceType ?? null;
36
+ if (isLoading)
37
+ return _jsx(_Fragment, { children: loading });
38
+ // Signed out is not "the other type" - there is no workspace to be a type
39
+ // of yet, and rendering the fallback here would put an individual-account
40
+ // layout in front of a visitor who has not signed in.
41
+ if (!workspaceType)
42
+ return _jsx(_Fragment, { children: loading });
43
+ const accepted = Array.isArray(type) ? type : [type];
44
+ return _jsx(_Fragment, { children: accepted.includes(workspaceType) ? children : fallback });
45
+ }
@@ -11,7 +11,7 @@
11
11
  * whatever theme the host application platform already configured, and one theme
12
12
  * change lands identically in all of them.
13
13
  */
14
- export { ConnectProvider, useConnect, useConnectUser, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
14
+ export { ConnectProvider, useConnect, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
15
15
  // Re-exported from the root entry rather than kept there alone: it is a pure
16
16
  // helper, but every caller is UI code, and making a component file reach two
17
17
  // entry points for one function is friction with nothing behind it.
@@ -28,7 +28,7 @@ export { ConnectAccountSessions } from "./components/account-sessions.js";
28
28
  // pieces it composes rather than in a section of its own - an integration
29
29
  // reaching for a sidebar wants to find the launcher and the account menu in
30
30
  // the same breath, since the shell already mounts both.
31
- export { ConnectAppShell } from "./components/app-shell.js";
31
+ export { AppShell } from "./components/app-shell.js";
32
32
  export { ConnectSideNav } from "./components/side-nav.js";
33
33
  export { useActivePath } from "./use-active-path.js";
34
34
  // The nav model is plain data and pure functions - a host generating its nav
@@ -37,6 +37,16 @@ export { useActivePath } from "./use-active-path.js";
37
37
  // page title derived from it cannot disagree with the rail.
38
38
  export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, } from "./components/nav.js";
39
39
  export { ConnectAppsGrid } from "./components/apps-grid.js";
40
+ // `AppShell askAi` mounts this; exported for an app that wants the panel
41
+ // without the frame, or wants it somewhere other than the shell's flex row
42
+ // (at which point it overlays rather than pushes - see its doc comment).
43
+ export { ConnectAskAi } from "./components/ask-ai.js";
44
+ // The seven widgets an Ask AI answer can draw, and the formatter they share -
45
+ // exported so a partner rendering Connect's chat somewhere else entirely (a
46
+ // support console, an embedded assistant) gets the same table, chart and metric
47
+ // tiles rather than reimplementing them from the wire types.
48
+ export { AiTable, AiChart, AiMetrics, AiTimeline, AiDetails, AiSteps, AiSources, } from "./components/ai-widgets.js";
49
+ export { formatAiValue, isNumericAiFormat, aiStatusTone, AI_EMPTY_VALUE, AI_TONE_CHIP, AI_TONE_DOT, } from "./components/ai-format.js";
40
50
  export { ConnectIntegrationsModal } from "./components/integrations-modal.js";
41
51
  export { ConnectPlans } from "./components/plans.js";
42
52
  export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
@@ -49,6 +59,7 @@ export { ConnectSubscriptionGate, ConnectNoSubscriptionCard, ConnectSubscription
49
59
  export { ConnectReadinessBanner, ConnectReadinessGate } from "./components/readiness.js";
50
60
  export { ConnectAccessGate, useConnectAccessGate } from "./components/access-gate.js";
51
61
  export { ConnectFeatureGate, useConnectFeature } from "./components/feature-gate.js";
62
+ export { ConnectWorkspaceTypeGate } from "./components/workspace-type-gate.js";
52
63
  export { ConnectUsageMeter } from "./components/usage-meter.js";
53
64
  export { ConnectPlansPage } from "./components/plans-page.js";
54
65
  export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
@@ -111,6 +111,20 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
111
111
  const invalidateResource = useCallback((path) => {
112
112
  cache.current.delete(`${basePath}${path}`);
113
113
  }, [basePath]);
114
+ const streamResource = useCallback(async (path, body, options) => {
115
+ const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
116
+ method: "POST",
117
+ credentials: "same-origin",
118
+ headers: { accept: "text/event-stream", "content-type": "application/json" },
119
+ body: JSON.stringify(body),
120
+ signal: options?.signal,
121
+ });
122
+ if (!response.ok) {
123
+ const envelope = await response.json().catch(() => null);
124
+ throw new Error(envelope?.error?.message ?? `Connect bridge returned ${response.status}`);
125
+ }
126
+ return response;
127
+ }, [basePath, fetchImpl]);
114
128
  const refresh = useCallback(async () => {
115
129
  cache.current.clear();
116
130
  try {
@@ -122,7 +136,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
122
136
  // error, so a Login button still renders rather than the UI hanging on
123
137
  // skeletons forever.
124
138
  setResult({
125
- session: { user: null, subscription: null, applicationRole: null },
139
+ session: { user: null, subscription: null, applicationRole: null, audience: null },
126
140
  error: cause instanceof Error ? cause.message : "Could not reach Connect",
127
141
  });
128
142
  }
@@ -171,7 +185,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
171
185
  credentials: "same-origin",
172
186
  }).catch(() => { });
173
187
  cache.current.clear();
174
- setResult({ session: { user: null, subscription: null, applicationRole: null }, error: null });
188
+ setResult({ session: { user: null, subscription: null, applicationRole: null, audience: null }, error: null });
175
189
  }, [basePath, fetchImpl]);
176
190
  const value = useMemo(() => ({
177
191
  clientId,
@@ -185,6 +199,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
185
199
  fetchResource,
186
200
  mutateResource,
187
201
  invalidateResource,
202
+ streamResource,
188
203
  }), [
189
204
  clientId,
190
205
  appUrl,
@@ -197,6 +212,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
197
212
  fetchResource,
198
213
  mutateResource,
199
214
  invalidateResource,
215
+ streamResource,
200
216
  ]);
201
217
  return _jsx(ConnectContext.Provider, { value: value, children: children });
202
218
  }
@@ -211,6 +227,31 @@ export function useConnect() {
211
227
  export function useConnectUser() {
212
228
  return useConnect().session?.user ?? null;
213
229
  }
230
+ /**
231
+ * Whether the signed-in workspace is a one-seat personal account or a team -
232
+ * `null` while the session is loading or signed out.
233
+ *
234
+ * Branch on this to stop rendering team UI at a solo account: a members
235
+ * table, an invite button and a seat meter are all dead controls on an
236
+ * `"individual"` workspace, which has exactly one seat and cannot gain
237
+ * another. `<ConnectWorkspaceTypeGate>` is the declarative form.
238
+ */
239
+ export function useConnectWorkspaceType() {
240
+ return useConnect().session?.user?.workspaceType ?? null;
241
+ }
242
+ /**
243
+ * Whether this account is the kind your application is sold to - `null` while
244
+ * the session is loading or signed out.
245
+ *
246
+ * `audience.matches === false` is a hard block, not a hint: readiness reports
247
+ * `audience_mismatch` and subscribing is refused. `<ConnectAccessGate>`
248
+ * already blocks the shell on it, so reach for this only when you need the
249
+ * verdict somewhere the gate does not run - a marketing page, say, or a
250
+ * sign-up CTA you would rather not show to an account that cannot complete it.
251
+ */
252
+ export function useConnectAudience() {
253
+ return useConnect().session?.audience ?? null;
254
+ }
214
255
  /** Readiness gates and the live subscription, fetched on first use. */
215
256
  export function useConnectEntitlements() {
216
257
  return useConnectResource("/entitlements");
@@ -115,4 +115,4 @@ export class ConnectClient {
115
115
  }
116
116
  }
117
117
  /** Kept in step with `package.json` by `sdk.test.ts`. */
118
- export const SDK_VERSION = "0.7.0";
118
+ export const SDK_VERSION = "0.9.0";
@@ -193,6 +193,49 @@ export function createConnectHandler(options) {
193
193
  const { user } = await whoami(request);
194
194
  return json({ apps: await client.apps.list(user.workspaceId) });
195
195
  },
196
+ /**
197
+ * Ask AI. The one streaming route here, and the only one that does not
198
+ * answer JSON - it forwards Connect's server-sent event stream through
199
+ * untouched so `<ConnectAskAi>` can render tokens as they arrive.
200
+ *
201
+ * Same rule as every other route: the workspace comes from the caller's
202
+ * own session. It matters more here than anywhere else, because the
203
+ * answers are *retrieved from that workspace's Context Index* - a
204
+ * client-supplied `workspaceId` would be a read of another tenant's
205
+ * documents, laundered through a chat reply.
206
+ *
207
+ * Messages are forwarded as-is (part-based `UIMessage`s), and only their
208
+ * shape is checked: the conversation is the caller's own, and Connect
209
+ * validates it again on arrival.
210
+ */
211
+ "POST /ai/chat": async (request) => {
212
+ const { user } = await whoami(request);
213
+ const body = (await request.json().catch(() => null));
214
+ if (!Array.isArray(body?.messages)) {
215
+ throw new ConnectError({
216
+ status: 400,
217
+ code: "validation_error",
218
+ message: "messages is required",
219
+ endpoint: "POST /ai/chat",
220
+ });
221
+ }
222
+ const upstream = await client.ai.chat({
223
+ workspaceId: user.workspaceId,
224
+ messages: body.messages,
225
+ });
226
+ // `upstream.body` is piped, never buffered - awaiting it would collect
227
+ // the whole answer server-side and deliver it in one lump, which is the
228
+ // only thing streaming was for. The browser cancelling its read
229
+ // propagates down this pipe and stops generation upstream.
230
+ return new Response(upstream.body, {
231
+ status: upstream.status,
232
+ headers: {
233
+ "content-type": upstream.headers.get("content-type") ?? "text/event-stream",
234
+ "cache-control": "no-store",
235
+ "x-content-type-options": "nosniff",
236
+ },
237
+ });
238
+ },
196
239
  "GET /integrations": async (request) => {
197
240
  const { user } = await whoami(request);
198
241
  return json({ toolkits: await client.integrations.list(user.workspaceId) });
@@ -124,33 +124,55 @@ export async function request(options, spec) {
124
124
  // request for minutes.
125
125
  await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
126
126
  }
127
- const timeout = AbortSignal.timeout(options.timeoutMs);
128
- const signal = spec.signal ? AbortSignal.any([spec.signal, timeout]) : timeout;
129
- let response;
127
+ // A cancellable timer rather than `AbortSignal.timeout`, because the
128
+ // timeout has to be *stopped* once this attempt is done reading - and a
129
+ // timeout signal cannot be cleared.
130
+ //
131
+ // That distinction is the whole point for a streaming endpoint. `fetch`
132
+ // resolves the moment the headers land, but its signal keeps governing
133
+ // the body: an `AbortSignal.timeout(20_000)` handed to `ai.chat` tears the
134
+ // SSE stream down mid-answer, twenty seconds in, on every conversation
135
+ // long enough to matter. Clearing the timer in `finally` bounds the
136
+ // request the same way it always did (a `raw` caller still gets its
137
+ // timeout on *reaching* Connect, and a JSON caller still gets it across
138
+ // the body read, which happens inside the `try`) while leaving a stream
139
+ // that has actually started to run as long as it needs. A caller that
140
+ // wants to bound the stream itself passes `spec.signal`.
141
+ const timeoutController = new AbortController();
142
+ const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
143
+ const signal = spec.signal
144
+ ? AbortSignal.any([spec.signal, timeoutController.signal])
145
+ : timeoutController.signal;
130
146
  try {
131
- response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
147
+ let response;
148
+ try {
149
+ response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
150
+ }
151
+ catch (cause) {
152
+ // A caller-supplied abort is a decision, not a fault - never retried.
153
+ if (spec.signal?.aborted)
154
+ throw cause;
155
+ lastError = new ConnectError({
156
+ status: 0,
157
+ code: "network_error",
158
+ message: `${endpoint} could not reach Connect`,
159
+ endpoint,
160
+ cause,
161
+ });
162
+ continue;
163
+ }
164
+ if (response.ok) {
165
+ if (spec.raw)
166
+ return response;
167
+ return (await readBody(response));
168
+ }
169
+ lastError = toConnectError(response, await readBody(response), endpoint);
170
+ if (!RETRYABLE_STATUS.has(response.status))
171
+ throw lastError;
132
172
  }
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;
173
+ finally {
174
+ clearTimeout(timer);
145
175
  }
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
176
  }
155
177
  throw lastError;
156
178
  }
@@ -42,4 +42,45 @@ export class EntitlementsResource {
42
42
  const result = await this.getSubscription(workspaceId);
43
43
  return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
44
44
  }
45
+ /**
46
+ * One page of the workspaces subscribed to your application, newest first.
47
+ *
48
+ * The inverse of `getSubscription`, which asks about one workspace you
49
+ * already know. This asks who your subscribers *are* - what a partner
50
+ * dashboard, an MRR total or a churn report is built on.
51
+ *
52
+ * Rate-limited far more tightly than the per-workspace read (10/min per
53
+ * key, against 60): it walks your whole subscriber base and is a reporting
54
+ * call, not a per-request gate. Use `getSubscription` in a hot path.
55
+ *
56
+ * Your application is resolved from the API key, so there is no parameter
57
+ * for it and no way to ask about anyone else's.
58
+ */
59
+ listSubscribers(options = {}) {
60
+ return request(this.transport, {
61
+ method: "GET",
62
+ path: "/subscriptions",
63
+ query: { status: options.status, before: options.before, limit: options.limit },
64
+ });
65
+ }
66
+ /**
67
+ * Every subscriber, paged through for you.
68
+ *
69
+ * An async generator rather than an array: a large subscriber base is
70
+ * exactly where an accidental full materialisation hurts, and `for await`
71
+ * reads the same as a loop over one page.
72
+ */
73
+ async *iterateSubscribers(options = {}) {
74
+ let before;
75
+ do {
76
+ const page = await this.listSubscribers({
77
+ status: options.status,
78
+ limit: options.pageSize,
79
+ before,
80
+ });
81
+ for (const item of page.items)
82
+ yield item;
83
+ before = page.nextBefore ?? undefined;
84
+ } while (before);
85
+ }
45
86
  }
@@ -67,6 +67,7 @@ export class IdentityResource {
67
67
  user: wire.user,
68
68
  subscription: wire.subscription,
69
69
  applicationRole: wire.applicationRole,
70
+ audience: wire.audience,
70
71
  };
71
72
  }
72
73
  /**
@@ -137,6 +138,7 @@ export class IdentityResource {
137
138
  user: wire.user,
138
139
  subscription: wire.subscription,
139
140
  applicationRole: wire.applicationRole,
141
+ audience: wire.audience,
140
142
  sessionExpiresAt: wire.session_expires_at,
141
143
  };
142
144
  }
package/dist/theme.css CHANGED
@@ -110,3 +110,90 @@ body {
110
110
  background: var(--background);
111
111
  color: var(--foreground);
112
112
  }
113
+
114
+ /* ---------------------------------------------------------------------------
115
+ * Ask AI: Streamdown token compatibility.
116
+ *
117
+ * `ConnectAskAi` renders markdown with Streamdown, which paints using
118
+ * shadcn/ui's token names. HeroUI defines a different set - four happen to line
119
+ * up already (`--background`, `--foreground`, `--border`, `--radius`), and the
120
+ * rest are aliased here so an answer inherits the platform's own theme instead
121
+ * of rendering with missing backgrounds and borders.
122
+ *
123
+ * This lives in the shipped theme rather than in each partner's CSS because it
124
+ * is a fact about how these two libraries name colours, not a design choice
125
+ * anyone should have to rediscover.
126
+ *
127
+ * Additive - HeroUI uses none of these names - with exactly one real collision,
128
+ * handled below.
129
+ * ------------------------------------------------------------------------ */
130
+ :root,
131
+ .light,
132
+ .default,
133
+ [data-theme="light"],
134
+ [data-theme="default"],
135
+ .dark,
136
+ [data-theme="dark"] {
137
+ --card: var(--surface);
138
+ --card-foreground: var(--surface-foreground);
139
+ --input: var(--border);
140
+ --primary: var(--accent);
141
+ --primary-foreground: var(--accent-foreground);
142
+ }
143
+
144
+ /* ---------------------------------------------------------------------------
145
+ * The collision: `--muted` is a *surface* in shadcn (code-block fills, table
146
+ * header rows) and a *text colour* in HeroUI. Reading it as HeroUI's would
147
+ * paint every code block mid-grey and leave the text on it unreadable.
148
+ *
149
+ * So it is shadowed - but only inside the answer body, and only after the host
150
+ * element has captured HeroUI's value under shadcn's name for it. Splitting it
151
+ * across two elements is what makes this safe: `--muted-foreground` is computed
152
+ * on `.connect-ai-host`, where `--muted` still means HeroUI's text grey, and
153
+ * inherits that resolved colour downward. Both declarations on one element
154
+ * would resolve `var(--muted)` against the new value and lose the original.
155
+ * ------------------------------------------------------------------------ */
156
+ .connect-ai-host {
157
+ --muted-foreground: var(--muted);
158
+ }
159
+
160
+ .connect-ai-host .connect-ai-body {
161
+ --muted: var(--surface-secondary);
162
+ }
163
+
164
+ /* ---------------------------------------------------------------------------
165
+ * The Ask AI "thinking" shimmer - a highlight sweeping through the label while
166
+ * the model works, rather than a spinner.
167
+ *
168
+ * A gradient clipped to the glyphs, so the motion is *in* the word instead of
169
+ * beside it. This is the one state where the panel has nothing at all to show:
170
+ * retrieval and the model's first thought take a second or two, and a static
171
+ * "Thinking" reads as frozen.
172
+ * ------------------------------------------------------------------------ */
173
+ .connect-ai-shimmer {
174
+ background: linear-gradient(100deg, var(--muted) 32%, var(--accent) 48%, var(--muted) 64%);
175
+ background-size: 220% 100%;
176
+ -webkit-background-clip: text;
177
+ background-clip: text;
178
+ color: transparent;
179
+ animation: connect-ai-shimmer 1.7s linear infinite;
180
+ }
181
+
182
+ @keyframes connect-ai-shimmer {
183
+ from {
184
+ background-position: 180% 0;
185
+ }
186
+ to {
187
+ background-position: -40% 0;
188
+ }
189
+ }
190
+
191
+ /* Motion is the whole affordance here, so with it suppressed the label has to
192
+ go back to being a legible static word rather than a transparent one. */
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .connect-ai-shimmer {
195
+ background: none;
196
+ color: var(--muted);
197
+ animation: none;
198
+ }
199
+ }
@@ -5,6 +5,12 @@ export type ConnectAccessGateState = "loading"
5
5
  | "signed_out"
6
6
  /** `workspace_suspended` - a platform admin took this workspace offline. Hard block, no dismiss. */
7
7
  | "suspended"
8
+ /**
9
+ * `audience_mismatch` - this application serves individual accounts and the
10
+ * workspace is a business, or the reverse. Hard block with nothing to
11
+ * offer: `Workspace.type` is fixed at sign-up.
12
+ */
13
+ | "audience_mismatch"
8
14
  /** `subscription_missing` - nothing to subscribe to yet. Hard block. */
9
15
  | "no_subscription"
10
16
  /** `onboarding_approval_pending` - waiting on a reviewer. Hard block, no action to offer. */
@@ -33,9 +39,17 @@ export interface ConnectAccessGateResult {
33
39
  * `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
34
40
  * is a priority ordering over the same data, not a second opinion about it.
35
41
  *
36
- * Priority, first match wins: `suspended` > `no_subscription` >
37
- * `onboarding_pending` > any other blocking code (`blocked`) >
38
- * `payment_retry` (advisory - `children` still render) > `clear`.
42
+ * Priority, first match wins: `suspended` > `audience_mismatch` >
43
+ * `no_subscription` > `onboarding_pending` > any other blocking code
44
+ * (`blocked`) > `payment_retry` (advisory - `children` still render) >
45
+ * `clear`.
46
+ *
47
+ * `audience_mismatch` outranks `no_subscription` deliberately, and the
48
+ * ordering is the whole point of it: a workspace of the wrong account type
49
+ * carries BOTH blockers at once, and showing the subscription one first sent
50
+ * the customer to a plan picker whose subscribe call
51
+ * (`ActivateSubscriptionUseCase`) rejects them for the audience anyway. The
52
+ * account type is the reason; the missing subscription is a consequence.
39
53
  */
40
54
  export declare function useConnectAccessGate(): ConnectAccessGateResult;
41
55
  export interface ConnectAccessGateProps {
@@ -0,0 +1,24 @@
1
+ import type { AiValue, AiValueFormat } from "../../types.js";
2
+ /**
3
+ * The one place a model-supplied value becomes a string.
4
+ *
5
+ * Ask AI's presentation tools send raw values with a `format` tag beside them
6
+ * rather than pre-formatted strings (see `AiValueFormat`), and this is the half
7
+ * that makes that worth doing: an amount renders in the viewer's own locale, a
8
+ * table sorts numerically, and the same figure reads identically on a chart
9
+ * axis, in a tooltip and in a metric tile.
10
+ *
11
+ * Every branch is total. A value that cannot be formatted the way it was
12
+ * tagged - a `date` that is not a date, a `currency` that is not a number -
13
+ * falls back to its plain string rather than throwing inside a render.
14
+ */
15
+ /** Renders in place of a missing value - an em dash, not the word "null". */
16
+ export declare const AI_EMPTY_VALUE = "\u2014";
17
+ export declare function formatAiValue(value: AiValue, format?: AiValueFormat): string;
18
+ /** Numeric columns read far better right-aligned; everything else stays with the text. */
19
+ export declare function isNumericAiFormat(format?: AiValueFormat): boolean;
20
+ export type AiStatusTone = "neutral" | "positive" | "warning" | "danger";
21
+ export declare function aiStatusTone(value: AiValue): AiStatusTone;
22
+ /** HeroUI semantic tokens only, so these inherit whatever theme the host defined. */
23
+ export declare const AI_TONE_CHIP: Record<AiStatusTone, string>;
24
+ export declare const AI_TONE_DOT: Record<AiStatusTone, string>;
@@ -0,0 +1,42 @@
1
+ import { type ReactNode } from "react";
2
+ import type { AiChartInput, AiDetailsInput, AiMetricsInput, AiSourcesInput, AiStepsInput, AiTableInput, AiTimelineInput } from "../../types.js";
3
+ /**
4
+ * `showTable`. Sorting and CSV are what a table is *for* - once an answer is
5
+ * tabular the next thing anyone wants is to reorder it or take it elsewhere,
6
+ * and a markdown table can do neither. Both work off the raw values, so
7
+ * "₹12,500" sorts as 12500 and the export carries numbers a spreadsheet can add.
8
+ */
9
+ export declare function AiTable({ title, columns, rows, caption }: Readonly<AiTableInput>): import("react").JSX.Element;
10
+ /**
11
+ * `showChart`. Cartesian kinds are drawn as SVG against a fixed viewBox and
12
+ * scaled by the browser, which is why there is no resize observer here: the
13
+ * chart is resolution-independent, so "responsive" costs nothing at runtime.
14
+ *
15
+ * `pie` is deliberately *not* a pie. In a 400px column a pie plus its legend
16
+ * is smaller than the labels it needs, and reading angles is worse than
17
+ * reading lengths at any size - so proportions render as a labelled bar list,
18
+ * which answers the same question and stays legible.
19
+ */
20
+ export declare function AiChart({ title, kind, xLabel, yLabel, valueFormat, series, caption }: Readonly<AiChartInput>): import("react").JSX.Element;
21
+ /**
22
+ * `showMetrics`. A delta is coloured by `deltaMeaning`, not by its sign -
23
+ * churn rising is bad and revenue rising is good, and painting both green
24
+ * because the arrow points up is exactly the confidently-wrong signal a
25
+ * dashboard must never send. The arrow still shows direction, so meaning and
26
+ * direction stay two separate channels.
27
+ */
28
+ export declare function AiMetrics({ title, metrics }: Readonly<AiMetricsInput>): import("react").JSX.Element;
29
+ /** `showTimeline`: dated events, oldest first, on a single rail. */
30
+ export declare function AiTimeline({ title, events }: Readonly<AiTimelineInput>): import("react").JSX.Element;
31
+ /** `showDetails`: one entity's fields, as a definition list rather than a one-row table. */
32
+ export declare function AiDetails({ title, fields }: Readonly<AiDetailsInput>): import("react").JSX.Element;
33
+ /** `showSteps`: an ordered procedure. `renderDetail` is how the panel feeds each step back through its markdown renderer. */
34
+ export declare function AiSteps({ title, steps, renderDetail, }: Readonly<AiStepsInput & {
35
+ renderDetail?: (markdown: string) => ReactNode;
36
+ }>): import("react").JSX.Element;
37
+ /**
38
+ * `showSources`: what a grounded answer actually rested on. The point of a
39
+ * citation is that it can be opened, so a source with a `uri` is a link and one
40
+ * without is still a named row - never a marker with nothing behind it.
41
+ */
42
+ export declare function AiSources({ sources }: Readonly<AiSourcesInput>): import("react").JSX.Element;