@cosmoops/connect-sdk 0.3.0 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +215 -6
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +162 -0
  6. package/dist/cjs/client/components/account-sessions.js +24 -22
  7. package/dist/cjs/client/components/app-shell.js +157 -0
  8. package/dist/cjs/client/components/apps-grid.js +4 -3
  9. package/dist/cjs/client/components/feature-gate.js +2 -2
  10. package/dist/cjs/client/components/integrations-modal.js +95 -0
  11. package/dist/cjs/client/components/logo.js +3 -3
  12. package/dist/cjs/client/components/motion.js +27 -0
  13. package/dist/cjs/client/components/nav.js +97 -0
  14. package/dist/cjs/client/components/plan-pricing.js +1 -1
  15. package/dist/cjs/client/components/plans.js +42 -40
  16. package/dist/cjs/client/components/readiness.js +6 -4
  17. package/dist/cjs/client/components/side-nav.js +58 -0
  18. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  19. package/dist/cjs/client/components/subscription.js +3 -2
  20. package/dist/cjs/client/components/surfaces.js +54 -0
  21. package/dist/cjs/client/components/usage-meter.js +4 -3
  22. package/dist/cjs/client/components/user-menu.js +33 -6
  23. package/dist/cjs/client/index.js +30 -2
  24. package/dist/cjs/client/mark.js +1 -1
  25. package/dist/cjs/client/provider.js +60 -8
  26. package/dist/cjs/client/use-active-path.js +77 -0
  27. package/dist/cjs/client/use-checkout.js +3 -3
  28. package/dist/cjs/errors.js +4 -4
  29. package/dist/cjs/helpers.js +1 -1
  30. package/dist/cjs/index.js +1 -1
  31. package/dist/cjs/next/index.js +96 -15
  32. package/dist/cjs/server/client.js +22 -14
  33. package/dist/cjs/server/handler.js +99 -16
  34. package/dist/cjs/server/http.js +1 -1
  35. package/dist/cjs/server/index.js +8 -1
  36. package/dist/cjs/server/resources/access.js +3 -3
  37. package/dist/cjs/server/resources/ai.js +1 -1
  38. package/dist/cjs/server/resources/analytics.js +66 -0
  39. package/dist/cjs/server/resources/apps.js +4 -4
  40. package/dist/cjs/server/resources/billing.js +5 -5
  41. package/dist/cjs/server/resources/drive.js +4 -4
  42. package/dist/cjs/server/resources/entitlements.js +7 -7
  43. package/dist/cjs/server/resources/identity.js +4 -4
  44. package/dist/cjs/server/resources/integrations.js +66 -0
  45. package/dist/cjs/server/resources/logs.js +3 -3
  46. package/dist/cjs/server/resources/sessions.js +6 -6
  47. package/dist/cjs/server/webhooks.js +127 -0
  48. package/dist/cjs/types.js +1 -1
  49. package/dist/esm/cli/doctor.js +3 -3
  50. package/dist/esm/cli/init.js +3 -3
  51. package/dist/esm/client/components/access-gate.js +158 -0
  52. package/dist/esm/client/components/account-sessions.js +24 -22
  53. package/dist/esm/client/components/app-shell.js +154 -0
  54. package/dist/esm/client/components/apps-grid.js +4 -3
  55. package/dist/esm/client/components/feature-gate.js +2 -2
  56. package/dist/esm/client/components/integrations-modal.js +92 -0
  57. package/dist/esm/client/components/logo.js +3 -3
  58. package/dist/esm/client/components/motion.js +21 -0
  59. package/dist/esm/client/components/nav.js +89 -0
  60. package/dist/esm/client/components/plan-pricing.js +1 -1
  61. package/dist/esm/client/components/plans.js +42 -40
  62. package/dist/esm/client/components/readiness.js +6 -4
  63. package/dist/esm/client/components/side-nav.js +53 -0
  64. package/dist/esm/client/components/subscribe-dialog.js +2 -2
  65. package/dist/esm/client/components/subscription.js +3 -2
  66. package/dist/esm/client/components/surfaces.js +51 -0
  67. package/dist/esm/client/components/usage-meter.js +4 -3
  68. package/dist/esm/client/components/user-menu.js +34 -7
  69. package/dist/esm/client/index.js +16 -2
  70. package/dist/esm/client/mark.js +1 -1
  71. package/dist/esm/client/provider.js +59 -8
  72. package/dist/esm/client/use-active-path.js +74 -0
  73. package/dist/esm/client/use-checkout.js +3 -3
  74. package/dist/esm/errors.js +4 -4
  75. package/dist/esm/helpers.js +1 -1
  76. package/dist/esm/index.js +1 -1
  77. package/dist/esm/next/index.js +95 -15
  78. package/dist/esm/server/client.js +22 -14
  79. package/dist/esm/server/handler.js +99 -16
  80. package/dist/esm/server/http.js +1 -1
  81. package/dist/esm/server/index.js +5 -0
  82. package/dist/esm/server/resources/access.js +3 -3
  83. package/dist/esm/server/resources/ai.js +1 -1
  84. package/dist/esm/server/resources/analytics.js +62 -0
  85. package/dist/esm/server/resources/apps.js +4 -4
  86. package/dist/esm/server/resources/billing.js +5 -5
  87. package/dist/esm/server/resources/drive.js +4 -4
  88. package/dist/esm/server/resources/entitlements.js +7 -7
  89. package/dist/esm/server/resources/identity.js +4 -4
  90. package/dist/esm/server/resources/integrations.js +62 -0
  91. package/dist/esm/server/resources/logs.js +3 -3
  92. package/dist/esm/server/resources/sessions.js +6 -6
  93. package/dist/esm/server/webhooks.js +123 -0
  94. package/dist/esm/types.js +1 -1
  95. package/dist/theme.css +28 -0
  96. package/dist/types/client/components/access-gate.d.ts +79 -0
  97. package/dist/types/client/components/account-sessions.d.ts +1 -1
  98. package/dist/types/client/components/app-shell.d.ts +115 -0
  99. package/dist/types/client/components/apps-grid.d.ts +1 -1
  100. package/dist/types/client/components/feature-gate.d.ts +1 -1
  101. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  102. package/dist/types/client/components/logo.d.ts +5 -5
  103. package/dist/types/client/components/motion.d.ts +22 -0
  104. package/dist/types/client/components/nav.d.ts +110 -0
  105. package/dist/types/client/components/plan-pricing.d.ts +2 -2
  106. package/dist/types/client/components/readiness.d.ts +2 -2
  107. package/dist/types/client/components/side-nav.d.ts +31 -0
  108. package/dist/types/client/components/subscribe-dialog.d.ts +3 -3
  109. package/dist/types/client/components/surfaces.d.ts +51 -0
  110. package/dist/types/client/components/usage-meter.d.ts +1 -1
  111. package/dist/types/client/components/user-menu.d.ts +2 -2
  112. package/dist/types/client/index.d.ts +13 -2
  113. package/dist/types/client/mark.d.ts +1 -1
  114. package/dist/types/client/provider.d.ts +44 -9
  115. package/dist/types/client/use-active-path.d.ts +8 -0
  116. package/dist/types/client/use-checkout.d.ts +3 -3
  117. package/dist/types/errors.d.ts +4 -4
  118. package/dist/types/helpers.d.ts +1 -1
  119. package/dist/types/index.d.ts +1 -1
  120. package/dist/types/next/index.d.ts +40 -0
  121. package/dist/types/server/client.d.ts +17 -11
  122. package/dist/types/server/handler.d.ts +2 -2
  123. package/dist/types/server/index.d.ts +7 -0
  124. package/dist/types/server/resources/access.d.ts +3 -3
  125. package/dist/types/server/resources/ai.d.ts +2 -2
  126. package/dist/types/server/resources/analytics.d.ts +31 -0
  127. package/dist/types/server/resources/apps.d.ts +4 -4
  128. package/dist/types/server/resources/billing.d.ts +4 -4
  129. package/dist/types/server/resources/drive.d.ts +4 -4
  130. package/dist/types/server/resources/entitlements.d.ts +4 -4
  131. package/dist/types/server/resources/identity.d.ts +14 -4
  132. package/dist/types/server/resources/integrations.d.ts +40 -0
  133. package/dist/types/server/resources/logs.d.ts +3 -3
  134. package/dist/types/server/resources/sessions.d.ts +5 -5
  135. package/dist/types/server/webhooks.d.ts +67 -0
  136. package/dist/types/types.d.ts +220 -39
  137. package/package.json +10 -4
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The one elevation model every panel inside a Connect modal follows.
3
+ *
4
+ * This exists because "just use `bg-surface`" is wrong here, and wrong in a
5
+ * way that is invisible until you look at the tokens. `Modal.Dialog` paints
6
+ * itself `bg-overlay`, and in the shared theme (`theme.css`) `--overlay` and
7
+ * `--surface` sit at the *same lightness* in both schemes - 100% against 100%
8
+ * in light, 21% against 21% in dark. A `bg-surface` panel dropped on a modal
9
+ * therefore has no fill contrast at all; only its border separates it from
10
+ * the dialog behind it.
11
+ *
12
+ * That is how the account modal ended up looking half-finished: the identity
13
+ * card used `bg-surface-secondary` and read as a solid block, while the
14
+ * destination tiles and the session list used `bg-surface` and read as
15
+ * hairline outlines floating on nothing. Same nesting depth, two different
16
+ * physical materials, for no reason a user could infer.
17
+ *
18
+ * Three levels, and nesting one inside another always steps the fill:
19
+ *
20
+ * - **L0 - the dialog itself.** `bg-overlay`, owned by HeroUI. Not ours.
21
+ * - **L1 - a panel on the dialog.** `PANEL_FILL` + `border-border`. The
22
+ * identity card, each destination tile, the session list.
23
+ * - **L2 - a medallion inside a panel.** `bg-surface`, which is L0's value
24
+ * and therefore reads as *inset* against L1 rather than raised. Light:
25
+ * white on grey. Dark: near-black on charcoal. Both directions legible.
26
+ *
27
+ * The inversion at L2 is deliberate and is why this is written down. Reaching
28
+ * for a "lighter" fill one step down works in light mode and quietly fails in
29
+ * dark; stepping *back to the dialog's own value* is the trick that reads in
30
+ * both, because the contrast comes from the gap between L1 and L0 rather than
31
+ * from a direction of travel.
32
+ */
33
+ export const PANEL_FILL = "bg-surface-secondary";
34
+ /**
35
+ * The icon well shared by destination tiles and session rows: a 40px box
36
+ * holding a 20px glyph.
37
+ *
38
+ * Half the well, which is the point. Both surfaces used to run a `size-4`
39
+ * (16px) icon inside a `size-9` (36px) well - a 44% fill that reads as an
40
+ * icon someone forgot to finish, and the specific thing that made the
41
+ * Profile/Billing/Connect tiles look under-drawn. They also disagreed on
42
+ * shape (a circle in the tiles, a rounded square in the session rows) for
43
+ * what is the same component role, so the two surfaces never looked related.
44
+ *
45
+ * Pair with `ICON` on the glyph inside. Session rows override the well to
46
+ * `size-9` for density but keep the same `ICON`, so glyph size stays constant
47
+ * across the whole modal even where the well does not.
48
+ */
49
+ export const MEDALLION = "size-10";
50
+ /** The glyph inside a `MEDALLION`. Constant across every well in the modal - see `MEDALLION`. */
51
+ export const ICON = "size-5";
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Card, Skeleton } from "@heroui/react";
4
4
  import { AlertTriangle } from "lucide-react";
