@cosmoops/connect-sdk 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,133 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.32.0
7
+
8
+ Push notifications: `connect.push.send()` on the server, `useConnectPush()` in
9
+ the browser.
10
+
11
+ **The point is what your application no longer holds.** It holds no VAPID key,
12
+ stores no push endpoint, and needs no notification table of its own. It names
13
+ a workspace, some members and three strings; Connect resolves those members'
14
+ devices, applies each member's own preferences, and signs the payload.
15
+
16
+ Two things follow that are worth more than the code saved:
17
+
18
+ - **A member grants the browser prompt once for the platform**, not once per
19
+ app. The same device receives your notifications, every other app's, and
20
+ Connect's own — and one switch in their Connect profile silences the lot.
21
+ That matters because a browser will not re-prompt once it has been told no,
22
+ so every app asking separately is a queue of chances to lose the permission
23
+ for all of them.
24
+ - **You never learn who has a device.** There is no read that answers it, by
25
+ design. A send reports a fan-out; it does not report an audience.
26
+
27
+ ```ts
28
+ // Your server — a task fell due.
29
+ await connect.push.send({
30
+ workspaceId,
31
+ userIds: [ownerId],
32
+ title: "A task is due",
33
+ body: "Follow up with Priya Sharma",
34
+ url: "https://crm.example.com/tasks",
35
+ });
36
+ ```
37
+
38
+ ```tsx
39
+ // Your browser — registering this device.
40
+ const { status, enable, error } = useConnectPush("/sw.js");
41
+ ```
42
+
43
+ Three things to know before wiring it up:
44
+
45
+ **`delivered: 0` is a success.** The member may have registered no device,
46
+ silenced notifications from their apps, or denied the prompt — all ordinary. A
47
+ caller that retries on zero retries forever against somebody who opted out.
48
+ Treat a notification as a nudge toward something already true in your own data,
49
+ and never let a send failure fail the thing that triggered it.
50
+
51
+ **`url` must be absolute and on an origin you registered as an SSO redirect
52
+ URI.** Connect refuses anything else: a URL it did not check is an open
53
+ redirect delivered to a lock screen, wearing the styling of an app the person
54
+ trusts.
55
+
56
+ **You supply the service worker.** The SDK does not install one — a worker is a
57
+ site-wide concern with caching implications only you can decide. It needs to
58
+ handle `push` and `notificationclick`; the payload is `{title, body, url, tag}`.
59
+
60
+ `status` has five values rather than being a boolean, and the one that matters
61
+ is `denied`: it cannot be undone by script, only from the browser's own site
62
+ settings. A control that silently does nothing when pressed is the worst
63
+ version of that screen, so the state is surfaced for you to explain.
64
+
65
+ Requires the `push:send` scope on your API key.
66
+
67
+ ## 0.31.0
68
+
69
+ `connect.enrichment.company()` tells you what a company *does*. This adds
70
+ `connect.enrichment.brand()`, which tells you what it *looks like*: its logo,
71
+ its palette, its typefaces, and the radius, spacing and breakpoint scales its
72
+ own stylesheets declare.
73
+
74
+ The reason this needed building rather than prompting: **these values are
75
+ measured, not generated.** Ask any model for a company's brand colour and it
76
+ answers `#3B82F6` with complete confidence, because that is the median of
77
+ every site it has ever seen. Every hex in this response was parsed out of a
78
+ real stylesheet, every scale step is a count of real declarations, and
79
+ `palette[].source` tells you which parse produced it — a `css-var` entry is
80
+ the company naming its own colour in its own tokens, a `frequency` entry is a
81
+ ranked count and much weaker. A model is involved in exactly two places:
82
+ writing the prose fields, and assigning a `role` to a value it was shown.
83
+
84
+ `officialName` follows the same discipline the company lookup applies to
85
+ `legalName`. It is filled only from a legal disclosure the company published
86
+ itself — a footer copyright line, or the entity named on its Terms page —
87
+ matched against a corporate suffix by code and cited. `officialNameSource`
88
+ says which. It is never a model's recollection of a company register.
89
+
90
+ ### Added
91
+
92
+ - **`connect.enrichment.brand()`** — read a company's brand kit and design
93
+ system from its domain.
94
+
95
+ ```ts
96
+ const result = await connect.enrichment.brand({ domain: "acme.com" });
97
+ if (result.status === "extracted") {
98
+ const primary = result.profile.brandKit.palette.find((c) => c.role === "primary");
99
+ const body = result.profile.brandKit.typography.find((t) => t.role === "body");
100
+ }
101
+ ```
102
+
103
+ Needs an API key scoped to the new **`enrichment:brand`**. That is separate
104
+ from `enrichment:read` because the cost differs by an order of magnitude: a
105
+ company lookup is three page fetches, a brand read crawls stylesheets, may
106
+ drive a headless browser, and caches image bytes.
107
+
108
+ Three sharp edges, in the order they will bite you:
109
+
110
+ **Set your HTTP client timeout to at least 90 seconds.** A cold read runs
111
+ the full extraction before answering and can take up to a minute. Many
112
+ clients — including `undici` and most `axios` setups — default to 30s and
113
+ will abandon a request that was about to succeed. Cached reads return in
114
+ milliseconds, and cached is the normal case: profiles are platform-owned
115
+ and held for 90 days, so you pay the cold path roughly once per domain
116
+ across all of your users.
117
+
118
+ **Never persist `brandKit.logos[].url`.** It is a short-lived signed URL,
119
+ minted fresh on every read. Stored in your own database or baked into a
120
+ cached page it becomes a broken image with a plausible-looking address.
121
+ Render it directly, or re-read the profile. `sourceUrl` is the stable value
122
+ and points at the company's own site — though it is theirs, so it can move.
123
+
124
+ **A `skipped` status is a normal 200.** `no_stylesheet` is the one new
125
+ reason worth handling specially: the site answered and said things, but
126
+ shipped no CSS this reader could measure.
127
+
128
+ `designSystem.framework` is worth reading first. It tells you how to
129
+ interpret everything else — a Tailwind site's spacing scale is Tailwind's
130
+ defaults rather than a considered choice, and a palette full of `--bs-*` is
131
+ Bootstrap's.
132
+
6
133
  ## 0.30.1
