@cosmoops/connect-sdk 0.30.0 → 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 +153 -0
- package/dist/cjs/client/components/ask-ai.js +61 -6
- 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/components/ask-ai.js +29 -7
- 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/components/ask-ai.d.ts +1 -1
- 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
|
@@ -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;
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Button, Input, Label, TextField } from "@heroui/react";
|
|
5
5
|
import { Brain, ChevronDown, History, Plus, Send, Sparkles, Square, X } from "lucide-react";
|
|
6
|
-
import { Streamdown } from "streamdown";
|
|
7
6
|
import { useConnect } from "../provider.js";
|
|
7
|
+
/**
|
|
8
|
+
* The markdown renderer, fetched the first time there is markdown to render.
|
|
9
|
+
*
|
|
10
|
+
* `streamdown` is **462 KB** of the host's client bundle, and a static import
|
|
11
|
+
* here put all of it on every page of every application platform that mounts
|
|
12
|
+
* `ConnectAppShell` - which is every page, because the shell owns the layout.
|
|
13
|
+
* It rendered nothing until somebody opened the assistant, which most page
|
|
14
|
+
* views never do. Measured on one partner app: 48 of 49 routes carrying it,
|
|
15
|
+
* about a quarter of each route's JavaScript.
|
|
16
|
+
*
|
|
17
|
+
* `lazy` rather than a conditional `import()` in an effect: React caches the
|
|
18
|
+
* module promise, so the chunk is requested once no matter how many messages
|
|
19
|
+
* render, and the boundary below is what covers the gap.
|
|
20
|
+
*
|
|
21
|
+
* The fallback is the **raw text**, not a spinner. An answer streams in token
|
|
22
|
+
* by token, and unformatted prose is readable - so the first words appear
|
|
23
|
+
* while the renderer is still arriving rather than behind it. Once it lands,
|
|
24
|
+
* the same text re-renders as markdown.
|
|
25
|
+
*/
|
|
26
|
+
const Streamdown = lazy(async () => {
|
|
27
|
+
const mod = await import("streamdown");
|
|
28
|
+
return { default: mod.Streamdown };
|
|
29
|
+
});
|
|
8
30
|
import { useActivePath } from "../use-active-path.js";
|
|
9
31
|
import { matchConnectScreen } from "./ai-screens.js";
|
|
10
32
|
import { AiActions, AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
|
|
@@ -264,11 +286,11 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
|
|
|
264
286
|
*/
|
|
265
287
|
const resolvedPath = useActivePath(activePath);
|
|
266
288
|
const currentScreen = useMemo(() => (resolvedPath ? matchConnectScreen(screens, resolvedPath) : null), [screens, resolvedPath]);
|
|
267
|
-
const renderMarkdown = useCallback((text, isStreaming = false) => (_jsx(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
289
|
+
const renderMarkdown = useCallback((text, isStreaming = false) => (_jsx(Suspense, { fallback: _jsx("div", { className: "connect-ai-body whitespace-pre-wrap", children: text }), children: _jsx(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
|
|
290
|
+
// Retrieved documents cite real URLs; an interstitial on every one of
|
|
291
|
+
// them turns a citation into a chore. The renderer still sanitises the
|
|
292
|
+
// markup itself, which is the part that matters.
|
|
293
|
+
linkSafety: LINK_SAFETY, children: text }) })), [markdownPlugins]);
|
|
272
294
|
const stop = useCallback(() => {
|
|
273
295
|
abortRef.current?.abort();
|
|
274
296
|
abortRef.current = null;
|
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
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { useConnect } from "./provider.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read the browser's own permission as external state.
|
|
6
|
+
*
|
|
7
|
+
* Not `useState` in an effect: the permission belongs to the browser, it can
|
|
8
|
+
* change from the address bar while the page is open, and it does not exist
|
|
9
|
+
* during a server render. `useSyncExternalStore` is the primitive for exactly
|
|
10
|
+
* that - a server snapshot of `"checking"` and a live client one.
|
|
11
|
+
*/
|
|
12
|
+
function subscribeToPermission(onChange) {
|
|
13
|
+
let status = null;
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
// Absent on some Safari builds, and throws on others. Neither is fatal:
|
|
16
|
+
// without the subscription the snapshot is still correct on every render, it
|
|
17
|
+
// simply will not update by itself.
|
|
18
|
+
navigator.permissions
|
|
19
|
+
?.query({ name: "notifications" })
|
|
20
|
+
.then((result) => {
|
|
21
|
+
if (cancelled)
|
|
22
|
+
return;
|
|
23
|
+
status = result;
|
|
24
|
+
result.addEventListener("change", onChange);
|
|
25
|
+
})
|
|
26
|
+
.catch(() => { });
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
status?.removeEventListener("change", onChange);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function permissionSnapshot() {
|
|
33
|
+
if (typeof navigator === "undefined" ||
|
|
34
|
+
!("serviceWorker" in navigator) ||
|
|
35
|
+
typeof window === "undefined" ||
|
|
36
|
+
!("PushManager" in window) ||
|
|
37
|
+
!("Notification" in window)) {
|
|
38
|
+
return "unsupported";
|
|
39
|
+
}
|
|
40
|
+
if (Notification.permission === "denied")
|
|
41
|
+
return "denied";
|
|
42
|
+
return Notification.permission === "granted" ? "granted" : "prompt";
|
|
43
|
+
}
|
|
44
|
+
const serverSnapshot = () => "checking";
|
|
45
|
+
/**
|
|
46
|
+
* Register this browser for the platform's notifications.
|
|
47
|
+
*
|
|
48
|
+
* The subscription is **Connect's, not your application's**: a member who
|
|
49
|
+
* turns notifications on in one app has turned them on for the platform, and
|
|
50
|
+
* the same device receives every other app's notifications and Connect's own.
|
|
51
|
+
* That is the point of centralising it - one browser prompt instead of one per
|
|
52
|
+
* app, and one place to turn everything off.
|
|
53
|
+
*
|
|
54
|
+
* Your application needs a service worker at its own origin that handles the
|
|
55
|
+
* `push` event; the SDK does not install one, because a worker is a
|
|
56
|
+
* site-wide concern with caching implications only the host can decide.
|
|
57
|
+
*
|
|
58
|
+
* @param serviceWorkerUrl Where your worker lives. Root-scoped by convention.
|
|
59
|
+
*/
|
|
60
|
+
export function useConnectPush(serviceWorkerUrl = "/sw.js") {
|
|
61
|
+
const { fetchResource, mutateResource } = useConnect();
|
|
62
|
+
const permission = useSyncExternalStore(subscribeToPermission, permissionSnapshot, serverSnapshot);
|
|
63
|
+
const [isEnabling, setIsEnabling] = useState(false);
|
|
64
|
+
const [error, setError] = useState(null);
|
|
65
|
+
const [unconfigured, setUnconfigured] = useState(false);
|
|
66
|
+
const enable = useCallback(async () => {
|
|
67
|
+
setError(null);
|
|
68
|
+
setIsEnabling(true);
|
|
69
|
+
try {
|
|
70
|
+
/*
|
|
71
|
+
* The key first, and the order matters: if the platform cannot send,
|
|
72
|
+
* asking for permission spends the member's one prompt on a feature
|
|
73
|
+
* that will never deliver - and a denial cannot be re-asked.
|
|
74
|
+
*/
|
|
75
|
+
const key = await fetchResource("/push/key");
|
|
76
|
+
if (!key.configured || !key.publicKey) {
|
|
77
|
+
setUnconfigured(true);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if ((await Notification.requestPermission()) !== "granted")
|
|
81
|
+
return null;
|
|
82
|
+
const registration = await navigator.serviceWorker.register(serviceWorkerUrl);
|
|
83
|
+
// `ready` rather than the registration itself: one that is still
|
|
84
|
+
// installing has no `pushManager`, and subscribing against it throws in
|
|
85
|
+
// a way that reads as a permission problem.
|
|
86
|
+
await navigator.serviceWorker.ready;
|
|
87
|
+
const subscription = await registration.pushManager.subscribe({
|
|
88
|
+
// Required, and it may not be false - a silent push is reserved for
|
|
89
|
+
// sites with a far higher engagement bar, and asking for one here has
|
|
90
|
+
// the browser refuse the subscription outright.
|
|
91
|
+
userVisibleOnly: true,
|
|
92
|
+
applicationServerKey: base64UrlToBytes(key.publicKey),
|
|
93
|
+
});
|
|
94
|
+
const json = subscription.toJSON();
|
|
95
|
+
const registered = await mutateResource("/push/subscribe", {
|
|
96
|
+
endpoint: json.endpoint,
|
|
97
|
+
keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
|
|
98
|
+
});
|
|
99
|
+
return registered.id;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
setError("This browser would not register for notifications. Reload the page and try again.");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
setIsEnabling(false);
|
|
107
|
+
}
|
|
108
|
+
}, [fetchResource, mutateResource, serviceWorkerUrl]);
|
|
109
|
+
const disable = useCallback(async (deviceId) => {
|
|
110
|
+
await mutateResource("/push/unsubscribe", { id: deviceId });
|
|
111
|
+
}, [mutateResource]);
|
|
112
|
+
return {
|
|
113
|
+
status: unconfigured && permission !== "denied" ? "unconfigured" : permission,
|
|
114
|
+
isEnabling,
|
|
115
|
+
error,
|
|
116
|
+
enable,
|
|
117
|
+
disable,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The VAPID public key as the bytes `pushManager.subscribe()` wants.
|
|
122
|
+
*
|
|
123
|
+
* It travels base64url and the API takes a `BufferSource`. Spelled out rather
|
|
124
|
+
* than inlined because getting it wrong produces an `InvalidAccessError` that
|
|
125
|
+
* reads as a permissions fault rather than an encoding one - and the explicit
|
|
126
|
+
* `ArrayBuffer` matters too: `Uint8Array.from` widens to `ArrayBufferLike`,
|
|
127
|
+
* which `applicationServerKey` will not accept.
|
|
128
|
+
*/
|
|
129
|
+
function base64UrlToBytes(base64) {
|
|
130
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
|
131
|
+
const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
132
|
+
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
|
|
133
|
+
for (let index = 0; index < raw.length; index += 1)
|
|
134
|
+
bytes[index] = raw.charCodeAt(index);
|
|
135
|
+
return bytes;
|
|
136
|
+
}
|
|
@@ -20,6 +20,7 @@ import { AnalyticsResource } from "./resources/analytics.js";
|
|
|
20
20
|
import { OtpResource } from "./resources/otp.js";
|
|
21
21
|
import { SessionsResource } from "./resources/sessions.js";
|
|
22
22
|
import { PlatformResource } from "./resources/platform.js";
|
|
23
|
+
import { PushResource } from "./resources/push.js";
|
|
23
24
|
export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
24
25
|
const APPLICATION_API_PREFIX = "/api/v1/application";
|
|
25
26
|
/**
|
|
@@ -113,6 +114,11 @@ export class ConnectClient {
|
|
|
113
114
|
workspaces;
|
|
114
115
|
/** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
|
|
115
116
|
platform;
|
|
117
|
+
/**
|
|
118
|
+
* Web Push. Connect holds the VAPID keys, the devices and the member's own
|
|
119
|
+
* preferences; you supply a title, a body and a URL. See `PushResource`.
|
|
120
|
+
*/
|
|
121
|
+
push;
|
|
116
122
|
/**
|
|
117
123
|
* Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
|
|
118
124
|
* store route and nothing else. See `storePassthrough`.
|
|
@@ -157,8 +163,9 @@ export class ConnectClient {
|
|
|
157
163
|
this.integrations = new IntegrationsResource(transport);
|
|
158
164
|
this.workspaces = new WorkspacesResource(transport);
|
|
159
165
|
this.platform = new PlatformResource(transport);
|
|
166
|
+
this.push = new PushResource(transport);
|
|
160
167
|
this.storeRelay = (spec) => storePassthrough(transport, spec);
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
164
|
-
export const SDK_VERSION = "0.
|
|
171
|
+
export const SDK_VERSION = "0.32.0";
|
|
@@ -513,6 +513,60 @@ export function createConnectHandler(options) {
|
|
|
513
513
|
* switching is simply absent for them rather than half-working - the same
|
|
514
514
|
* treatment `/signin` and `/callback` already get.
|
|
515
515
|
*/
|
|
516
|
+
/*
|
|
517
|
+
* Push notifications - the browser half, and deliberately only that half.
|
|
518
|
+
*
|
|
519
|
+
* **There is no `POST /push` here**, and its absence is the design: a
|
|
520
|
+
* notification anybody can trigger from a devtools console is one an
|
|
521
|
+
* attacker can trigger from a compromised page, wearing the styling of an
|
|
522
|
+
* app the person trusts. Sending is a server-to-server act with your API
|
|
523
|
+
* key; Connect's own route refuses the session-token path outright.
|
|
524
|
+
*
|
|
525
|
+
* What a browser legitimately does is register itself, because
|
|
526
|
+
* `pushManager.subscribe()` produces a subscription no server can make on
|
|
527
|
+
* its behalf. All three routes resolve the member from their session, so
|
|
528
|
+
* there is no `userId` anywhere on this surface - a page cannot register a
|
|
529
|
+
* device against somebody else's account, nor list theirs.
|
|
530
|
+
*/
|
|
531
|
+
"GET /push/key": async (request) => json(await client.push.key({ asUser: await requireToken(request) })),
|
|
532
|
+
"POST /push/subscribe": async (request) => {
|
|
533
|
+
const token = await requireToken(request);
|
|
534
|
+
const body = (await request.json().catch(() => null));
|
|
535
|
+
// Shape-checked here rather than relayed blindly, so a malformed
|
|
536
|
+
// subscription fails with a sentence naming what is missing instead of
|
|
537
|
+
// a Zod message from a server the page cannot see.
|
|
538
|
+
if (typeof body?.endpoint !== "string" ||
|
|
539
|
+
typeof body.keys?.p256dh !== "string" ||
|
|
540
|
+
typeof body.keys?.auth !== "string") {
|
|
541
|
+
throw new ConnectError({
|
|
542
|
+
status: 400,
|
|
543
|
+
code: "validation_error",
|
|
544
|
+
message: "endpoint and keys.p256dh/keys.auth are required",
|
|
545
|
+
endpoint: "POST /push/subscribe",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return json(await client.push.registerDevice({
|
|
549
|
+
endpoint: body.endpoint,
|
|
550
|
+
keys: { p256dh: body.keys.p256dh, auth: body.keys.auth },
|
|
551
|
+
// Taken from the request rather than the body: a page can put
|
|
552
|
+
// anything in a field, and this one is shown back on a device
|
|
553
|
+
// list where a spoofed entry would be the point of spoofing it.
|
|
554
|
+
userAgent: (request.headers.get("user-agent") ?? "").slice(0, 512),
|
|
555
|
+
}, { asUser: token }));
|
|
556
|
+
},
|
|
557
|
+
"POST /push/unsubscribe": async (request) => {
|
|
558
|
+
const token = await requireToken(request);
|
|
559
|
+
const body = (await request.json().catch(() => null));
|
|
560
|
+
if (typeof body?.id !== "string") {
|
|
561
|
+
throw new ConnectError({
|
|
562
|
+
status: 400,
|
|
563
|
+
code: "validation_error",
|
|
564
|
+
message: "id is required",
|
|
565
|
+
endpoint: "POST /push/unsubscribe",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return json(await client.push.revokeDevice(body.id, { asUser: token }));
|
|
569
|
+
},
|
|
516
570
|
"GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
|
|
517
571
|
"GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
|
|
518
572
|
"POST /workspaces/switch": async (request) => {
|
|
@@ -104,4 +104,39 @@ export class AiResource {
|
|
|
104
104
|
});
|
|
105
105
|
return result.data;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Draft every section of a platform-defined assessment from one sentence.
|
|
109
|
+
*
|
|
110
|
+
* The intended flow is draft first, then let the person correct it section
|
|
111
|
+
* by section: a blank questionnaire is the reason most people abandon one,
|
|
112
|
+
* and editing a strawman is a far smaller ask than authoring from nothing.
|
|
113
|
+
*
|
|
114
|
+
* Unattributed - assessments run against platform-wide definitions and the
|
|
115
|
+
* first consumers run before anyone has signed in, so there is no `asUser`
|
|
116
|
+
* and no workspace here.
|
|
117
|
+
*/
|
|
118
|
+
async draftAssessment(input) {
|
|
119
|
+
const result = await request(this.transport, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
path: "/ai/assessment/draft",
|
|
122
|
+
json: input,
|
|
123
|
+
});
|
|
124
|
+
return result.data;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Critique a completed assessment - the tensions between sections, the
|
|
128
|
+
* riskiest assumption, and a coarse score.
|
|
129
|
+
*
|
|
130
|
+
* Unknown section keys in `answers` are ignored rather than rejected, so a
|
|
131
|
+
* definition changing under a half-finished assessment costs the reader
|
|
132
|
+
* nothing.
|
|
133
|
+
*/
|
|
134
|
+
async reviewAssessment(input) {
|
|
135
|
+
const result = await request(this.transport, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
path: "/ai/assessment/review",
|
|
138
|
+
json: input,
|
|
139
|
+
});
|
|
140
|
+
return result.data;
|
|
141
|
+
}
|
|
107
142
|
}
|
|
@@ -49,4 +49,52 @@ export class EnrichmentResource {
|
|
|
49
49
|
signal: options.signal,
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* What the company at a domain **looks like** - logo, palette, typefaces,
|
|
54
|
+
* and the radius, spacing and breakpoint scales its own stylesheets declare.
|
|
55
|
+
*
|
|
56
|
+
* The sibling of `company()` above: that one answers what a company does,
|
|
57
|
+
* this one answers what it looks like. Useful for white-labelling a portal
|
|
58
|
+
* to a customer's brand, pre-filling a design brief, or showing a real
|
|
59
|
+
* account card instead of a grey placeholder.
|
|
60
|
+
*
|
|
61
|
+
* Needs an API key scoped to **`enrichment:brand`** - separate from
|
|
62
|
+
* `enrichment:read` because it is far more expensive: it crawls
|
|
63
|
+
* stylesheets, may render the page in a headless browser, and caches image
|
|
64
|
+
* bytes.
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* const result = await connect.enrichment.brand({ domain: "acme.com" });
|
|
68
|
+
* if (result.status === "extracted") {
|
|
69
|
+
* const primary = result.profile.brandKit.palette.find((c) => c.role === "primary");
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Three things to know before wiring it in:
|
|
74
|
+
*
|
|
75
|
+
* - **Set your client timeout to at least 90 seconds.** A cold read runs
|
|
76
|
+
* the whole extraction before answering and can take up to a minute; many
|
|
77
|
+
* HTTP clients default to 30s and abandon a request that was about to
|
|
78
|
+
* succeed. Cached reads return in milliseconds, and cached is the normal
|
|
79
|
+
* case - profiles are held for 90 days, so you pay the cold path roughly
|
|
80
|
+
* once per domain.
|
|
81
|
+
* - **Never store `brandKit.logos[].url`.** It is a short-lived signed URL
|
|
82
|
+
* minted per read. Render it directly or re-read the profile; persist
|
|
83
|
+
* `sourceUrl` if you need something stable.
|
|
84
|
+
* - **The values were measured, not generated.** Check `palette[].source` -
|
|
85
|
+
* a `css-var` entry is the company naming its own colour, a `frequency`
|
|
86
|
+
* entry is a ranked count and much weaker.
|
|
87
|
+
*/
|
|
88
|
+
brand(input, options = {}) {
|
|
89
|
+
return request(this.transport, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
path: "/enrichment/brand",
|
|
92
|
+
json: {
|
|
93
|
+
domain: input.domain,
|
|
94
|
+
...(input.refresh ? { refresh: true } : {}),
|
|
95
|
+
},
|
|
96
|
+
headers: actingAs(input.asUser),
|
|
97
|
+
signal: options.signal,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
52
100
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
import { actingAs } from "./acting-as.js";
|
|
3
|
+
/**
|
|
4
|
+
* Web Push, with Connect holding everything that makes it work.
|
|
5
|
+
*
|
|
6
|
+
* Your application supplies a title, a body, a URL and the members to reach.
|
|
7
|
+
* Connect owns the VAPID keys it signs with, the devices each member has
|
|
8
|
+
* registered across every application on the platform, that member's own
|
|
9
|
+
* notification preferences, and the bookkeeping when a browser goes away.
|
|
10
|
+
*
|
|
11
|
+
* So you never hold a push credential, never store an endpoint, and cannot
|
|
12
|
+
* reach anybody who is not an active member of the workspace you name -
|
|
13
|
+
* membership is re-checked on every send rather than trusted from the ids you
|
|
14
|
+
* pass.
|
|
15
|
+
*
|
|
16
|
+
* ## Two halves, and only `send` belongs on your server
|
|
17
|
+
*
|
|
18
|
+
* Registering a device happens in the **browser**, because a subscription is
|
|
19
|
+
* produced by `pushManager.subscribe()` against the member's own browser and
|
|
20
|
+
* no server can make one for them. `registerDevice` and `revokeDevice` below
|
|
21
|
+
* therefore take a session token and are what the SDK bridge calls on the
|
|
22
|
+
* browser's behalf - see `createConnectHandler`. You will normally reach them
|
|
23
|
+
* through `useConnectPush()` in `@cosmoops/connect-sdk/client` rather than
|
|
24
|
+
* directly.
|
|
25
|
+
*/
|
|
26
|
+
export class PushResource {
|
|
27
|
+
transport;
|
|
28
|
+
constructor(transport) {
|
|
29
|
+
this.transport = transport;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Notify members of a workspace.
|
|
33
|
+
*
|
|
34
|
+
* **`delivered: 0` is a success, not a failure.** The member may have
|
|
35
|
+
* registered no device, silenced notifications from their apps, or denied
|
|
36
|
+
* the browser prompt - all ordinary. A caller that retries on zero will
|
|
37
|
+
* retry forever against somebody who has opted out.
|
|
38
|
+
*
|
|
39
|
+
* Treat every send as a nudge toward something already true in your own
|
|
40
|
+
* data, and never let a failure here fail the thing that triggered it: the
|
|
41
|
+
* reminder is worth less than the record it is about.
|
|
42
|
+
*
|
|
43
|
+
* `url` must be absolute and on an origin this application registered as an
|
|
44
|
+
* SSO redirect URI. Connect refuses anything else, because a URL it did not
|
|
45
|
+
* check is an open redirect delivered to a lock screen.
|
|
46
|
+
*/
|
|
47
|
+
send(input) {
|
|
48
|
+
return request(this.transport, { method: "POST", path: "/push", json: input });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The VAPID public key a browser needs before it can subscribe, and whether
|
|
52
|
+
* the platform can send at all.
|
|
53
|
+
*
|
|
54
|
+
* Safe to hand to any signed-in caller: it is the verification half of the
|
|
55
|
+
* pair, so holding it lets a browser *receive* what Connect signs and never
|
|
56
|
+
* send anything.
|
|
57
|
+
*/
|
|
58
|
+
key(options) {
|
|
59
|
+
return request(this.transport, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
path: "/push/subscriptions",
|
|
62
|
+
headers: actingAs(options.asUser),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Register one browser, from a subscription that browser just produced. */
|
|
66
|
+
registerDevice(input, options) {
|
|
67
|
+
return request(this.transport, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
path: "/push/subscriptions",
|
|
70
|
+
json: input,
|
|
71
|
+
headers: actingAs(options.asUser),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop notifications on one browser.
|
|
76
|
+
*
|
|
77
|
+
* The id is the one `registerDevice` returned. Connect re-checks that it
|
|
78
|
+
* belongs to the calling member, so a guessed id is a refusal rather than
|
|
79
|
+
* somebody else's device going quiet.
|
|
80
|
+
*/
|
|
81
|
+
revokeDevice(id, options) {
|
|
82
|
+
return request(this.transport, {
|
|
83
|
+
method: "DELETE",
|
|
84
|
+
path: `/push/subscriptions?id=${encodeURIComponent(id)}`,
|
|
85
|
+
headers: actingAs(options.asUser),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -41,6 +41,13 @@ export type { ConnectShellContextValue } from "./components/shell-context.js";
|
|
|
41
41
|
export { ConnectSidebarNav, ConnectSideNav } from "./components/side-nav.js";
|
|
42
42
|
export type { ConnectSidebarNavProps, ConnectSideNavProps } from "./components/side-nav.js";
|
|
43
43
|
export { useActivePath } from "./use-active-path.js";
|
|
44
|
+
/**
|
|
45
|
+
* Web Push. The hook owns the browser half - permission, the worker, the
|
|
46
|
+
* subscription - and Connect owns the keys, the devices and the member's
|
|
47
|
+
* preferences. Sending is server-side, through `connect.push.send`.
|
|
48
|
+
*/
|
|
49
|
+
export { useConnectPush } from "./use-push.js";
|
|
50
|
+
export type { ConnectPushStatus, UseConnectPush } from "./use-push.js";
|
|
44
51
|
export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, filterNavForAudience, } from "./components/nav.js";
|
|
45
52
|
export type { ConnectNav, ConnectNavItem, ConnectNavSection, ConnectNavLinkProps, ConnectLinkRenderer, } from "./components/nav.js";
|
|
46
53
|
export type { ConnectScreen } from "../types.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this browser can show notifications, and whether it has been asked.
|
|
3
|
+
*
|
|
4
|
+
* Five states, not a boolean - and that is the whole reason this hook exists
|
|
5
|
+
* rather than a `<Switch>`:
|
|
6
|
+
*
|
|
7
|
+
* - `unsupported` - no service worker or no Push API. On iOS this is every
|
|
8
|
+
* browser until the site is added to the home screen, which is worth saying
|
|
9
|
+
* out loud because it is most of the phones in some markets.
|
|
10
|
+
* - `unconfigured` - the platform has no VAPID keys, so nothing could be
|
|
11
|
+
* delivered even if permission were granted.
|
|
12
|
+
* - `denied` - **and this one cannot be undone by script.** A browser will not
|
|
13
|
+
* show the prompt again once told no; only the site-settings panel in the
|
|
14
|
+
* address bar can reverse it. A control that silently does nothing when
|
|
15
|
+
* pressed is the worst version of this screen, so the state is surfaced and
|
|
16
|
+
* the UI is expected to explain it.
|
|
17
|
+
* - `prompt` - askable.
|
|
18
|
+
* - `granted` - permission held. It does **not** follow that this browser is
|
|
19
|
+
* registered with Connect; permission and registration are separate facts,
|
|
20
|
+
* and a member who cleared site data holds the first without the second.
|
|
21
|
+
*/
|
|
22
|
+
export type ConnectPushStatus = "checking" | "unsupported" | "unconfigured" | "denied" | "prompt" | "granted";
|
|
23
|
+
export interface UseConnectPush {
|
|
24
|
+
status: ConnectPushStatus;
|
|
25
|
+
/** True while `enable()` is in flight. */
|
|
26
|
+
isEnabling: boolean;
|
|
27
|
+
/** The last failure, in words a person can act on. Cleared by the next attempt. */
|
|
28
|
+
error: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Ask for permission, register this browser with Connect, and resolve the
|
|
31
|
+
* device id - or `null` if the member declined or anything refused.
|
|
32
|
+
*
|
|
33
|
+
* Must be called from a user gesture: browsers refuse
|
|
34
|
+
* `Notification.requestPermission()` outside one, and the refusal is
|
|
35
|
+
* indistinguishable from the member saying no.
|
|
36
|
+
*/
|
|
37
|
+
enable: () => Promise<string | null>;
|
|
38
|
+
/** Stop notifications on one browser, by the id `enable()` returned. */
|
|
39
|
+
disable: (deviceId: string) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Register this browser for the platform's notifications.
|
|
43
|
+
*
|
|
44
|
+
* The subscription is **Connect's, not your application's**: a member who
|
|
45
|
+
* turns notifications on in one app has turned them on for the platform, and
|
|
46
|
+
* the same device receives every other app's notifications and Connect's own.
|
|
47
|
+
* That is the point of centralising it - one browser prompt instead of one per
|
|
48
|
+
* app, and one place to turn everything off.
|
|
49
|
+
*
|
|
50
|
+
* Your application needs a service worker at its own origin that handles the
|
|
51
|
+
* `push` event; the SDK does not install one, because a worker is a
|
|
52
|
+
* site-wide concern with caching implications only the host can decide.
|
|
53
|
+
*
|
|
54
|
+
* @param serviceWorkerUrl Where your worker lives. Root-scoped by convention.
|
|
55
|
+
*/
|
|
56
|
+
export declare function useConnectPush(serviceWorkerUrl?: string): UseConnectPush;
|