@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/dist/cjs/client/components/visitor-chat.js +387 -0
  3. package/dist/cjs/client/components/visitor-stream.js +124 -0
  4. package/dist/cjs/client/index.js +23 -3
  5. package/dist/cjs/client/provider.js +24 -0
  6. package/dist/cjs/client/use-push.js +139 -0
  7. package/dist/cjs/server/client.js +15 -1
  8. package/dist/cjs/server/handler.js +196 -0
  9. package/dist/cjs/server/resources/ai.js +85 -0
  10. package/dist/cjs/server/resources/enrichment.js +107 -0
  11. package/dist/cjs/server/resources/push.js +92 -0
  12. package/dist/cjs/server/resources/visitor.js +227 -0
  13. package/dist/esm/client/components/visitor-chat.js +384 -0
  14. package/dist/esm/client/components/visitor-stream.js +120 -0
  15. package/dist/esm/client/index.js +17 -1
  16. package/dist/esm/client/provider.js +23 -0
  17. package/dist/esm/client/use-push.js +136 -0
  18. package/dist/esm/server/client.js +15 -1
  19. package/dist/esm/server/handler.js +196 -0
  20. package/dist/esm/server/resources/ai.js +85 -0
  21. package/dist/esm/server/resources/enrichment.js +107 -0
  22. package/dist/esm/server/resources/push.js +88 -0
  23. package/dist/esm/server/resources/visitor.js +222 -0
  24. package/dist/types/client/components/visitor-chat.d.ts +120 -0
  25. package/dist/types/client/components/visitor-stream.d.ts +42 -0
  26. package/dist/types/client/index.d.ts +13 -1
  27. package/dist/types/client/provider.d.ts +21 -0
  28. package/dist/types/client/use-push.d.ts +56 -0
  29. package/dist/types/server/client.d.ts +13 -1
  30. package/dist/types/server/handler.d.ts +0 -24
  31. package/dist/types/server/resources/ai.d.ts +75 -1
  32. package/dist/types/server/resources/enrichment.d.ts +102 -1
  33. package/dist/types/server/resources/push.d.ts +73 -0
  34. package/dist/types/server/resources/visitor.d.ts +200 -0
  35. package/dist/types/types.d.ts +683 -0
  36. 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.30.1";
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
  }