7
134
 
8
135
  `ConnectAppShell` was putting **462 KB** of markdown renderer on every page of
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.flattenNav = exports.normalizeNav = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectOneTap = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnect = exports.ConnectProvider = void 0;
5
- exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = exports.normalizePath = void 0;
6
- exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = void 0;
4
+ exports.normalizeNav = exports.useConnectPush = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectOneTap = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnect = exports.ConnectProvider = void 0;
5
+ exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = exports.normalizePath = exports.flattenNav = void 0;
6
+ exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = exports.resolveAction = void 0;
7
7
  /**
8
8
  * Browser half of `@cosmoops/connect-sdk` - React components and hooks.
9
9
  *
@@ -104,6 +104,13 @@ Object.defineProperty(exports, "ConnectSidebarNav", { enumerable: true, get: fun
104
104
  Object.defineProperty(exports, "ConnectSideNav", { enumerable: true, get: function () { return side_nav_js_1.ConnectSideNav; } });
105
105
  var use_active_path_js_1 = require("./use-active-path.js");
106
106
  Object.defineProperty(exports, "useActivePath", { enumerable: true, get: function () { return use_active_path_js_1.useActivePath; } });
107
+ /**
108
+ * Web Push. The hook owns the browser half - permission, the worker, the
109
+ * subscription - and Connect owns the keys, the devices and the member's
110
+ * preferences. Sending is server-side, through `connect.push.send`.
111
+ */
112
+ var use_push_js_1 = require("./use-push.js");
113
+ Object.defineProperty(exports, "useConnectPush", { enumerable: true, get: function () { return use_push_js_1.useConnectPush; } });
107
114
  // The nav model is plain data and pure functions - a host generating its nav
