@cosmoops/connect-sdk 0.37.0 → 0.40.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 (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cjs/client/components/availability-form.js +103 -0
  3. package/dist/cjs/client/components/create-workspace-form.js +66 -0
  4. package/dist/cjs/client/components/event-type-form.js +156 -0
  5. package/dist/cjs/client/components/user-menu.js +3 -24
  6. package/dist/cjs/client/components/workspace-switcher.js +42 -9
  7. package/dist/cjs/client/index.js +31 -3
  8. package/dist/cjs/client/portal-links.js +38 -0
  9. package/dist/cjs/client/use-scheduling.js +104 -8
  10. package/dist/cjs/client/use-workspaces.js +41 -0
  11. package/dist/cjs/server/client.js +1 -1
  12. package/dist/cjs/server/handler.js +181 -2
  13. package/dist/cjs/server/http.js +13 -1
  14. package/dist/cjs/server/resources/ai.js +6 -0
  15. package/dist/cjs/server/resources/scheduling.js +129 -0
  16. package/dist/cjs/server/resources/visitor.js +6 -0
  17. package/dist/cjs/server/resources/workspaces.js +30 -11
  18. package/dist/esm/client/components/availability-form.js +100 -0
  19. package/dist/esm/client/components/create-workspace-form.js +63 -0
  20. package/dist/esm/client/components/event-type-form.js +153 -0
  21. package/dist/esm/client/components/user-menu.js +3 -24
  22. package/dist/esm/client/components/workspace-switcher.js +42 -9
  23. package/dist/esm/client/index.js +17 -1
  24. package/dist/esm/client/portal-links.js +34 -0
  25. package/dist/esm/client/use-scheduling.js +98 -8
  26. package/dist/esm/client/use-workspaces.js +38 -0
  27. package/dist/esm/server/client.js +1 -1
  28. package/dist/esm/server/handler.js +181 -2
  29. package/dist/esm/server/http.js +12 -1
  30. package/dist/esm/server/resources/ai.js +7 -1
  31. package/dist/esm/server/resources/scheduling.js +129 -0
  32. package/dist/esm/server/resources/visitor.js +7 -1
  33. package/dist/esm/server/resources/workspaces.js +30 -11
  34. package/dist/types/client/components/availability-form.d.ts +33 -0
  35. package/dist/types/client/components/create-workspace-form.d.ts +32 -0
  36. package/dist/types/client/components/event-type-form.d.ts +44 -0
  37. package/dist/types/client/components/workspace-switcher.d.ts +9 -9
  38. package/dist/types/client/index.d.ts +10 -1
  39. package/dist/types/client/portal-links.d.ts +22 -0
  40. package/dist/types/client/use-scheduling.d.ts +93 -1
  41. package/dist/types/client/use-workspaces.d.ts +27 -0
  42. package/dist/types/server/client.d.ts +1 -1
  43. package/dist/types/server/http.d.ts +35 -0
  44. package/dist/types/server/resources/scheduling.d.ts +131 -1
  45. package/dist/types/server/resources/workspaces.d.ts +28 -0
  46. package/dist/types/types.d.ts +60 -0
  47. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import { Button, Dropdown, ListBox, Separator } from "@heroui/react";
5
5
  import { ChevronsUpDown, Plus } from "lucide-react";
6
6
  import { useConnect, useConnectUser, useConnectWorkspaces } from "../provider.js";
7
+ import { ConnectCreateWorkspaceModal } from "./create-workspace-form.js";
7
8
  import { m } from "./motion.js";
8
9
  import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
9
10
  /** `Acme Logistics` -> `AL`. The collapsed rail's whole label for a workspace. */
@@ -11,9 +12,6 @@ function workspaceInitials(name) {
11
12
  const parts = name.trim().split(/\s+/).filter(Boolean);
12
13
  return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
13
14
  }
14
- function openExternal(url) {
15
- window.open(url, "_blank", "noopener,noreferrer");
16
- }
17
15
  /**
18
16
  * Switches which workspace this application is looking at.
19
17
  *
@@ -28,9 +26,9 @@ function openExternal(url) {
28
26
  * a workspace is *created*, so hiding it at one workspace would guarantee
29
27
  * nobody ever reached two. `alwaysShow` is the escape hatch for a partner
30
28
  * application that wants that same discoverability without becoming Connect:
31
- * it keeps the dropdown open below two workspaces and adds a row that sends
32
- * the member to Connect to actually create one, since there is nothing here
33
- * that can.
29
+ * it keeps the dropdown open below two workspaces and adds a row that opens
30
+ * `<ConnectCreateWorkspaceModal>` inline, auto-switching into the result when
31
+ * that is possible - see `handleCreated` below.
34
32
  *
35
33
  * **Authorization is the data, not a prop.** The list comes from the caller's
36
34
  * own session (`GET /workspaces` takes no user id), so a workspace the user
@@ -50,12 +48,13 @@ function openExternal(url) {
50
48
  * `onSwitched` defaults to.
51
49
  */
52
50
  export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysShow = false, }) {
53
- const { mutateResource, appUrl } = useConnect();
51
+ const { mutateResource } = useConnect();
54
52
  const { collapsed } = useConnectShell();
55
53
  const user = useConnectUser();
56
- const { data, isLoading } = useConnectWorkspaces();
54
+ const { data, isLoading, refetch } = useConnectWorkspaces();
57
55
  const [switching, setSwitching] = useState(null);
58
56
  const [error, setError] = useState(null);
57
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
59
58
  const workspaces = data?.workspaces ?? [];
60
59
  // Nothing loaded yet. Deliberately renders `null` rather than a skeleton:
61
60
  // this sits in persistent chrome, and a placeholder that resolves to
@@ -90,11 +89,45 @@ export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, al
90
89
  setSwitching(null);
91
90
  }
