@cosmoops/connect-sdk 0.30.1 → 0.33.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 +267 -0
- package/dist/cjs/client/components/visitor-chat.js +387 -0
- package/dist/cjs/client/components/visitor-stream.js +124 -0
- package/dist/cjs/client/index.js +23 -3
- package/dist/cjs/client/provider.js +24 -0
- package/dist/cjs/client/use-push.js +139 -0
- package/dist/cjs/server/client.js +15 -1
- package/dist/cjs/server/handler.js +196 -0
- package/dist/cjs/server/resources/ai.js +85 -0
- package/dist/cjs/server/resources/enrichment.js +107 -0
- package/dist/cjs/server/resources/push.js +92 -0
- package/dist/cjs/server/resources/visitor.js +227 -0
- package/dist/esm/client/components/visitor-chat.js +384 -0
- package/dist/esm/client/components/visitor-stream.js +120 -0
- package/dist/esm/client/index.js +17 -1
- package/dist/esm/client/provider.js +23 -0
- package/dist/esm/client/use-push.js +136 -0
- package/dist/esm/server/client.js +15 -1
- package/dist/esm/server/handler.js +196 -0
- package/dist/esm/server/resources/ai.js +85 -0
- package/dist/esm/server/resources/enrichment.js +107 -0
- package/dist/esm/server/resources/push.js +88 -0
- package/dist/esm/server/resources/visitor.js +222 -0
- package/dist/types/client/components/visitor-chat.d.ts +120 -0
- package/dist/types/client/components/visitor-stream.d.ts +42 -0
- package/dist/types/client/index.d.ts +13 -1
- package/dist/types/client/provider.d.ts +21 -0
- package/dist/types/client/use-push.d.ts +56 -0
- package/dist/types/server/client.d.ts +13 -1
- package/dist/types/server/handler.d.ts +0 -24
- package/dist/types/server/resources/ai.d.ts +75 -1
- package/dist/types/server/resources/enrichment.d.ts +102 -1
- package/dist/types/server/resources/push.d.ts +73 -0
- package/dist/types/server/resources/visitor.d.ts +200 -0
- package/dist/types/types.d.ts +683 -0
- package/package.json +3 -2
|
@@ -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,8 @@ 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 { VisitorResource } from "./resources/visitor.js";
|
|
24
|
+
import { PushResource } from "./resources/push.js";
|
|
23
25
|
export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
24
26
|
const APPLICATION_API_PREFIX = "/api/v1/application";
|
|
25
27
|
/**
|
|
@@ -111,8 +113,18 @@ export class ConnectClient {
|
|
|
111
113
|
integrations;
|
|
112
114
|
/** Which workspaces the signed-in user belongs to, switching between them, and the team - see `WorkspacesResource`. */
|
|
113
115
|
workspaces;
|
|
116
|
+
/**
|
|
117
|
+
* The public chat bubble on your own site - see `VisitorResource`. The one
|
|
118
|
+
* resource here whose caller is not one of your users.
|
|
119
|
+
*/
|
|
120
|
+
visitor;
|
|
114
121
|
/** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
|
|
115
122
|
platform;
|
|
123
|
+
/**
|
|
124
|
+
* Web Push. Connect holds the VAPID keys, the devices and the member's own
|
|
125
|
+
* preferences; you supply a title, a body and a URL. See `PushResource`.
|
|
126
|
+
*/
|
|
127
|
+
push;
|
|
116
128
|
/**
|
|
117
129
|
* Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
|
|
118
130
|
* store route and nothing else. See `storePassthrough`.
|
|
@@ -156,9 +168,11 @@ export class ConnectClient {
|
|
|
156
168
|
this.sessions = new SessionsResource(transport, options.clientId ?? null);
|
|
157
169
|
this.integrations = new IntegrationsResource(transport);
|
|
158
170
|
this.workspaces = new WorkspacesResource(transport);
|
|
171
|
+
this.visitor = new VisitorResource(transport);
|
|
159
172
|
this.platform = new PlatformResource(transport);
|
|
173
|
+
this.push = new PushResource(transport);
|
|
160
174
|
this.storeRelay = (spec) => storePassthrough(transport, spec);
|
|
161
175
|
}
|
|
162
176
|
}
|
|
163
177
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
164
|
-
export const SDK_VERSION = "0.
|
|
178
|
+
export const SDK_VERSION = "0.32.0";
|
|
@@ -96,6 +96,24 @@ function redirect(location, cookies = []) {
|
|
|
96
96
|
* workspace it is not signed into, and a client-supplied `workspaceId`
|
|
97
97
|
* is exactly that hole.
|
|
98
98
|
*/
|
|
99
|
+
/**
|
|
100
|
+
* The visitor's own address, for Connect's per-visitor limiter.
|
|
101
|
+
*
|
|
102
|
+
* **Left-most `x-forwarded-for` entry**, which is the same precedence Connect's
|
|
103
|
+
* own `/api/book` and `/api/auth/session` use, and the same caveat applies: the
|
|
104
|
+
* left-most entry is the client as the first proxy saw it, and a client can
|
|
105
|
+
* prepend to that header. That makes it unsuitable for authorization and fine
|
|
106
|
+
* for this - it partitions your application's own budget between visitors, and
|
|
107
|
+
* the ceilings that actually cap spending are per key and per application.
|
|
108
|
+
*
|
|
109
|
+
* `null` where nothing forwarded one - a local dev server, or a platform that
|
|
110
|
+
* does not set it. Connect then counts every visitor in one shared bucket,
|
|
111
|
+
* which is a stricter limit rather than an exemption.
|
|
112
|
+
*/
|
|
113
|
+
function visitorIpOf(request) {
|
|
114
|
+
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
115
|
+
return forwarded || request.headers.get("x-real-ip") || null;
|
|
116
|
+
}
|
|
99
117
|
export function createConnectHandler(options) {
|
|
100
118
|
const basePath = (options.basePath ?? "/api/connect").replace(/\/+$/, "");
|
|
101
119
|
const { client, session } = options;
|
|
@@ -327,6 +345,130 @@ export function createConnectHandler(options) {
|
|
|
327
345
|
return json({ readiness, subscription });
|
|
328
346
|
},
|
|
329
347
|
"GET /plans": async () => json(await client.billing.listPlans()),
|
|
348
|
+
/* ---------------------------------------------------------------------- */
|
|
349
|
+
/* The visitor assistant - the only routes here with no session at all */
|
|
350
|
+
/* ---------------------------------------------------------------------- */
|
|
351
|
+
/**
|
|
352
|
+
* `POST /visitor/session`, `/visitor/chat`, `/visitor/book`.
|
|
353
|
+
*
|
|
354
|
+
* Three routes that take **no session**, which makes them the exception to
|
|
355
|
+
* the rule every other route here follows. That is not a hole: the caller
|
|
356
|
+
* is a stranger on a public page, and there is no account for a session to
|
|
357
|
+
* identify. The question "whose data is this" has a different answer here -
|
|
358
|
+
* it is the *application's*, and the application is the one thing this
|
|
359
|
+
* bridge does hold a credential for.
|
|
360
|
+
*
|
|
361
|
+
* So the scoping that `requireWorkspace` provides elsewhere is provided
|
|
362
|
+
* here by Connect's own configuration: the workspace a call books into and
|
|
363
|
+
* the knowledge it answers from come from the application record, and no
|
|
364
|
+
* field in any of these bodies names a tenant. A browser cannot widen that
|
|
365
|
+
* by asking, because there is nothing to ask with.
|
|
366
|
+
*
|
|
367
|
+
* **`GET /company-profile` is the precedent and the limit of it.** That one
|
|
368
|
+
* is unauthenticated because the answer is identical for every caller.
|
|
369
|
+
* These are unauthenticated because the *caller* has no identity - but the
|
|
370
|
+
* answer is still per application, and spending is still per application.
|
|
371
|
+
* Which is why they are the only routes here that pass the visitor's own
|
|
372
|
+
* address along: the limiters on the far side count a visitor, and this
|
|
373
|
+
* bridge is the only thing that knows which one.
|
|
374
|
+
*/
|
|
375
|
+
"POST /visitor/session": async (request) => {
|
|
376
|
+
const body = (await request.json().catch(() => null));
|
|
377
|
+
/*
|
|
378
|
+
* **The workspace is the browser's claim here, and that is the one thing
|
|
379
|
+
* this bridge must not simply relay.** Every other route on this bridge
|
|
380
|
+
* resolves the tenant from the caller's session; a visitor has none, so
|
|
381
|
+
* the widget names the customer whose site it is on - and a page can name
|
|
382
|
+
* any of them.
|
|
383
|
+
*
|
|
384
|
+
* Connect checks the workspace against your subscriptions, so the worst
|
|
385
|
+
* case is bounded to *your own* customers rather than the platform's. That
|
|
386
|
+
* is still one customer's budget spent from another's page, so an
|
|
387
|
+
* application serving several customers should resolve the workspace from
|
|
388
|
+
* its own widget record - by the embed token in the snippet, say - rather
|
|
389
|
+
* than trusting this field. The SDK cannot do that for you: it does not
|
|
390
|
+
* know what your widgets are keyed by.
|
|
391
|
+
*/
|
|
392
|
+
if (typeof body?.workspaceId !== "string") {
|
|
393
|
+
throw new ConnectError({
|
|
394
|
+
status: 400,
|
|
395
|
+
code: "validation_error",
|
|
396
|
+
message: "workspaceId is required",
|
|
397
|
+
endpoint: "POST /visitor/session",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return json(await client.visitor.startSession({
|
|
401
|
+
workspaceId: body.workspaceId,
|
|
402
|
+
page: typeof body.page === "string" ? body.page : null,
|
|
403
|
+
resume: typeof body.resume === "string" ? body.resume : null,
|
|
404
|
+
}, { visitorIp: visitorIpOf(request) }));
|
|
405
|
+
},
|
|
406
|
+
"POST /visitor/chat": async (request) => {
|
|
407
|
+
const body = (await request.json().catch(() => null));
|
|
408
|
+
if (typeof body?.sessionId !== "string" ||
|
|
409
|
+
typeof body?.workspaceId !== "string" ||
|
|
410
|
+
typeof body?.message !== "string") {
|
|
411
|
+
throw new ConnectError({
|
|
412
|
+
status: 400,
|
|
413
|
+
code: "validation_error",
|
|
414
|
+
message: "sessionId, workspaceId and message are required",
|
|
415
|
+
endpoint: "POST /visitor/chat",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const upstream = await client.visitor.chat({
|
|
419
|
+
sessionId: body.sessionId,
|
|
420
|
+
workspaceId: body.workspaceId,
|
|
421
|
+
message: body.message,
|
|
422
|
+
...(typeof body.assistantName === "string"
|
|
423
|
+
? { assistantName: body.assistantName }
|
|
424
|
+
: {}),
|
|
425
|
+
...(typeof body.bookingEventTypeSlug === "string"
|
|
426
|
+
? { bookingEventTypeSlug: body.bookingEventTypeSlug }
|
|
427
|
+
: {}),
|
|
428
|
+
}, { visitorIp: visitorIpOf(request) });
|
|
429
|
+
/*
|
|
430
|
+
* Piped, never buffered - awaiting the body would collect the whole reply
|
|
431
|
+
* server-side and deliver it in one lump, which is the only thing
|
|
432
|
+
* streaming was for. The browser hanging up propagates down this pipe and
|
|
433
|
+
* stops generation upstream, which on this surface also stops the
|
|
434
|
+
* spending.
|
|
435
|
+
*/
|
|
436
|
+
return new Response(upstream.body, {
|
|
437
|
+
status: upstream.status,
|
|
438
|
+
headers: {
|
|
439
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
440
|
+
"cache-control": "no-store",
|
|
441
|
+
// Without this a proxy in front of your app may buffer the whole
|
|
442
|
+
// stream to inspect it, which undoes the streaming end to end.
|
|
443
|
+
"x-accel-buffering": "no",
|
|
444
|
+
"x-content-type-options": "nosniff",
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
"POST /visitor/book": async (request) => {
|
|
449
|
+
const body = (await request.json().catch(() => null));
|
|
450
|
+
if (typeof body?.sessionId !== "string" ||
|
|
451
|
+
typeof body?.workspaceId !== "string" ||
|
|
452
|
+
typeof body?.eventTypeSlug !== "string" ||
|
|
453
|
+
typeof body?.startAt !== "string") {
|
|
454
|
+
throw new ConnectError({
|
|
455
|
+
status: 400,
|
|
456
|
+
code: "validation_error",
|
|
457
|
+
message: "sessionId, workspaceId, eventTypeSlug and startAt are required",
|
|
458
|
+
endpoint: "POST /visitor/book",
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return json(await client.visitor.book({
|
|
462
|
+
sessionId: body.sessionId,
|
|
463
|
+
workspaceId: body.workspaceId,
|
|
464
|
+
eventTypeSlug: body.eventTypeSlug,
|
|
465
|
+
startAt: body.startAt,
|
|
466
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
467
|
+
phone: typeof body.phone === "string" ? body.phone : "",
|
|
468
|
+
email: typeof body.email === "string" ? body.email : null,
|
|
469
|
+
notes: typeof body.notes === "string" ? body.notes : null,
|
|
470
|
+
}, { visitorIp: visitorIpOf(request) }));
|
|
471
|
+
},
|
|
330
472
|
/**
|
|
331
473
|
* The platform's legal identity and grievance channel.
|
|
332
474
|
*
|
|
@@ -513,6 +655,60 @@ export function createConnectHandler(options) {
|
|
|
513
655
|
* switching is simply absent for them rather than half-working - the same
|
|
514
656
|
* treatment `/signin` and `/callback` already get.
|
|
515
657
|
*/
|
|
658
|
+
/*
|
|
659
|
+
* Push notifications - the browser half, and deliberately only that half.
|
|
660
|
+
*
|
|
661
|
+
* **There is no `POST /push` here**, and its absence is the design: a
|
|
662
|
+
* notification anybody can trigger from a devtools console is one an
|
|
663
|
+
* attacker can trigger from a compromised page, wearing the styling of an
|
|
664
|
+
* app the person trusts. Sending is a server-to-server act with your API
|
|
665
|
+
* key; Connect's own route refuses the session-token path outright.
|
|
666
|
+
*
|
|
667
|
+
* What a browser legitimately does is register itself, because
|
|
668
|
+
* `pushManager.subscribe()` produces a subscription no server can make on
|
|
669
|
+
* its behalf. All three routes resolve the member from their session, so
|
|
670
|
+
* there is no `userId` anywhere on this surface - a page cannot register a
|
|
671
|
+
* device against somebody else's account, nor list theirs.
|
|
672
|
+
*/
|
|
673
|
+
"GET /push/key": async (request) => json(await client.push.key({ asUser: await requireToken(request) })),
|
|
674
|
+
"POST /push/subscribe": async (request) => {
|
|
675
|
+
const token = await requireToken(request);
|
|
676
|
+
const body = (await request.json().catch(() => null));
|
|
677
|
+
// Shape-checked here rather than relayed blindly, so a malformed
|
|
678
|
+
// subscription fails with a sentence naming what is missing instead of
|
|
679
|
+
// a Zod message from a server the page cannot see.
|
|
680
|
+
if (typeof body?.endpoint !== "string" ||
|
|
681
|
+
typeof body.keys?.p256dh !== "string" ||
|
|
682
|
+
typeof body.keys?.auth !== "string") {
|
|
683
|
+
throw new ConnectError({
|
|
684
|
+
status: 400,
|
|
685
|
+
code: "validation_error",
|
|
686
|
+
message: "endpoint and keys.p256dh/keys.auth are required",
|
|
687
|
+
endpoint: "POST /push/subscribe",
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return json(await client.push.registerDevice({
|
|
691
|
+
endpoint: body.endpoint,
|
|
692
|
+
keys: { p256dh: body.keys.p256dh, auth: body.keys.auth },
|
|
693
|
+
// Taken from the request rather than the body: a page can put
|
|
694
|
+
// anything in a field, and this one is shown back on a device
|
|
695
|
+
// list where a spoofed entry would be the point of spoofing it.
|
|
696
|
+
userAgent: (request.headers.get("user-agent") ?? "").slice(0, 512),
|
|
697
|
+
}, { asUser: token }));
|
|
698
|
+
},
|
|
699
|
+
"POST /push/unsubscribe": async (request) => {
|
|
700
|
+
const token = await requireToken(request);
|
|
701
|
+
const body = (await request.json().catch(() => null));
|
|
702
|
+
if (typeof body?.id !== "string") {
|
|
703
|
+
throw new ConnectError({
|
|
704
|
+
status: 400,
|
|
705
|
+
code: "validation_error",
|
|
706
|
+
message: "id is required",
|
|
707
|
+
endpoint: "POST /push/unsubscribe",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return json(await client.push.revokeDevice(body.id, { asUser: token }));
|
|
711
|
+
},
|
|
516
712
|
"GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
|
|
517
713
|
"GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
|
|
518
714
|
"POST /workspaces/switch": async (request) => {
|
|
@@ -104,4 +104,89 @@ 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
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* How ready the site at a domain is for AI agents.
|
|
144
|
+
*
|
|
145
|
+
* Probes for an MCP server, an OpenAPI description, `llms.txt`, structured
|
|
146
|
+
* data and a markdown twin; evaluates the site's `robots.txt` against the
|
|
147
|
+
* assistants that actually fetch pages; checks whether its content survives
|
|
148
|
+
* without JavaScript and whether a bot wall refuses agents outright. Then
|
|
149
|
+
* grades what it found and says what to fix first.
|
|
150
|
+
*
|
|
151
|
+
* Needs an API key scoped to **`ai:readiness`**, which is separate from every
|
|
152
|
+
* other AI scope because it is the widest outbound surface on the API - it
|
|
153
|
+
* POSTs to candidate MCP endpoints and fetches the home page under two user
|
|
154
|
+
* agents.
|
|
155
|
+
*
|
|
156
|
+
* ```ts
|
|
157
|
+
* const result = await connect.ai.readiness({ domain: "acme.com" });
|
|
158
|
+
* if (result.status === "assessed") {
|
|
159
|
+
* console.log(result.report.grade, result.report.topFix?.fix);
|
|
160
|
+
* }
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* Four things to know before wiring it in:
|
|
164
|
+
*
|
|
165
|
+
* - **Set your client timeout to at least 90 seconds.** A cold assessment
|
|
166
|
+
* runs the whole probe before answering and can take up to a minute; many
|
|
167
|
+
* HTTP clients default to 30s and abandon a request that was about to
|
|
168
|
+
* succeed. Cached reads return in milliseconds and are the normal case -
|
|
169
|
+
* reports are held for a week.
|
|
170
|
+
* - **A week, not ninety days**, because a report is a to-do list somebody is
|
|
171
|
+
* working through. Pass `refresh` after shipping a fix to see it land.
|
|
172
|
+
* - **Handle `status: "error"` checks as unknown, not as failures.** They mean
|
|
173
|
+
* Connect could not measure that check, and they are excluded from the
|
|
174
|
+
* score rather than counted against it.
|
|
175
|
+
* - **`skipped` says whose problem it is.** `unreachable` means the site did
|
|
176
|
+
* not answer; `provider_unavailable` means Connect's own assessment service
|
|
177
|
+
* did. Only the second is worth retrying silently, and neither should be
|
|
178
|
+
* shown as a failing grade.
|
|
179
|
+
*/
|
|
180
|
+
readiness(input, options = {}) {
|
|
181
|
+
return request(this.transport, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
path: "/ai/readiness",
|
|
184
|
+
json: {
|
|
185
|
+
domain: input.domain,
|
|
186
|
+
...(input.refresh ? { refresh: true } : {}),
|
|
187
|
+
},
|
|
188
|
+
headers: actingAs(input.asUser),
|
|
189
|
+
signal: options.signal,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
107
192
|
}
|
|
@@ -49,4 +49,111 @@ 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
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* How **visible** the business at a domain is online, scored out of 100.
|
|
102
|
+
*
|
|
103
|
+
* The third sibling of `company()` and `brand()`: that one answers what a
|
|
104
|
+
* business does, the other what it looks like, and this one answers how
|
|
105
|
+
* findable it is - by a search engine, by a customer, and by the assistants
|
|
106
|
+
* that increasingly answer questions about a business before anybody visits
|
|
107
|
+
* its site.
|
|
108
|
+
*
|
|
109
|
+
* Needs an API key scoped to **`enrichment:presence`** - separate from
|
|
110
|
+
* `enrichment:read` and `enrichment:brand` because it is the widest outbound
|
|
111
|
+
* read of the three: roughly twenty fetches across the site's well-known
|
|
112
|
+
* paths, and sometimes a headless browser.
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* const result = await connect.enrichment.presence({ domain: "acme.com" });
|
|
116
|
+
* if (result.status === "scanned") {
|
|
117
|
+
* for (const next of result.scan.recommendations) {
|
|
118
|
+
* console.log(`${next.priority}. ${next.title} - ${next.fix}`);
|
|
119
|
+
* }
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* Four things to know before wiring it in:
|
|
124
|
+
*
|
|
125
|
+
* - **The score is computed, not generated.** Every point comes from a check
|
|
126
|
+
* run against something actually observed, and every check ships with its
|
|
127
|
+
* weight, what was seen and the URL that settles it - so the total is
|
|
128
|
+
* reconstructable. A model only orders `recommendations` and writes the
|
|
129
|
+
* prose; it cannot move the number. That is the whole reason this is worth
|
|
130
|
+
* showing a customer: ask a model how good a company's web presence is and
|
|
131
|
+
* it answers 72 with total confidence for a site it never read.
|
|
132
|
+
* - **`social` is claimed, never verified.** Those are URLs the site itself
|
|
133
|
+
* printed. Nothing proves the account belongs to that business - render it
|
|
134
|
+
* as "linked from their site". Check `machineReadable` too: `false` means
|
|
135
|
+
* a real profile that search engines have to infer from a footer link,
|
|
136
|
+
* which is a specific, common and easily fixed finding.
|
|
137
|
+
* - **A low score is a success, not a skip.** Every check failing is the most
|
|
138
|
+
* useful report this produces. Only `unreachable` means the site did not
|
|
139
|
+
* answer - and `provider_unavailable` means *Connect* did not, which is
|
|
140
|
+
* never something to tell your user about their own site.
|
|
141
|
+
* - **Set your client timeout to at least 90 seconds.** A cold scan runs the
|
|
142
|
+
* whole probe before answering; many HTTP clients default to 30s and
|
|
143
|
+
* abandon a request that was about to succeed. Cached scans return in
|
|
144
|
+
* milliseconds, and cached is the normal case - results are held for a
|
|
145
|
+
* fortnight, so you pay the cold path roughly once per domain.
|
|
146
|
+
*/
|
|
147
|
+
presence(input, options = {}) {
|
|
148
|
+
return request(this.transport, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
path: "/enrichment/presence",
|
|
151
|
+
json: {
|
|
152
|
+
domain: input.domain,
|
|
153
|
+
...(input.refresh ? { refresh: true } : {}),
|
|
154
|
+
},
|
|
155
|
+
headers: actingAs(input.asUser),
|
|
156
|
+
signal: options.signal,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
52
159
|
}
|