108
115
  // from its own route table needs the types, and `resolveActivePath` is the
109
116
  // same longest-prefix rule the sidebar highlights with, so a breadcrumb or a
@@ -0,0 +1,139 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.useConnectPush = useConnectPush;
5
+ const react_1 = require("react");
6
+ const provider_js_1 = require("./provider.js");
7
+ /**
8
+ * Read the browser's own permission as external state.
9
+ *
10
+ * Not `useState` in an effect: the permission belongs to the browser, it can
11
+ * change from the address bar while the page is open, and it does not exist
12
+ * during a server render. `useSyncExternalStore` is the primitive for exactly
13
+ * that - a server snapshot of `"checking"` and a live client one.
14
+ */
15
+ function subscribeToPermission(onChange) {
16
+ let status = null;
17
+ let cancelled = false;
18
+ // Absent on some Safari builds, and throws on others. Neither is fatal:
19
+ // without the subscription the snapshot is still correct on every render, it
20
+ // simply will not update by itself.
21
+ navigator.permissions
22
+ ?.query({ name: "notifications" })
23
+ .then((result) => {
24
+ if (cancelled)
25
+ return;
26
+ status = result;
27
+ result.addEventListener("change", onChange);
28
+ })
29
+ .catch(() => { });
30
+ return () => {
31
+ cancelled = true;
32
+ status?.removeEventListener("change", onChange);
33
+ };
34
+ }
35
+ function permissionSnapshot() {
36
+ if (typeof navigator === "undefined" ||
37
+ !("serviceWorker" in navigator) ||
38
+ typeof window === "undefined" ||
39
+ !("PushManager" in window) ||
40
+ !("Notification" in window)) {
41
+ return "unsupported";
42
+ }
43
+ if (Notification.permission === "denied")
44
+ return "denied";
45
+ return Notification.permission === "granted" ? "granted" : "prompt";
46
+ }
47
+ const serverSnapshot = () => "checking";
48
+ /**
49
+ * Register this browser for the platform's notifications.
50
+ *
51
+ * The subscription is **Connect's, not your application's**: a member who
52
+ * turns notifications on in one app has turned them on for the platform, and
53
+ * the same device receives every other app's notifications and Connect's own.
54
+ * That is the point of centralising it - one browser prompt instead of one per
55
+ * app, and one place to turn everything off.
56
+ *
57
+ * Your application needs a service worker at its own origin that handles the
58
+ * `push` event; the SDK does not install one, because a worker is a
59
+ * site-wide concern with caching implications only the host can decide.
60
+ *
61
+ * @param serviceWorkerUrl Where your worker lives. Root-scoped by convention.
62
+ */
63
+ function useConnectPush(serviceWorkerUrl = "/sw.js") {
64
+ const { fetchResource, mutateResource } = (0, provider_js_1.useConnect)();
65
+ const permission = (0, react_1.useSyncExternalStore)(subscribeToPermission, permissionSnapshot, serverSnapshot);
66
+ const [isEnabling, setIsEnabling] = (0, react_1.useState)(false);
67
+ const [error, setError] = (0, react_1.useState)(null);
68
+ const [unconfigured, setUnconfigured] = (0, react_1.useState)(false);
69
+ const enable = (0, react_1.useCallback)(async () => {
70
+ setError(null);
71
+ setIsEnabling(true);
72
+ try {
73
+ /*
74
+ * The key first, and the order matters: if the platform cannot send,
75
+ * asking for permission spends the member's one prompt on a feature
76
+ * that will never deliver - and a denial cannot be re-asked.
77
+ */
78
+ const key = await fetchResource("/push/key");
79
+ if (!key.configured || !key.publicKey) {
80
+ setUnconfigured(true);
81
+ return null;
82
+ }
83
+ if ((await Notification.requestPermission()) !== "granted")
84
+ return null;
85
+ const registration = await navigator.serviceWorker.register(serviceWorkerUrl);
86
+ // `ready` rather than the registration itself: one that is still
87
+ // installing has no `pushManager`, and subscribing against it throws in
88
+ // a way that reads as a permission problem.
89
+ await navigator.serviceWorker.ready;
90
+ const subscription = await registration.pushManager.subscribe({
91
+ // Required, and it may not be false - a silent push is reserved for
92
+ // sites with a far higher engagement bar, and asking for one here has
93
+ // the browser refuse the subscription outright.
94
+ userVisibleOnly: true,
95
+ applicationServerKey: base64UrlToBytes(key.publicKey),
96
+ });
97
+ const json = subscription.toJSON();
98
+ const registered = await mutateResource("/push/subscribe", {
99
+ endpoint: json.endpoint,
100
+ keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
101
+ });
102
+ return registered.id;
103
+ }
104
+ catch {
105
+ setError("This browser would not register for notifications. Reload the page and try again.");
106
+ return null;
107
+ }
108
+ finally {
109
+ setIsEnabling(false);
110
+ }
111
+ }, [fetchResource, mutateResource, serviceWorkerUrl]);
112
+ const disable = (0, react_1.useCallback)(async (deviceId) => {
113
+ await mutateResource("/push/unsubscribe", { id: deviceId });
114
+ }, [mutateResource]);
115
+ return {
116
+ status: unconfigured && permission !== "denied" ? "unconfigured" : permission,
117
+ isEnabling,
118
+ error,
119
+ enable,
120
+ disable,
121
+ };
122
+ }
123
+ /**
124
+ * The VAPID public key as the bytes `pushManager.subscribe()` wants.
125
+ *
126
+ * It travels base64url and the API takes a `BufferSource`. Spelled out rather
127
+ * than inlined because getting it wrong produces an `InvalidAccessError` that
128
+ * reads as a permissions fault rather than an encoding one - and the explicit
129
+ * `ArrayBuffer` matters too: `Uint8Array.from` widens to `ArrayBufferLike`,
130
+ * which `applicationServerKey` will not accept.
131
+ */
132
+ function base64UrlToBytes(base64) {
133
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
134
+ const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
135
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
136
+ for (let index = 0; index < raw.length; index += 1)
137
+ bytes[index] = raw.charCodeAt(index);
138
+ return bytes;
139
+ }
@@ -23,6 +23,7 @@ const analytics_js_1 = require("./resources/analytics.js");
23
23
  const otp_js_1 = require("./resources/otp.js");