5
5
  import { useConnect, useConnectAddonUsage } from "../provider.js";
6
+ import { m, METER_SPRING, MotionProvider } from "./motion.js";
6
7
  import { formatPrice } from "./plan-pricing.js";
7
8
  function formatUnits(value, unit) {
8
9
  return `${value.toLocaleString("en-IN")} ${unit}`;
@@ -15,7 +16,7 @@ function formatDate(iso) {
15
16
  *
16
17
  * Reads the balance Connect actually debits, so the number here and the number
17
18
  * `consumeAddon` enforces cannot disagree. Before the `addons:read` endpoint
18
- * existed this component was impossible to write honestly: a service could
19
+ * existed this component was impossible to write honestly: an application could
19
20
  * spend an allowance down on every request and the only thing the API exposed
20
21
  * was the list of add-on *ids* on the subscription.
21
22
  *
@@ -50,7 +51,7 @@ export function ConnectUsageMeter({ addonIds, hideWhenEmpty = true, title = "Usa
50
51
  return null;
51
52
  return (_jsx(Card, { className: className, children: _jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), _jsx(Card.Description, { children: "No metered add-ons on this plan." })] }) }));
52
53
  }
53
- return (_jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), data.resetsAt && _jsxs(Card.Description, { children: ["Resets on ", formatDate(data.resetsAt), "."] })] }), _jsx(Card.Content, { className: "flex flex-col gap-4", children: addons.map((addon) => (_jsx(AddonRow, { addon: addon }, addon.addonId))) })] }));
54
+ return (_jsx(MotionProvider, { children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), data.resetsAt && _jsxs(Card.Description, { children: ["Resets on ", formatDate(data.resetsAt), "."] })] }), _jsx(Card.Content, { className: "flex flex-col gap-4", children: addons.map((addon) => (_jsx(AddonRow, { addon: addon }, addon.addonId))) })] }) }));
54
55
  }