92
91
  };
92
+ /**
93
+ * After a successful create: bring the new row into this list (it is
94
+ * created, not switched to - see `ConnectCreateWorkspaceModal`'s doc
95
+ * comment), then move into it when that is actually possible.
96
+ *
97
+ * Deliberately **not** `handleSwitch` - that function's `selectable()`
98
+ * guard requires `subscribed`, which a workspace this young never is, so
99
+ * calling it here would silently do nothing. `SwitchWorkspaceUseCase`
100
+ * itself has no subscription gate, only membership/MFA/audience, so this
101
+ * calls the bridge directly and only withholds on `inAudience` - a
102
+ * workspace of a type this application is not sold to, which `switch`
103
+ * really does refuse.
104
+ */
105
+ const handleCreated = async (workspace) => {
106
+ setIsCreateOpen(false);
107
+ refetch();
108
+ if (!workspace.inAudience)
109
+ return;
110
+ setSwitching(workspace.workspaceId);
111
+ setError(null);
112
+ try {
113
+ await mutateResource("/workspaces/switch", { workspaceId: workspace.workspaceId });
114
+ if (onSwitched) {
115
+ onSwitched(workspace);
116
+ }
117
+ else {
118
+ window.location.reload();
119
+ }
120
+ }
121
+ catch (cause) {
122
+ setError(cause instanceof Error ? cause.message : "Workspace created, but could not switch to it");
123
+ setSwitching(null);
124
+ }
125
+ };
93
126
  return (_jsxs("div", { className: row ? "" : className, children: [_jsxs(Dropdown, { children: [row ? (
94
127
  // A 32px tile rather than a 20px glyph, so the initials land on the
95
128
  // same optical centre the avatar below them uses - see
96
129
  // `SIDEBAR_ROW_TILE`. Collapsed, this tile *is* the switcher.
97
- _jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children: _jsxs(ListBox, { children: [workspaces.map((workspace) => (_jsxs(ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : (_jsx("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), _jsx(ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? (_jsxs(_Fragment, { children: [workspaces.length > 0 && _jsx(Separator, {}), _jsxs(ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => openExternal(appUrl), children: [_jsx(Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), _jsx("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? _jsx("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null] }));
130
+ _jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children: _jsxs(ListBox, { children: [workspaces.map((workspace) => (_jsxs(ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : (_jsx("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), _jsx(ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? (_jsxs(_Fragment, { children: [workspaces.length > 0 && _jsx(Separator, {}), _jsxs(ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), _jsx("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? _jsx("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null, alwaysShow ? (_jsx(ConnectCreateWorkspaceModal, { isOpen: isCreateOpen, onOpenChange: setIsCreateOpen, onCreated: (workspace) => void handleCreated(workspace) })) : null] }));
98
131
  }
99
132
  /** Belongs to the user, and usable here. The same two facts `switch` enforces. */
100
133
  function selectable(workspace) {
@@ -31,6 +31,11 @@ ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, Connect
31
31
  // Built, compiled into `dist/`, and reachable from no entry point until now -
32
32
  // `ConnectUserMenu` was the only thing that could render it.
33
33
  export { ConnectAccountSessions } from "./components/account-sessions.js";
34
+ // A link to *any* Connect portal page that offers a "Back to <your app>" way
35
+ // home - what `ConnectProfileButton` builds for `/profile` internally, made
36
+ // reusable for a host linking out to a different Connect page (e.g.
37
+ // `/calendar/settings`) on its own.
38
+ export { connectPortalUrl, useConnectPortalUrl } from "./portal-links.js";
34
39
  // The frame the rest of these components live inside. Exported next to the
35
40
  // pieces it composes rather than in a section of its own - an integration
36
41
  // reaching for a sidebar wants to find the launcher and the account menu in
@@ -142,7 +147,18 @@ isFreePlan,
142
147
  groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
143
148
  export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
144
149
  export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js";
150
+ // The write half of the workspace switcher - opening another workspace under
151
+ // the signed-in user's own tenant, from inside a host application rather
152
+ // than a link-out to Connect's own "Create workspace" button.
153
+ export { ConnectCreateWorkspaceModal } from "./components/create-workspace-form.js";
154
+ export { useCreateWorkspace } from "./use-workspaces.js";
145
155
  export { ConnectBookingCalendar } from "./components/booking-calendar.js";
146
156
  export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
147
- export { useBookableEventTypes, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
157
+ // The write half of scheduling - a workspace's own first-time setup, not a
158
+ // partner integration's booking flow. `ConnectAvailabilityFormModal` and
159
+ // `ConnectEventTypeFormModal` give a host application a real inline setup
160
+ // step instead of a link-out to Connect's own Calendar settings.
161
+ export { ConnectAvailabilityFormModal } from "./components/availability-form.js";
162
+ export { ConnectEventTypeFormModal } from "./components/event-type-form.js";
163
+ export { useBookableEventTypes, useAvailabilitySchedules, useCreateAvailabilitySchedule, useUpdateAvailabilitySchedule, useManageableEventTypes, useCreateEventType, useUpdateEventType, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
148
164
  export { layoutBookings, dayColumns, zonedInstant, } from "./components/booking-grid.js";
@@ -0,0 +1,34 @@
1
+ "use client";
2
+ import { useConnect } from "./provider.js";
3
+ /**
4
+ * Any Connect portal page, with the context it needs to offer a way back
5
+ * here - `connectPortalUrl(appUrl, clientId, "/profile")`,
6
+ * `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
7
+ *
8
+ * Connect answers any of these paths for a signed-in user on the bare URL -
9
+ * these two params only add the "Back to <your app>" banner
10
+ * (`ApplicationReturnBar` on Connect's side). It validates `return_to`
11
+ * against this `client_id`'s registered redirect origins and silently drops
12
+ * the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
13
+ * pairing degrades to what linking there did before rather than erroring.
14
+ *
15
+ * `window.location.href` rather than a configured home URL: the point is to
16
+ * come back to the page they left, and only the browser knows which one that
17
+ * is.
18
+ */
19
+ export function connectPortalUrl(appUrl, clientId, path) {
20
+ const url = new URL(path, appUrl);
21
+ url.searchParams.set("client_id", clientId);
22
+ if (typeof window !== "undefined") {
23
+ url.searchParams.set("return_to", window.location.href);
24
+ }
25
+ return url.toString();
26
+ }
27
+ /**
28
+ * `connectPortalUrl`, with `appUrl`/`clientId` read from the provider instead
29
+ * of threaded through by every caller.
30
+ */
31
+ export function useConnectPortalUrl(path) {
32
+ const { appUrl, clientId } = useConnect();
33
+ return connectPortalUrl(appUrl, clientId, path);
34
+ }
@@ -15,6 +15,82 @@ export function useBookableEventTypes() {
15
15
  const { data, isLoading, error } = useConnectResource("/scheduling/event-types");
16
16
  return { eventTypes: data?.eventTypes ?? [], isLoading, error };
17
17
  }
18
+ /**
19
+ * The working-hours templates an appointment type can point at.
20
+ *
21
+ * Read this before offering a "set up availability" step - a workspace that
22
+ * already has one does not need a second "Working hours" from your UI.
23
+ */
24
+ export function useAvailabilitySchedules() {
25
+ const { data, isLoading, error } = useConnectResource("/scheduling/schedules");
26
+ return { schedules: data?.schedules ?? [], isLoading, error };
27
+ }
28
+ /**
29
+ * The richer read `ConnectEventTypeFormModal`'s edit mode needs - everything
30
+ * `useBookableEventTypes`'s booking catalogue deliberately withholds. See
31
+ * the bridge route's own doc comment for why this is a separate read rather
32
+ * than a wider response on that one. Includes inactive rows.
33
+ */
34
+ export function useManageableEventTypes() {
35
+ const { data, isLoading, error, refetch } = useConnectResource("/scheduling/event-types/manage");
36
+ return { eventTypes: data?.eventTypes ?? [], isLoading, error, refetch };
37
+ }
38
+ /**
39
+ * Gives a workspace its first working hours.
40
+ *
41
+ * **Not optimistic** - same posture as `useBookingActions`: this is one of
42
+ * the two writes a host's first-run setup makes, and a modal that closes on
43
+ * a failed create is worse than one that stays open with the reason shown.
44
+ */
45
+ export function useCreateAvailabilitySchedule() {
46
+ const { run, isPending, error, clearError } = useResourceAction();
47
+ return {
48
+ isPending,
49
+ error,
50
+ clearError,
51
+ createSchedule: (input) => run("/scheduling/schedules", input),
52
+ };
53
+ }
54
+ /** Edits an existing availability schedule. Every field optional - send only what changed. */
55
+ export function useUpdateAvailabilitySchedule() {
56
+ const { run, isPending, error, clearError } = useResourceAction();
57
+ return {
58
+ isPending,
59
+ error,
60
+ clearError,
61
+ updateSchedule: (input) => run("/scheduling/schedules/update", input),
62
+ };
63
+ }
64
+ /**
65
+ * Creates the first thing a workspace can be booked for.
66
+ *
67
+ * Host is a specific member, never a team - there is no groups read in this
68
+ * bridge, so a round-robin team host stays a Console-only capability
69
+ * reachable by following "Manage in Connect".
70
+ */
71
+ export function useCreateEventType() {
72
+ const { run, isPending, error, clearError } = useResourceAction();
73
+ return {
74
+ isPending,
75
+ error,
76
+ clearError,
77
+ createEventType: (input) => run("/scheduling/event-types", input),
78
+ };
79
+ }
80
+ /**
81
+ * Edits an existing appointment type. Every field optional - send only what
82
+ * changed. `hostMemberId` omitted leaves the host untouched, including a
83
+ * *group* host this surface cannot represent.
84
+ */
85
+ export function useUpdateEventType() {
86
+ const { run, isPending, error, clearError } = useResourceAction();
87
+ return {
88
+ isPending,
89
+ error,
90
+ clearError,
91
+ updateEventType: (input) => run("/scheduling/event-types/update", input),
92
+ };
93
+ }
18
94
  /**
19
95
  * Free times for one appointment type.
20
96
  *
@@ -68,15 +144,16 @@ export function useBookings(query) {
68
144
  };
69
145
  }
70
146
  /**
71
- * Book, move and cancel.
147
+ * The isPending/error bookkeeping every write in this file shares, factored
148
+ * out once `useCreateAvailabilitySchedule` and `useCreateEventType` needed
149
+ * the identical shape `useBookingActions` already had.
72
150
  *
73
- * **None of these is optimistic**, and that is not an oversight. Whether a
74
- * slot is still free is a question only the server can answer, inside the
75
- * transaction that decides it - two people can be holding the same minute
76
- * right now. Painting a booking as made and withdrawing it is exactly the lie
77
- * the two-phase hold exists to prevent.
151
+ * **Never optimistic**, and that is not an oversight for any caller of this:
152
+ * whether a slot is free, or a schedule name collides, is a question only the
153
+ * server can answer. Painting the result before it comes back is exactly the
154
+ * lie a two-phase hold (or a modal that closes early) would tell.
78
155
  */
79
- export function useBookingActions() {
156
+ function useResourceAction() {
80
157
  const { mutateResource } = useConnect();
81
158
  const [isPending, setPending] = useState(false);
82
159
  const [error, setError] = useState(null);
@@ -96,10 +173,23 @@ export function useBookingActions() {
96
173
  setPending(false);
97
174
  }
98
175
  }, [mutateResource]);
176
+ return { isPending, error, clearError: () => setError(null), run };
177
+ }
178
+ /**
179
+ * Book, move and cancel.
180
+ *
181
+ * **None of these is optimistic**, and that is not an oversight. Whether a
182
+ * slot is still free is a question only the server can answer, inside the
183
+ * transaction that decides it - two people can be holding the same minute
184
+ * right now. Painting a booking as made and withdrawing it is exactly the lie
185
+ * the two-phase hold exists to prevent.
186
+ */
187
+ export function useBookingActions() {
188
+ const { run, isPending, error, clearError } = useResourceAction();
99
189
  return {
100
190
  isPending,
101
191
  error,
102
- clearError: () => setError(null),
192
+ clearError,
103
193
  createBooking: (input) => run("/scheduling/bookings", input),
104
194
  rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
105
195
  cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ import { useConnect } from "./provider.js";
4
+ /**
5
+ * Opens another workspace under the tenant the signed-in user already owns.
6
+ *
7
+ * **Not optimistic**, same posture as every write in `use-scheduling.ts`:
8
+ * whether the tenant's plan still has room for another workspace is a
9
+ * question only the server can answer, and a modal that closes before that
10
+ * answer comes back is the lie a two-phase write exists to avoid.
11
+ *
12
+ * The result is a `UserWorkspace` row with `isActive`/`subscribed` both
13
+ * `false` - creating does not switch the session to it, and the workspace has
14
+ * no subscription yet. Pair with `<ConnectWorkspaceSwitcher>`'s own
15
+ * `mutateResource("/workspaces/switch", ...)` call to move into it, guarded
16
+ * on `inAudience` the same way that component's `selectable()` already is.
17
+ */
18
+ export function useCreateWorkspace() {
19
+ const { mutateResource } = useConnect();
20
+ const [isPending, setPending] = useState(false);
21
+ const [error, setError] = useState(null);
22
+ const createWorkspace = useCallback(async (input) => {
23
+ setPending(true);
24
+ setError(null);
25
+ try {
26
+ const result = await mutateResource("/workspaces", input);
27
+ return result.workspace;
28
+ }
29
+ catch (cause) {
30
+ setError(cause instanceof Error ? cause.message : "Could not create that workspace.");
31
+ return null;
32
+ }
33
+ finally {
34
+ setPending(false);
35
+ }
36
+ }, [mutateResource]);
37
+ return { isPending, error, clearError: () => setError(null), createWorkspace };
38
+ }
@@ -175,4 +175,4 @@ export class ConnectClient {
175
175
  }
176
176
  }
177
177
  /** Kept in step with `package.json` by `sdk.test.ts`. */
178
- export const SDK_VERSION = "0.37.0";
178
+ export const SDK_VERSION = "0.40.0";
@@ -738,8 +738,8 @@ export function createConnectHandler(options) {
738
738
  */
739
739
  "STORE": async (request) => storeRoute(request),
740
740
  /**
741
- * The workspace switcher's three reads, forwarded with the caller's own
742
- * session token.
741
+ * The workspace switcher's reads and writes, forwarded with the caller's
742
+ * own session token.
743
743
  *
744
744
  * `POST /workspaces/switch` is the only bridge route that *changes the
745
745
  * caller's session*, so it writes the new token back through the same
@@ -751,6 +751,14 @@ export function createConnectHandler(options) {
751
751
  * A partner with no `session.write` cannot re-persist a rotated token, so
752
752
  * switching is simply absent for them rather than half-working - the same
753
753
  * treatment `/signin` and `/callback` already get.
754
+ *
755
+ * `POST /workspaces` opens another workspace under the caller's own
756
+ * tenant and does not rotate anything - it does not switch the session,
757
+ * same as Connect's own "Create workspace" button. Every guard
758
+ * (ownership, the plan's workspace allowance, the business-email-domain
759
+ * rule) lives server-side in `CreateWorkspaceUseCase`; this route is a
760
+ * plain forward with the caller's session token, exactly like the reads
761
+ * beside it.
754
762
  */
755
763
  /*
756
764
  * Push notifications - the browser half, and deliberately only that half.
@@ -807,6 +815,20 @@ export function createConnectHandler(options) {
807
815
  return json(await client.push.revokeDevice(body.id, { asUser: token }));
808
816
  },
809
817
  "GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
818
+ "POST /workspaces": async (request) => {
819
+ const token = await requireToken(request);
820
+ const body = (await request.json().catch(() => null));
821
+ if (typeof body?.name !== "string" || (body.type !== "individual" && body.type !== "business")) {
822
+ throw new ConnectError({
823
+ status: 400,
824
+ code: "validation_error",
825
+ message: "name and type are required",
826
+ endpoint: "POST /workspaces",
827
+ });
828
+ }
829
+ const workspace = await client.workspaces.create({ name: body.name, type: body.type }, { asUser: token });
830
+ return json({ workspace });
831
+ },
810
832
  "GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
811
833
  "POST /workspaces/switch": async (request) => {
812
834
  const token = await requireToken(request);
@@ -850,6 +872,163 @@ export function createConnectHandler(options) {
850
872
  const asUser = await requireToken(request);
851
873
  return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
852
874
  },
875
+ /*
876
+ * Creates the first thing a workspace can be booked for - a browser-side
877
+ * counterpart to `POST /scheduling/event-types` above, for a host's own
878
+ * inline setup UI (`ConnectEventTypeFormModal`). Host is a specific
879
+ * member, never a team - there is no groups read in this bridge, so a
880
+ * round-robin team host stays a Console-only capability.
881
+ */
882
+ "POST /scheduling/event-types": async (request) => {
883
+ const { workspaceId } = await requireWorkspace(request);
884
+ const asUser = await requireToken(request);
885
+ const body = (await request.json().catch(() => null));
886
+ if (typeof body?.name !== "string" ||
887
+ typeof body.durationMinutes !== "number" ||
888
+ typeof body.hostMemberId !== "string" ||
889
+ typeof body.scheduleId !== "string" ||
890
+ typeof body.location?.kind !== "string") {
891
+ throw new ConnectError({
892
+ status: 400,
893
+ code: "validation_error",
894
+ message: "name, durationMinutes, hostMemberId, scheduleId and location are required",
895
+ endpoint: "/scheduling/event-types",
896
+ });
897
+ }
898
+ return json(await client.scheduling.createEventType({
899
+ workspaceId,
900
+ name: body.name,
901
+ description: typeof body.description === "string" ? body.description : null,
902
+ durationMinutes: body.durationMinutes,
903
+ bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
904
+ bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
905
+ hostMemberId: body.hostMemberId,
906
+ scheduleId: body.scheduleId,
907
+ location: {
908
+ kind: body.location.kind,
909
+ value: typeof body.location.value === "string" ? body.location.value : null,
910
+ },
911
+ publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
912
+ asUser,
913
+ }), 201);
914
+ },
915
+ /*
916
+ * The richer read `ConnectEventTypeFormModal`'s edit mode needs - see
917
+ * `listManageableEventTypes`'s own doc comment for why this is a separate
918
+ * route from the catalogue above rather than a wider response on it.
919
+ */
920
+ "GET /scheduling/event-types/manage": async (request) => {
921
+ const { workspaceId } = await requireWorkspace(request);
922
+ const asUser = await requireToken(request);
923
+ return json({
924
+ eventTypes: await client.scheduling.listManageableEventTypes(workspaceId, { asUser }),
925
+ });
926
+ },
927
+ /*
928
+ * A fixed path with the id in the body, not `/scheduling/event-types/{id}` -
929
+ * this table is matched by exact string, the same reason the booking
930
+ * actions below take their id this way rather than as a path segment.
931
+ */
932
+ "POST /scheduling/event-types/update": async (request) => {
933
+ const { workspaceId } = await requireWorkspace(request);
934
+ const asUser = await requireToken(request);
935
+ const body = (await request.json().catch(() => null));
936
+ if (typeof body?.eventTypeId !== "string") {
937
+ throw new ConnectError({
938
+ status: 400,
939
+ code: "validation_error",
940
+ message: "eventTypeId is required",
941
+ endpoint: "/scheduling/event-types/update",
942
+ });
943
+ }
944
+ return json(await client.scheduling.updateEventType({
945
+ workspaceId,
946
+ eventTypeId: body.eventTypeId,
947
+ name: typeof body.name === "string" ? body.name : undefined,
948
+ description: typeof body.description === "string" || body.description === null
949
+ ? body.description
950
+ : undefined,
951
+ durationMinutes: typeof body.durationMinutes === "number" ? body.durationMinutes : undefined,
952
+ bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
953
+ bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
954
+ hostMemberId: typeof body.hostMemberId === "string" ? body.hostMemberId : undefined,
955
+ scheduleId: typeof body.scheduleId === "string" ? body.scheduleId : undefined,
956
+ location: body.location && typeof body.location.kind === "string"
957
+ ? {
958
+ kind: body.location.kind,
959
+ value: typeof body.location.value === "string" ? body.location.value : null,
960
+ }
961
+ : undefined,
962
+ publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
963
+ active: typeof body.active === "boolean" ? body.active : undefined,
964
+ asUser,
965
+ }));
966
+ },
967
+ /*
968
+ * The working-hours templates an appointment type can point at - read
969
+ * before a setup UI offers to create one, so it can tell "none yet" from
970
+ * "already set up".
971
+ */
972
+ "GET /scheduling/schedules": async (request) => {
973
+ const { workspaceId } = await requireWorkspace(request);
974
+ const asUser = await requireToken(request);
975
+ return json({ schedules: await client.scheduling.listSchedules(workspaceId, { asUser }) });
976
+ },
977
+ /**
978
+ * Gives a workspace its first working hours - the browser-side
979
+ * counterpart to `POST /scheduling/schedules` above, for
980
+ * `ConnectAvailabilityFormModal`.
981
+ */
982
+ "POST /scheduling/schedules": async (request) => {
983
+ const { workspaceId } = await requireWorkspace(request);
984
+ const asUser = await requireToken(request);
985
+ const body = (await request.json().catch(() => null));
986
+ if (typeof body?.name !== "string" ||
987
+ typeof body.timezone !== "string" ||
988
+ typeof body.weeklyHours !== "object" ||
989
+ body.weeklyHours === null) {
990
+ throw new ConnectError({
991
+ status: 400,
992
+ code: "validation_error",
993
+ message: "name, timezone and weeklyHours are required",
994
+ endpoint: "/scheduling/schedules",
995
+ });
996
+ }
997
+ return json(await client.scheduling.createSchedule({
998
+ workspaceId,
999
+ name: body.name,
1000
+ timezone: body.timezone,
1001
+ weeklyHours: body.weeklyHours,
1002
+ asUser,
1003
+ }), 201);
1004
+ },
1005
+ /*
1006
+ * A fixed path with the id in the body, same reasoning as the event
1007
+ * type's own `update` route above.
1008
+ */
1009
+ "POST /scheduling/schedules/update": async (request) => {
1010
+ const { workspaceId } = await requireWorkspace(request);
1011
+ const asUser = await requireToken(request);
1012
+ const body = (await request.json().catch(() => null));
1013
+ if (typeof body?.scheduleId !== "string") {
1014
+ throw new ConnectError({
1015
+ status: 400,
1016
+ code: "validation_error",
1017
+ message: "scheduleId is required",
1018
+ endpoint: "/scheduling/schedules/update",
1019
+ });
1020
+ }
1021
+ return json(await client.scheduling.updateSchedule({
1022
+ workspaceId,
1023
+ scheduleId: body.scheduleId,
1024
+ name: typeof body.name === "string" ? body.name : undefined,
1025
+ timezone: typeof body.timezone === "string" ? body.timezone : undefined,
1026
+ weeklyHours: typeof body.weeklyHours === "object" && body.weeklyHours !== null
1027
+ ? body.weeklyHours
1028
+ : undefined,
1029
+ asUser,
1030
+ }));
1031
+ },
853
1032
  "GET /scheduling/slots": async (request) => {
854
1033
  const { workspaceId } = await requireWorkspace(request);
855
1034
  const asUser = await requireToken(request);
@@ -1,4 +1,15 @@
1
1
  import { ConnectError } from "../errors.js";
2
+ /**
3
+ * How long a *generating* endpoint may take to send its headers.
4
+ *
5
+ * One number, named once, and it is not a guess: it is what
6
+ * `POST /visitor/chat` and `POST /ai/chat` declare as their `maxDuration` on
7
+ * the far side. A client bound shorter than the route's own ceiling cannot be
8
+ * right - it can only abort work Connect is still allowed to be doing - and
9
+ * the two numbers drifting apart is exactly how a 20s client came to be
10
+ * fronting a 60s route.
11
+ */
12
+ export const GENERATION_TIMEOUT_MS = 60_000;
2
13
  const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
3
14
  /**
4
15
  * One id, safe to interpolate into a request path.
@@ -145,7 +156,7 @@ export async function request(options, spec) {
145
156
  // that has actually started to run as long as it needs. A caller that
146
157
  // wants to bound the stream itself passes `spec.signal`.
147
158
  const timeoutController = new AbortController();
148
- const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
159
+ const timer = setTimeout(() => timeoutController.abort(), spec.timeoutMs ?? options.timeoutMs);
149
160
  const signal = spec.signal
150
161
  ? AbortSignal.any([spec.signal, timeoutController.signal])
151
162
  : timeoutController.signal;
@@ -1,5 +1,5 @@
1
1
  import { actingAs } from "./acting-as.js";
2
- import { request } from "../http.js";
2
+ import { GENERATION_TIMEOUT_MS, request } from "../http.js";
3
3
  /**
4
4
  * RAG chat and structured extraction against a workspace's data.
5
5
  *
@@ -40,6 +40,12 @@ export class AiResource {
40
40
  ...(input.screenContext ? { screenContext: input.screenContext } : {}),
41
41
  },
42
42
  headers: actingAs(input.asUser),
43
+ /*
44
+ * Matches the route's own `maxDuration`. This endpoint does not send
45
+ * headers until the model's first token, so the default 20s bound was
46
+ * aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
47
+ */
48
+ timeoutMs: GENERATION_TIMEOUT_MS,
43
49
  raw: true,
44
50
  signal: options.signal,
45
51
  });