24
24
  const sessions_js_1 = require("./resources/sessions.js");
25
25
  const platform_js_1 = require("./resources/platform.js");
26
+ const push_js_1 = require("./resources/push.js");
26
27
  exports.DEFAULT_BASE_URL = "https://connect.cosmoops.com";
27
28
  const APPLICATION_API_PREFIX = "/api/v1/application";
28
29
  /**
@@ -116,6 +117,11 @@ class ConnectClient {
116
117
  workspaces;
117
118
  /** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
118
119
  platform;
120
+ /**
121
+ * Web Push. Connect holds the VAPID keys, the devices and the member's own
122
+ * preferences; you supply a title, a body and a URL. See `PushResource`.
123
+ */
124
+ push;
119
125
  /**
120
126
  * Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
121
127
  * store route and nothing else. See `storePassthrough`.
@@ -160,9 +166,10 @@ class ConnectClient {
160
166
  this.integrations = new integrations_js_1.IntegrationsResource(transport);
161
167
  this.workspaces = new workspaces_js_1.WorkspacesResource(transport);
162
168
  this.platform = new platform_js_1.PlatformResource(transport);
169
+ this.push = new push_js_1.PushResource(transport);
163
170
  this.storeRelay = (spec) => (0, data_js_1.storePassthrough)(transport, spec);
164
171
  }
165
172
  }
166
173
  exports.ConnectClient = ConnectClient;
167
174
  /** Kept in step with `package.json` by `sdk.test.ts`. */