55
56
  function AddonRow({ addon }) {
56
57
  const overdrawn = addon.balance < 0;
@@ -61,5 +62,5 @@ function AddonRow({ addon }) {
61
62
  const carried = addon.balance > addon.includedPerCycle ? addon.balance - addon.includedPerCycle : 0;
62
63
  return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium text-foreground", children: addon.name }), _jsxs("span", { className: overdrawn ? "text-danger" : "text-muted", children: [overdrawn
63
64
  ? `${formatUnits(Math.abs(addon.balance), addon.unit)} over`
64
- : `${formatUnits(addon.balance, addon.unit)} left`, _jsxs("span", { className: "text-muted", children: [" of ", formatUnits(addon.includedPerCycle, addon.unit)] })] })] }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-surface", role: "meter", "aria-label": `${addon.name} remaining`, "aria-valuenow": addon.balance, "aria-valuemin": 0, "aria-valuemax": addon.includedPerCycle, children: _jsx("div", { className: `h-full rounded-full ${overdrawn ? "bg-danger" : "bg-accent"}`, style: { width: `${overdrawn ? 100 : fraction * 100}%` } }) }), carried > 0 && (_jsxs("p", { className: "text-xs text-muted", children: ["Includes ", formatUnits(carried, addon.unit), " carried over from last cycle."] })), overdrawn && addon.overagePricePerUnit !== null && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Billed at ", formatPrice(addon.overagePricePerUnit), " per ", addon.unit, " beyond your allowance."] })), !overdrawn && addon.overagePolicy === "reject" && addon.balance === 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Nothing left this cycle - further use is blocked until it resets."] }))] }));
65
+ : `${formatUnits(addon.balance, addon.unit)} left`, _jsxs("span", { className: "text-muted", children: [" of ", formatUnits(addon.includedPerCycle, addon.unit)] })] })] }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-surface", role: "meter", "aria-label": `${addon.name} remaining`, "aria-valuenow": addon.balance, "aria-valuemin": 0, "aria-valuemax": addon.includedPerCycle, children: _jsx(m.div, { className: `h-full rounded-full ${overdrawn ? "bg-danger" : "bg-accent"}`, initial: { width: 0 }, animate: { width: `${overdrawn ? 100 : fraction * 100}%` }, transition: METER_SPRING }) }), carried > 0 && (_jsxs("p", { className: "text-xs text-muted", children: ["Includes ", formatUnits(carried, addon.unit), " carried over from last cycle."] })), overdrawn && addon.overagePricePerUnit !== null && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Billed at ", formatPrice(addon.overagePricePerUnit), " per ", addon.unit, " beyond your allowance."] })), !overdrawn && addon.overagePolicy === "reject" && addon.balance === 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Nothing left this cycle - further use is blocked until it resets."] }))] }));
65
66
  }
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Avatar, Button, Chip, Modal, Separator, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
3
+ import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
4
4
  import { CreditCard, ExternalLink, Laptop, LogOut, Moon, ReceiptText, Sun, UserRound, } from "lucide-react";
