@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 +127 -0
- package/dist/cjs/client/index.js +10 -3
- package/dist/cjs/client/use-push.js +139 -0
- package/dist/cjs/server/client.js +8 -1
- package/dist/cjs/server/handler.js +54 -0
- package/dist/cjs/server/resources/ai.js +35 -0
- package/dist/cjs/server/resources/enrichment.js +48 -0
- package/dist/cjs/server/resources/push.js +92 -0
- package/dist/esm/client/index.js +6 -0
- package/dist/esm/client/use-push.js +136 -0
- package/dist/esm/server/client.js +8 -1
- package/dist/esm/server/handler.js +54 -0
- package/dist/esm/server/resources/ai.js +35 -0
- package/dist/esm/server/resources/enrichment.js +48 -0
- package/dist/esm/server/resources/push.js +88 -0
- package/dist/types/client/index.d.ts +7 -0
- package/dist/types/client/use-push.d.ts +56 -0
- package/dist/types/server/client.d.ts +7 -1
- package/dist/types/server/resources/ai.d.ts +28 -1
- package/dist/types/server/resources/enrichment.d.ts +46 -1
- package/dist/types/server/resources/push.d.ts +73 -0
- package/dist/types/types.d.ts +270 -0
- package/package.json +1 -1
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
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
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.
|
|
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;
|
package/dist/esm/client/index.js
CHANGED
|
@@ -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
|