168
- exports.SDK_VERSION = "0.30.1";
175
+ exports.SDK_VERSION = "0.32.0";
@@ -516,6 +516,60 @@ function createConnectHandler(options) {
516
516
  * switching is simply absent for them rather than half-working - the same
517
517
  * treatment `/signin` and `/callback` already get.
518
518
  */
519
+ /*
520
+ * Push notifications - the browser half, and deliberately only that half.
521
+ *
522
+ * **There is no `POST /push` here**, and its absence is the design: a
523
+ * notification anybody can trigger from a devtools console is one an
524
+ * attacker can trigger from a compromised page, wearing the styling of an
525
+ * app the person trusts. Sending is a server-to-server act with your API
526
+ * key; Connect's own route refuses the session-token path outright.
527
+ *
528
+ * What a browser legitimately does is register itself, because
529
+ * `pushManager.subscribe()` produces a subscription no server can make on
530
+ * its behalf. All three routes resolve the member from their session, so
531
+ * there is no `userId` anywhere on this surface - a page cannot register a
532
+ * device against somebody else's account, nor list theirs.
533
+ */
534
+ "GET /push/key": async (request) => json(await client.push.key({ asUser: await requireToken(request) })),
535
+ "POST /push/subscribe": async (request) => {
536
+ const token = await requireToken(request);
537
+ const body = (await request.json().catch(() => null));
538
+ // Shape-checked here rather than relayed blindly, so a malformed
539
+ // subscription fails with a sentence naming what is missing instead of
540
+ // a Zod message from a server the page cannot see.
541
+ if (typeof body?.endpoint !== "string" ||
542
+ typeof body.keys?.p256dh !== "string" ||
543
+ typeof body.keys?.auth !== "string") {
544
+ throw new errors_js_1.ConnectError({
545
+ status: 400,
546
+ code: "validation_error",
547
+ message: "endpoint and keys.p256dh/keys.auth are required",
548
+ endpoint: "POST /push/subscribe",
549
+ });
550
+ }
551
+ return json(await client.push.registerDevice({
552
+ endpoint: body.endpoint,
553
+ keys: { p256dh: body.keys.p256dh, auth: body.keys.auth },
554
+ // Taken from the request rather than the body: a page can put
555
+ // anything in a field, and this one is shown back on a device
556
+ // list where a spoofed entry would be the point of spoofing it.
557
+ userAgent: (request.headers.get("user-agent") ?? "").slice(0, 512),
558
+ }, { asUser: token }));
559
+ },
560
+ "POST /push/unsubscribe": async (request) => {
561
+ const token = await requireToken(request);
562
+ const body = (await request.json().catch(() => null));
563
+ if (typeof body?.id !== "string") {
564
+ throw new errors_js_1.ConnectError({
565
+ status: 400,
566
+ code: "validation_error",
567
+ message: "id is required",
568
+ endpoint: "POST /push/unsubscribe",
569
+ });
570
+ }
571
+ return json(await client.push.revokeDevice(body.id, { asUser: token }));
572
+ },
519
573
  "GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
520
574
  "GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
521
575
  "POST /workspaces/switch": async (request) => {
@@ -107,5 +107,40 @@ class AiResource {
107
107
  });
108
108
  return result.data;
109
109
  }
110
+ /**
111
+ * Draft every section of a platform-defined assessment from one sentence.
112
+ *
113
+ * The intended flow is draft first, then let the person correct it section
114
+ * by section: a blank questionnaire is the reason most people abandon one,
115
+ * and editing a strawman is a far smaller ask than authoring from nothing.
116
+ *
117
+ * Unattributed - assessments run against platform-wide definitions and the
118
+ * first consumers run before anyone has signed in, so there is no `asUser`
119
+ * and no workspace here.
120
+ */
121
+ async draftAssessment(input) {
122
+ const result = await (0, http_js_1.request)(this.transport, {
123
+ method: "POST",
124
+ path: "/ai/assessment/draft",
125
+ json: input,
126
+ });
127
+ return result.data;
128
+ }
129
+ /**
130
+ * Critique a completed assessment - the tensions between sections, the
131
+ * riskiest assumption, and a coarse score.
132
+ *
133
+ * Unknown section keys in `answers` are ignored rather than rejected, so a
134
+ * definition changing under a half-finished assessment costs the reader
135
+ * nothing.
136
+ */
137
+ async reviewAssessment(input) {
138
+ const result = await (0, http_js_1.request)(this.transport, {
139
+ method: "POST",
140
+ path: "/ai/assessment/review",
141
+ json: input,
142
+ });
143
+ return result.data;
144
+ }
110
145
  }