5
5
  import { useConnect } from "../provider.js";
6
6
  import { ConnectMark } from "../mark.js";
7
7
  import { ConnectAccountSessions } from "./account-sessions.js";
8
+ import { ICON, MEDALLION, PANEL_FILL } from "./surfaces.js";
8
9
  const THEME_OPTIONS = [
9
10
  { value: "light", label: "Light", icon: Sun },
10
11
  { value: "dark", label: "Dark", icon: Moon },
@@ -18,6 +19,20 @@ function initials(name, email) {
18
19
  function openExternal(url) {
19
20
  window.open(url, "_blank", "noopener,noreferrer");
20
21
  }
22
+ /**
23
+ * The member's photo, with their initials underneath it.
24
+ *
25
+ * Both halves always render, which is the whole trick: `<Avatar.Fallback>` is
26
+ * what shows while `src` is still loading and what stays if it 404s or the
27
+ * user has no photo at all, so there is no `photoUrl &&` branch anywhere and
28
+ * no flash of an empty circle on a cold cache. This component previously
29
+ * rendered *only* the fallback - `ConnectUser` carried no photo field at all,
30
+ * so every Connect account showed initials no matter what picture the member
31
+ * had set on their profile.
32
+ */
33
+ function UserAvatar({ user, size, }) {
34
+ return (_jsxs(Avatar, { size: size, color: "accent", variant: "soft", children: [user.photoUrl && _jsx(Avatar.Image, { alt: "", src: user.photoUrl }), _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) })] }));
35
+ }
21
36
  /** `dispatch_admin` / `billing-viewer` -> `Dispatch admin`. */