111
146
  exports.AiResource = AiResource;
@@ -52,5 +52,53 @@ class EnrichmentResource {
52
52
  signal: options.signal,
53
53
  });
54
54
  }
55
+ /**
56
+ * What the company at a domain **looks like** - logo, palette, typefaces,
57
+ * and the radius, spacing and breakpoint scales its own stylesheets declare.
58
+ *
59
+ * The sibling of `company()` above: that one answers what a company does,
60
+ * this one answers what it looks like. Useful for white-labelling a portal
61
+ * to a customer's brand, pre-filling a design brief, or showing a real
62
+ * account card instead of a grey placeholder.
63
+ *
64
+ * Needs an API key scoped to **`enrichment:brand`** - separate from
65
+ * `enrichment:read` because it is far more expensive: it crawls
66
+ * stylesheets, may render the page in a headless browser, and caches image
67
+ * bytes.
68
+ *
69
+ * ```ts
70
+ * const result = await connect.enrichment.brand({ domain: "acme.com" });
71
+ * if (result.status === "extracted") {
72
+ * const primary = result.profile.brandKit.palette.find((c) => c.role === "primary");
73
+ * }
74
+ * ```
75
+ *
76
+ * Three things to know before wiring it in:
77
+ *
78
+ * - **Set your client timeout to at least 90 seconds.** A cold read runs
79
+ * the whole extraction before answering and can take up to a minute; many
80
+ * HTTP clients default to 30s and abandon a request that was about to
81
+ * succeed. Cached reads return in milliseconds, and cached is the normal
82
+ * case - profiles are held for 90 days, so you pay the cold path roughly
83
+ * once per domain.
84
+ * - **Never store `brandKit.logos[].url`.** It is a short-lived signed URL
85
+ * minted per read. Render it directly or re-read the profile; persist
86
+ * `sourceUrl` if you need something stable.
87
+ * - **The values were measured, not generated.** Check `palette[].source` -
88
+ * a `css-var` entry is the company naming its own colour, a `frequency`
89
+ * entry is a ranked count and much weaker.
90
+ */
91
+ brand(input, options = {}) {
92
+ return (0, http_js_1.request)(this.transport, {
93
+ method: "POST",
94
+ path: "/enrichment/brand",
95
+ json: {
96
+ domain: input.domain,
97
+ ...(input.refresh ? { refresh: true } : {}),
98
+ },
99
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
100
+ signal: options.signal,
101
+ });
102
+ }
55
103
  }
56
104
  exports.EnrichmentResource = EnrichmentResource;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PushResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ const acting_as_js_1 = require("./acting-as.js");
6
+ /**
7
+ * Web Push, with Connect holding everything that makes it work.
8
+ *
9
+ * Your application supplies a title, a body, a URL and the members to reach.
10
+ * Connect owns the VAPID keys it signs with, the devices each member has
11
+ * registered across every application on the platform, that member's own
12
+ * notification preferences, and the bookkeeping when a browser goes away.
13
+ *
14
+ * So you never hold a push credential, never store an endpoint, and cannot
15
+ * reach anybody who is not an active member of the workspace you name -
16
+ * membership is re-checked on every send rather than trusted from the ids you
17
+ * pass.
18
+ *
19
+ * ## Two halves, and only `send` belongs on your server
20
+ *
21
+ * Registering a device happens in the **browser**, because a subscription is
22
+ * produced by `pushManager.subscribe()` against the member's own browser and
23
+ * no server can make one for them. `registerDevice` and `revokeDevice` below
24
+ * therefore take a session token and are what the SDK bridge calls on the
25
+ * browser's behalf - see `createConnectHandler`. You will normally reach them
26
+ * through `useConnectPush()` in `@cosmoops/connect-sdk/client` rather than
27
+ * directly.
28
+ */
29
+ class PushResource {
30
+ transport;
31
+ constructor(transport) {
32
+ this.transport = transport;
33
+ }
34
+ /**
35
+ * Notify members of a workspace.
36
+ *
37
+ * **`delivered: 0` is a success, not a failure.** The member may have
38
+ * registered no device, silenced notifications from their apps, or denied
39
+ * the browser prompt - all ordinary. A caller that retries on zero will
40
+ * retry forever against somebody who has opted out.
41
+ *
42
+ * Treat every send as a nudge toward something already true in your own
43
+ * data, and never let a failure here fail the thing that triggered it: the
44
+ * reminder is worth less than the record it is about.
45
+ *
46
+ * `url` must be absolute and on an origin this application registered as an
47
+ * SSO redirect URI. Connect refuses anything else, because a URL it did not
48
+ * check is an open redirect delivered to a lock screen.
49
+ */
50
+ send(input) {
51
+ return (0, http_js_1.request)(this.transport, { method: "POST", path: "/push", json: input });
52
+ }
53
+ /**
54
+ * The VAPID public key a browser needs before it can subscribe, and whether
55
+ * the platform can send at all.
56
+ *
57
+ * Safe to hand to any signed-in caller: it is the verification half of the
58
+ * pair, so holding it lets a browser *receive* what Connect signs and never
59
+ * send anything.
60
+ */
61
+ key(options) {
62
+ return (0, http_js_1.request)(this.transport, {
63
+ method: "GET",
64
+ path: "/push/subscriptions",
65
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
66
+ });
67
+ }
68
+ /** Register one browser, from a subscription that browser just produced. */
69
+ registerDevice(input, options) {
70
+ return (0, http_js_1.request)(this.transport, {
71
+ method: "POST",
72
+ path: "/push/subscriptions",
73
+ json: input,
74
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
75
+ });
76
+ }
77
+ /**
78
+ * Stop notifications on one browser.
79
+ *
80
+ * The id is the one `registerDevice` returned. Connect re-checks that it
81
+ * belongs to the calling member, so a guessed id is a refusal rather than
82
+ * somebody else's device going quiet.
83
+ */
84
+ revokeDevice(id, options) {
85
+ return (0, http_js_1.request)(this.transport, {
86
+ method: "DELETE",
87
+ path: `/push/subscriptions?id=${encodeURIComponent(id)}`,
88
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
89
+ });
90
+ }
91
+ }
92
+ exports.PushResource = PushResource;
@@ -52,6 +52,12 @@ export { ConnectSidebar, ConnectSidebarHeader, ConnectSidebarBody, ConnectSideba
52
52
  export { ConnectShellProvider, useConnectShell, sidebarRowClass, RAIL_COLLAPSED, RAIL_EXPANDED, } from "./components/shell-context.js";
53
53
  export { ConnectSidebarNav, ConnectSideNav } from "./components/side-nav.js";
54
54
  export { useActivePath } from "./use-active-path.js";
55
+ /**
56
+ * Web Push. The hook owns the browser half - permission, the worker, the
57
+ * subscription - and Connect owns the keys, the devices and the member's
58
+ * preferences. Sending is server-side, through `connect.push.send`.
59
+ */
60
+ export { useConnectPush } from "./use-push.js";
55
61
  // The nav model is plain data and pure functions - a host generating its nav
56
62
  // from its own route table needs the types, and `resolveActivePath` is the
57
63
  // same longest-prefix rule the sidebar highlights with, so a breadcrumb or a