22
37
  function humanizeRole(role) {
23
38
  const words = role.replace(/[_-]+/g, " ").trim();
@@ -58,9 +73,17 @@ function subscriptionChip(subscription) {
58
73
  * `w-full` is load-bearing, not decoration: HeroUI's `.button` base is
59
74
  * `w-fit`, so a tile dropped into a grid cell shrinks to its own label and
60
75
  * the three of them come out ragged and unequal.
76
+ *
77
+ * On the box: `p-3` all round rather than the `px-2 py-4` this used to carry.
78
+ * That old pair was both lopsided (16px of vertical air against 8px of
79
+ * horizontal) and too tall for what it held, so a 16px glyph sat marooned in
80
+ * a ~92px tile. Even padding plus the shared `size-10`/`size-5` medallion
81
+ * (see `MEDALLION`) puts the icon at half the well instead of a stranded
82
+ * 44%, and takes the tile down to ~88px while making its contents read
83
+ * bigger, not smaller.
61
84
  */
62
85
  function DestinationTile({ icon, label, url, }) {
63
- return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)`, className: "relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border bg-surface px-2 py-4 hover:bg-surface-secondary", onPress: () => openExternal(url), children: [_jsx(ExternalLink, { className: "absolute end-2 top-2 size-3 text-muted", "aria-hidden": "true" }), _jsx("span", { className: "flex size-9 items-center justify-center rounded-full bg-surface-secondary text-muted", children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
86
+ return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)`, className: `relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border ${PANEL_FILL} p-3 hover:border-accent hover:bg-surface-secondary`, onPress: () => openExternal(url), children: [_jsx(ExternalLink, { className: "absolute end-2 top-2 size-3.5 text-muted", "aria-hidden": "true" }), _jsx("span", { className: `flex ${MEDALLION} items-center justify-center rounded-xl bg-surface text-muted`, children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
64
87
  }
65
88
  /**
66
89
  * Avatar and name in the trigger; an identity card, the three Connect
@@ -74,7 +97,7 @@ function DestinationTile({ icon, label, url, }) {
74
97
  * in place, which is why it sits alone in the footer rather than as a fourth
75
98
  * item in the same list as three navigations.
76
99
  *
77
- * The session list is account-wide, not scoped to this service - see
100
+ * The session list is account-wide, not scoped to this application - see
78
101
  * `ConnectUserSession`'s doc comment in `types.ts`. Appearance only renders
79
102
  * when both `theme` and `onThemeChange` are supplied.
80
103
  *
@@ -99,13 +122,17 @@ export function ConnectUserMenu({ compact = false, onSignOut, className, theme,
99
122
  if (!user)
100
123
  return null;
101
124
  const label = user.displayName ?? user.email ?? "Account";
102
- const role = session?.serviceRole?.role;
125
+ const role = session?.applicationRole?.role;
103
126
  const planChip = subscriptionChip(session?.subscription ?? null);
104
- return (_jsxs(Modal, { children: [_jsxs(Button, { variant: "tertiary", "aria-label": `Account: ${label}`, className: `gap-1 pl-1 ${className ?? ""}`, children: [_jsx(Avatar, { size: "sm", color: "accent", variant: "soft", children: _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) }) }), !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 bg-surface-secondary p-4", children: [_jsx(Avatar, { size: "lg", color: "accent", variant: "soft", children: _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) }) }), _jsxs("div", { className: "min-w-0 flex-1", 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 }), _jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1.5", children: [_jsx(Chip, { size: "sm", variant: "soft", children: user.organizationName }), 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: "size-4", "aria-hidden": "true" }), label: "Profile", url: `${appUrl}/profile` }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: "size-4", "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: "size-4" }), 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) => {
127
+ return (_jsxs(Modal, { children: [_jsxs(Button, { variant: "tertiary", "aria-label": `Account: ${label}`,
128
+ // `pl-1` against the default right padding is optical, not sloppy: a
129
+ // circular avatar needs less inset than a text label to look evenly
130
+ // 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) => {
105
132
  const [next] = [...keys];
106
133
  if (typeof next === "string")
107
134
  onThemeChange(next);
108
- }, children: THEME_OPTIONS.map(({ value, label: optionLabel, icon: Icon }, index) => (_jsxs(ToggleButton, { id: value, children: [index > 0 && _jsx(ToggleButtonGroup.Separator, {}), _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), optionLabel] }, value))) })] })), _jsx(Separator, {}), _jsx(ConnectAccountSessions, {})] }), _jsx(Modal.Footer, { children: _jsxs(Button, { slot: "close", fullWidth: true, variant: "tertiary", className: "text-danger", onPress: () => {
135
+ }, children: THEME_OPTIONS.map(({ value, label: optionLabel, icon: Icon }, index) => (_jsxs(ToggleButton, { id: value, children: [index > 0 && _jsx(ToggleButtonGroup.Separator, {}), _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), optionLabel] }, value))) })] })), _jsx(ConnectAccountSessions, {})] }), _jsx(Modal.Footer, { children: _jsxs(Button, { slot: "close", fullWidth: true, variant: "tertiary", className: "text-danger", onPress: () => {
109
136
  void signOut().then(onSignOut);
110
137
  }, children: [_jsx(LogOut, { className: "size-4 shrink-0" }), "Sign out"] }) })] }) }) })] }));
111
138
  }
@@ -122,7 +149,7 @@ export function ConnectManageSubscriptionButton({ className }) {
122
149
  * Invoices and payment history, on Connect.
123
150
  *
124
151
  * A deep link rather than a component that lists them, and that is not a
125
- * placeholder: nothing in `/api/v1/service/*` exposes invoices, so an SDK-side
152
+ * placeholder: nothing in `/api/v1/application/*` exposes invoices, so an SDK-side
126
153
  * list would have nothing to read. It is also the right answer regardless -
127
154
  * a GST tax invoice is a legal document with a fixed format, downloadable and
128
155
  * emailed from one place, and a partner-rendered copy of one would be a second
@@ -8,10 +8,10 @@
8
8
  *
9
9
  * Styling is HeroUI v3 semantic tokens only (`accent`, `muted`, `surface`,
10
10
  * `separator`, ...), never a hardcoded colour - so these components inherit
11
- * whatever theme the host service platform already configured, and one theme
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, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
14
+ export { ConnectProvider, useConnect, useConnectUser, 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.
@@ -24,7 +24,20 @@ export { ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton
24
24
  // Built, compiled into `dist/`, and reachable from no entry point until now -
25
25
  // `ConnectUserMenu` was the only thing that could render it.
26
26
  export { ConnectAccountSessions } from "./components/account-sessions.js";
27
+ // The frame the rest of these components live inside. Exported next to the
28
+ // pieces it composes rather than in a section of its own - an integration
29
+ // reaching for a sidebar wants to find the launcher and the account menu in
30
+ // the same breath, since the shell already mounts both.
31
+ export { ConnectAppShell } from "./components/app-shell.js";
32
+ export { ConnectSideNav } from "./components/side-nav.js";
33
+ export { useActivePath } from "./use-active-path.js";
34
+ // The nav model is plain data and pure functions - a host generating its nav
35
+ // from its own route table needs the types, and `resolveActivePath` is the
36
+ // same longest-prefix rule the sidebar highlights with, so a breadcrumb or a
37
+ // page title derived from it cannot disagree with the rail.
38
+ export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, } from "./components/nav.js";
27
39
  export { ConnectAppsGrid } from "./components/apps-grid.js";
40
+ export { ConnectIntegrationsModal } from "./components/integrations-modal.js";
28
41
  export { ConnectPlans } from "./components/plans.js";
29
42
  export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
30
43
  // The subscribe / upgrade / downgrade flow's two moving parts, exported so a
@@ -34,6 +47,7 @@ export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
34
47
  // Subscription state, gates and the surfaces around the plan grid.
35
48
  export { ConnectSubscriptionGate, ConnectNoSubscriptionCard, ConnectSubscriptionCard, ConnectTrialBanner, ConnectSeatUsage, resolveSubscriptionState, } from "./components/subscription.js";
36
49
  export { ConnectReadinessBanner, ConnectReadinessGate } from "./components/readiness.js";
50
+ export { ConnectAccessGate, useConnectAccessGate } from "./components/access-gate.js";
37
51
  export { ConnectFeatureGate, useConnectFeature } from "./components/feature-gate.js";
38
52
  export { ConnectUsageMeter } from "./components/usage-meter.js";
39
53
  export { ConnectPlansPage } from "./components/plans-page.js";
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  * The Connect mark - concentric rings on a solid accent badge.
5
5
  *
6
6
  * Painted from `--accent` / `--accent-foreground`, the same HeroUI tokens the
7
- * host app themes, so the badge tracks whatever palette a service platform has
7
+ * host app themes, so the badge tracks whatever palette an application platform has
8
8
  * configured rather than carrying a hardcoded CosmoOps green into someone
9
9
  * else's brand.
10
10
  */
@@ -3,6 +3,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
4
  export const CONNECT_APP_URL = "https://connect.cosmoops.com";
5
5
  const ConnectContext = createContext(null);
6
+ /** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
7
+ const SILENT_ATTEMPT_KEY = "connect_silent_attempted";
6
8
  /**
7
9
  * Wraps the part of your app that shows Connect UI.
8
10
  *
@@ -17,14 +19,44 @@ const ConnectContext = createContext(null);
17
19
  * </ConnectProvider>
18
20
  * ```
19
21
  */
20
- export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, children, }) {
22
+ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, children, }) {
21
23
  // One state cell, not three. `isLoading` is *derived* from "no result yet"
22
24
  // rather than set - a `setIsLoading(true)` at the top of an effect is a
23
25
  // synchronous setState inside that effect, which cascades an extra render
24
26
  // and is what `react-hooks/set-state-in-effect` flags. Everything below
25
27
  // writes state only from an async callback.
26
28
  const [result, setResult] = useState(null);
27
- const isLoading = result === null;
29
+ /**
30
+ * Whether this tab has already spent its one silent attempt, sampled once
31
+ * at mount.
32
+ *
33
+ * A lazy `useState` initializer rather than a read inside the render body:
34
+ * `sessionStorage` is external mutable state, and reading it during render
35
+ * is impure - with the React Compiler on, that is exactly the kind of thing
36
+ * that can be hoisted or memoized into the wrong answer. An initializer
37
+ * runs once, which is all this needs; the value only ever changes as this
38
+ * component navigates the page away.
39
+ *
40
+ * Safe on the server, where it reads `false`: `result` is `null` during SSR
41
+ * and first client render alike, so `isLoading` below is `true` on both
42
+ * regardless of this value, and hydration cannot diverge on it.
43
+ */
44
+ const [silentAttemptSpent] = useState(() => typeof window !== "undefined" && window.sessionStorage.getItem(SILENT_ATTEMPT_KEY) === "1");
45
+ /**
46
+ * A silent attempt is settled-signed-out *and* about to redirect. Reported
47
+ * as still-loading, which is the honest answer: the question "is this
48
+ * visitor signed in" has not been finally answered yet, and there is a
49
+ * navigation already on its way.
50
+ *
51
+ * Without this the provider publishes `signed out` for the one frame before
52
+ * the effect below fires, so every consumer paints its signed-out state -
53
+ * a "Sign in" button, the plan-picker gate - and then the page vanishes
54
+ * underneath it. Flashing a sales pitch at someone who is about to be
55
+ * silently signed in is the same bug `ConnectSubscriptionGate` exists to
56
+ * prevent, one layer down.
57
+ */
58
+ const silentPending = silentSignIn && !silentAttemptSpent && result !== null && !result.session.user && !result.error;
59
+ const isLoading = result === null || silentPending;
28
60
  const session = result?.session ?? null;
29
61
  const error = result?.error ?? null;
30
62
  // One entry per path. Held in a ref, not state, because a cache write must
@@ -90,7 +122,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
90
122
  // error, so a Login button still renders rather than the UI hanging on
91
123
  // skeletons forever.
92
124
  setResult({
93
- session: { user: null, subscription: null, serviceRole: null },
125
+ session: { user: null, subscription: null, applicationRole: null },
94
126
  error: cause instanceof Error ? cause.message : "Could not reach Connect",
95
127
  });
96
128
  }
@@ -104,6 +136,21 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
104
136
  // eslint-disable-next-line react-hooks/set-state-in-effect
105
137
  void refresh();
106
138
  }, [refresh]);
139
+ useEffect(() => {
140
+ // `silentPending` already encodes settled-and-signed-out, no prior
141
+ // attempt, and no bridge error - a failed bridge read says nothing about
142
+ // whether Connect has a session, and bouncing the browser on a transient
143
+ // network failure would turn a recoverable error into a navigation nobody
144
+ // asked for.
145
+ if (!silentPending || typeof window === "undefined")
146
+ return;
147
+ // Written before navigating, not after: a navigation that begins before
148
+ // the marker lands leaves the tab unmarked and free to probe again on the
149
+ // way back, which is the loop this bounds.
150
+ window.sessionStorage.setItem(SILENT_ATTEMPT_KEY, "1");
151
+ const next = `${window.location.pathname}${window.location.search}`;
152
+ window.location.href = `${basePath}/signin?prompt=none&next=${encodeURIComponent(next)}`;
153
+ }, [silentPending, basePath]);
107
154
  const signIn = useCallback((options = {}) => {
108
155
  const url = new URL("/sso/authorize", appUrl);
109
156
  url.searchParams.set("client_id", clientId);
@@ -124,7 +171,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
124
171
  credentials: "same-origin",
125
172
  }).catch(() => { });
126
173
  cache.current.clear();
127
- setResult({ session: { user: null, subscription: null, serviceRole: null }, error: null });
174
+ setResult({ session: { user: null, subscription: null, applicationRole: null }, error: null });
128
175
  }, [basePath, fetchImpl]);
129
176
  const value = useMemo(() => ({
130
177
  clientId,
@@ -171,24 +218,28 @@ export function useConnectEntitlements() {
171
218
  /**
172
219
  * What is left of every consumable add-on on this workspace's subscription.
173
220
  *
174
- * The read half of `client.billing.consumeAddon` - a service that meters its
221
+ * The read half of `client.billing.consumeAddon` - an application that meters its
175
222
  * own end users had no way to show them what remained until this existed.
176
223
  * Empty `addons` means there is nothing to meter, which is not an error.
177
224
  */
178
225
  export function useConnectAddonUsage() {
179
226
  return useConnectResource("/addons");
180
227
  }
181
- /** This service's plan catalog. */
228
+ /** This application's plan catalog. */
182
229
  export function useConnectPlans() {
183
230
  return useConnectResource("/plans");
184
231
  }
185
- /** The apps this organization can open. */
232
+ /** The apps this workspace can open. */
186
233
  export function useConnectApps() {
187
234
  return useConnectResource("/apps");
188
235
  }
236
+ /** Enabled Composio-hosted toolkits and this workspace's connection status for each. */
237
+ export function useConnectIntegrations() {
238
+ return useConnectResource("/integrations");
239
+ }
189
240
  /**
190
241
  * This Connect account's own login sessions - account-wide, not scoped to
191
- * this service, see `SessionsListResponse`'s doc comment. The component
242
+ * this application, see `SessionsListResponse`'s doc comment. The component
192
243
  * that calls this is also the one that must own any revoke actions and call
193
244
  * the returned `refetch()` afterward - see `mutateResource`'s doc comment
194
245
  * for why nothing does that automatically.
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ const LOCATION_CHANGE = "connect:locationchange";
4
+ let patched = false;
5
+ /**
6
+ * Make `history.pushState`/`replaceState` announce themselves.
7
+ *
8
+ * Neither fires an event - `popstate` covers Back and Forward only - so a
9
+ * client-side router that pushes a new URL moves the address bar and tells
10
+ * nothing outside itself. Without this, a sidebar reading `location.pathname`
11
+ * highlights whatever route the page first loaded on and then never moves
12
+ * again.
13
+ *
14
+ * Both wrappers call through to the original first and return its result, so
15
+ * the patch is transparent to whatever router installed the navigation; it is
16
+ * applied at most once per document, and only when a `ConnectSideNav` is
17
+ * actually mounted without an `activePath`. Pass `activePath` (Next:
18
+ * `usePathname()`) and none of this runs at all - which is the recommended
19
+ * path for any app that has a router to ask.
20
+ */
21
+ function patchHistory() {
22
+ if (patched || typeof window === "undefined" || typeof window.history === "undefined")
23
+ return;
24
+ patched = true;
25
+ for (const method of ["pushState", "replaceState"]) {
26
+ const original = window.history[method];
27
+ window.history[method] = function patchedHistoryMethod(...args) {
28
+ const result = original.apply(this, args);
29
+ window.dispatchEvent(new Event(LOCATION_CHANGE));
30
+ return result;
31
+ };
32
+ }
33
+ }
34
+ function subscribe(onStoreChange) {
35
+ patchHistory();
36
+ window.addEventListener("popstate", onStoreChange);
37
+ window.addEventListener(LOCATION_CHANGE, onStoreChange);
38
+ return () => {
39
+ window.removeEventListener("popstate", onStoreChange);
40
+ window.removeEventListener(LOCATION_CHANGE, onStoreChange);
41
+ };
42
+ }
43
+ function getSnapshot() {
44
+ return window.location.pathname;
45
+ }
46
+ /**
47
+ * The server never has a location, and guessing one is worse than having
48
+ * none: a guess that misses renders a *different* item highlighted on the
49
+ * server than on the client, which is a hydration mismatch. `""` matches no
50
+ * nav item, so the first paint has no highlight and the client's first commit
51
+ * adds it.
52
+ *
53
+ * Doubles as the snapshot when `activePath` *was* supplied - the value is
54
+ * discarded either way, and pairing it with `noopSubscribe` is what keeps a
55
+ * controlled shell from ever touching `history` or `window.location`.
56
+ */
57
+ function emptySnapshot() {
58
+ return "";
59
+ }
60
+ function noopSubscribe() {
61
+ return () => { };
62
+ }
63
+ /**
64
+ * The current path, from `activePath` when given and from the browser
65
+ * otherwise.
66
+ *
67
+ * Exported because a host building its own shell out of `ConnectSideNav`
68
+ * needs the same fallback the shell uses.
69
+ */
70
+ export function useActivePath(activePath) {
71
+ const controlled = activePath !== undefined;
72
+ const live = useSyncExternalStore(controlled ? noopSubscribe : subscribe, controlled ? emptySnapshot : getSnapshot, emptySnapshot);
73
+ return activePath ?? live;
74
+ }
@@ -152,15 +152,15 @@ export function buildCancelUrl({ appUrl, subscriptionId }) {
152
152
  * The Connect URL that completes a subscribe or a plan change.
153
153
  *
154
154
  * Both land on a modal Connect renders over the relevant page (an intercepted
155
- * parallel route - `app/@modal/(.)services/...` and
155
+ * parallel route - `app/@modal/(.)applications/...` and
156
156
  * `app/@modal/(.)billing/...`), so the customer arrives already looking at the
157
157
  * confirmation for the plan they picked here, not at a catalogue they have to
158
158
  * find it in again.
159
159
  */
160
- export function buildCheckoutUrl({ appUrl, serviceId, planId, subscriptionId, coupon, }) {
160
+ export function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }) {
161
161
  const path = subscriptionId
162
162
  ? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
163
- : `/services/${encodeURIComponent(serviceId)}/subscribe/${encodeURIComponent(planId)}`;
163
+ : `/applications/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
164
164
  const url = new URL(path, appUrl);
165
165
  if (coupon?.trim())
166
166
  url.searchParams.set("coupon", coupon.trim());
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Every failure the Connect service API can report, as one class.
2
+ * Every failure the Connect application API can report, as one class.
3
3
  *
4
4
  * The API speaks two error dialects and this collapses them: the
5
- * `/api/v1/service/*` endpoints use `{ error: { code, message } }`, while the
5
+ * `/api/v1/application/*` endpoints use `{ error: { code, message } }`, while the
6
6
  * three `/sso/*` ones use OAuth2's `{ error, error_description }` (RFC 6749
7
7
  * §5.2). Both land here with `code` populated, so integrating code branches on
8
8
  * one field either way.
@@ -41,7 +41,7 @@ export class ConnectError extends Error {
41
41
  * Whether the user's Connect sign-in is over and they need to go back
42
42
  * through `identity.authorizeUrl()`. Collapses every session-side failure,
43
43
  * matching how the API reports them - unknown, expired, revoked, or issued
44
- * to a different service all arrive as one `invalid_token`.
44
+ * to a different application all arrive as one `invalid_token`.
45
45
  */
46
46
  get isSessionExpired() {
47
47
  return this.code === "invalid_token";
@@ -55,7 +55,7 @@ export class ConnectError extends Error {
55
55
  * guessing keys. These two are the exception: reaching them required
56
56
  * presenting a key whose hash matched, so you already hold it, and what you
57
57
  * need to know is that no retry and no scope change will fix this - the key
58
- * has to be reissued from the service's Setup tab.
58
+ * has to be reissued from the application's Integration tab.
59
59
  *
60
60
  * Worth alerting on. It is the one failure that will not resolve itself and
61
61
  * takes down every call your integration makes.
@@ -12,7 +12,7 @@ export function blockingBlockers(readiness) {
12
12
  * Coerces either wire shape into `PlanFeature[]`.
13
13
  *
14
14
  * A bare key becomes `{ key, name: key, description: "" }` - the same
15
- * fallback the server applies for a plan older than its service's feature
15
+ * fallback the server applies for a plan older than its application's feature
16
16
  * catalog, so an un-upgraded Connect renders exactly what it always did
17
17
  * rather than a column of blanks.
18
18
  *
package/dist/esm/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `@cosmoops/connect-sdk` - the official client for the CosmoOps Connect
3
- * **service-partner API** (`/api/v1/service/*`).
3
+ * **application-partner API** (`/api/v1/application/*`).
4
4
  *
5
5
  * This root entry is the isomorphic surface: wire types, the error class, and
6
6
  * pure helpers. It pulls in neither Node-only code nor React, so it is safe to