@cosmoops/connect-sdk 0.32.0 → 0.34.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 (51) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +17 -5
  3. package/dist/cjs/client/components/access-gate.js +98 -2
  4. package/dist/cjs/client/components/visitor-chat.js +387 -0
  5. package/dist/cjs/client/components/visitor-stream.js +124 -0
  6. package/dist/cjs/client/index.js +16 -3
  7. package/dist/cjs/client/provider.js +29 -2
  8. package/dist/cjs/client/use-checkout.js +3 -1
  9. package/dist/cjs/helpers.js +46 -0
  10. package/dist/cjs/index.js +5 -1
  11. package/dist/cjs/next/index.js +10 -3
  12. package/dist/cjs/server/client.js +8 -1
  13. package/dist/cjs/server/handler.js +142 -0
  14. package/dist/cjs/server/resources/ai.js +50 -0
  15. package/dist/cjs/server/resources/billing.js +5 -0
  16. package/dist/cjs/server/resources/enrichment.js +59 -0
  17. package/dist/cjs/server/resources/entitlements.js +47 -0
  18. package/dist/cjs/server/resources/visitor.js +227 -0
  19. package/dist/esm/client/components/access-gate.js +99 -3
  20. package/dist/esm/client/components/visitor-chat.js +384 -0
  21. package/dist/esm/client/components/visitor-stream.js +120 -0
  22. package/dist/esm/client/index.js +11 -1
  23. package/dist/esm/client/provider.js +28 -2
  24. package/dist/esm/client/use-checkout.js +3 -1
  25. package/dist/esm/helpers.js +43 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/next/index.js +10 -3
  28. package/dist/esm/server/client.js +8 -1
  29. package/dist/esm/server/handler.js +142 -0
  30. package/dist/esm/server/resources/ai.js +50 -0
  31. package/dist/esm/server/resources/billing.js +5 -0
  32. package/dist/esm/server/resources/enrichment.js +59 -0
  33. package/dist/esm/server/resources/entitlements.js +47 -0
  34. package/dist/esm/server/resources/visitor.js +222 -0
  35. package/dist/types/client/components/access-gate.d.ts +9 -0
  36. package/dist/types/client/components/visitor-chat.d.ts +120 -0
  37. package/dist/types/client/components/visitor-stream.d.ts +42 -0
  38. package/dist/types/client/index.d.ts +6 -1
  39. package/dist/types/client/provider.d.ts +176 -51
  40. package/dist/types/client/use-checkout.d.ts +18 -1
  41. package/dist/types/helpers.d.ts +36 -1
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/server/client.d.ts +7 -1
  44. package/dist/types/server/handler.d.ts +0 -24
  45. package/dist/types/server/resources/ai.d.ts +48 -1
  46. package/dist/types/server/resources/billing.d.ts +4 -0
  47. package/dist/types/server/resources/enrichment.d.ts +57 -1
  48. package/dist/types/server/resources/entitlements.d.ts +40 -1
  49. package/dist/types/server/resources/visitor.d.ts +200 -0
  50. package/dist/types/types.d.ts +562 -0
  51. package/package.json +3 -2
@@ -35,11 +35,18 @@ import { ConnectError } from "../errors.js";
35
35
  * | --- | --- | --- |
36
36
  * | `CONNECT_API_KEY` | yes | server-side credential |
37
37
  * | `CONNECT_CLIENT_ID` | yes | OAuth client id |
38
- * | `CONNECT_APP_URL` | no | non-production Connect instance |
39
- * | `CONNECT_SESSION_COOKIE` | no | session cookie name |
38
+ * | `CONNECT_APP_URL` | no | non-production Connect instance; unset means production |
39
+ * | `CONNECT_SESSION_COOKIE` | no | session cookie name; unset means `connect_session` |
40
+ *
41
+ * `CONNECT_APP_URL` is server-side only and does not reach the browser:
42
+ * `ConnectProvider` defaults `appUrl` to the production origin independently,
43
+ * so an app pointed at another instance must pass that prop as well or its
44
+ * portal links and its browser-built authorize URL still go to production.
40
45
  *
41
46
  * Register `{your origin}/api/connect/callback` as a redirect URI in the
42
- * application's Connect client section. That is the only step this cannot do for you.
47
+ * application's Connect client section. That is the only step this cannot do
48
+ * for you, and Connect matches it exactly - every origin you run from needs
49
+ * its own entry, localhost included.
43
50
  *
44
51
  * No `next.config.ts` changes needed either, including `transpilePackages`:
45
52
  * `dist/` is already ES2022 with `"use client"` hoisted to the top of every
@@ -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 { VisitorResource } from "./resources/visitor.js";
23
24
  import { PushResource } from "./resources/push.js";
24
25
  export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
25
26
  const APPLICATION_API_PREFIX = "/api/v1/application";
@@ -112,6 +113,11 @@ export class ConnectClient {
112
113
  integrations;
113
114
  /** Which workspaces the signed-in user belongs to, switching between them, and the team - see `WorkspacesResource`. */
114
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;
115
121
  /** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
116
122
  platform;
117
123
  /**
@@ -162,10 +168,11 @@ export class ConnectClient {
162
168
  this.sessions = new SessionsResource(transport, options.clientId ?? null);
163
169
  this.integrations = new IntegrationsResource(transport);
164
170
  this.workspaces = new WorkspacesResource(transport);
171
+ this.visitor = new VisitorResource(transport);
165
172
  this.platform = new PlatformResource(transport);
166
173
  this.push = new PushResource(transport);
167
174
  this.storeRelay = (spec) => storePassthrough(transport, spec);
168
175
  }
169
176
  }
170
177
  /** Kept in step with `package.json` by `sdk.test.ts`. */
171
- export const SDK_VERSION = "0.32.0";
178
+ export const SDK_VERSION = "0.34.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
  *
@@ -139,4 +139,54 @@ export class AiResource {
139
139
  });
140
140
  return result.data;
141
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
+ }
142
192
  }
@@ -14,6 +14,10 @@ export class BillingResource {
14
14
  * independently of the instance it talks to. Without the coercion a partner
15
15
  * pointed at a Connect that has not upgraded yet renders a feature list of
16
16
  * blanks - which is exactly what happened. See `normalizePlanFeatures`.
17
+ *
18
+ * `limits` is defaulted for the same reason, one version later: an instance
19
+ * that predates limits sends no such field, and a pricing grid mapping over
20
+ * `plan.limits` would throw rather than simply show no limit rows.
17
21
  */
18
22
  async listPlans() {
19
23
  const response = await request(this.transport, { method: "GET", path: "/plans" });
@@ -21,6 +25,7 @@ export class BillingResource {
21
25
  plans: (response.plans ?? []).map((plan) => ({
22
26
  ...plan,
23
27
  features: normalizePlanFeatures(plan.features),
28
+ limits: plan.limits ?? [],
24
29
  })),
25
30
  };
26
31
  }
@@ -97,4 +97,63 @@ export class EnrichmentResource {
97
97
  signal: options.signal,
98
98
  });
99
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
+ }
100
159
  }
@@ -1,4 +1,5 @@
1
1
  import { pathSegment, request } from "../http.js";
2
+ import { NO_PLAN_LIMIT, planLimitAllows, planLimitFor } from "../../helpers.js";
2
3
  /**
3
4
  * The two checks worth making on every request: may this workspace be here at
4
5
  * all, and what is it paying for.
@@ -42,6 +43,52 @@ export class EntitlementsResource {
42
43
  const result = await this.getSubscription(workspaceId);
43
44
  return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
44
45
  }
46
+ /**
47
+ * How much of a metered resource this workspace may have - the numeric
48
+ * counterpart of `hasFeature`, for the resources your application counts
49
+ * itself (sites, projects, pipelines).
50
+ *
51
+ * **This is the effective ceiling, not the plan's own number.** Connect has
52
+ * already folded in whatever the add-ons on the subscription raise it by, so
53
+ * "Starter includes 1 site, they bought two 5-site packs" arrives here as
54
+ * `{ model: "limited", value: 11 }`. You never join the plan against the
55
+ * add-on list yourself, and a customer who buys another pack needs no change
56
+ * on your side.
57
+ *
58
+ * Returns `{ model: "none" }` rather than throwing when there is no active
59
+ * subscription, when the plan carries no answer for this key, and when the
60
+ * Connect instance predates limits entirely - the same reasoning
61
+ * `hasFeature` gives for answering `false`: "not entitled" is the same answer
62
+ * either way, and a limit check should not need a try/catch around it.
63
+ *
64
+ * Connect does not enforce this. It cannot see what you are counting, so it
65
+ * hands you the number and your code refuses the next row - `canAdd` is that
66
+ * comparison if you already have the count.
67
+ */
68
+ async getLimit(workspaceId, key) {
69
+ const result = await this.getSubscription(workspaceId);
70
+ if (!result.hasActiveSubscription)
71
+ return NO_PLAN_LIMIT;
72
+ return planLimitFor(result.plan?.limits, key);
73
+ }
74
+ /**
75
+ * Whether this workspace may add `additional` more of a limited resource,
76
+ * given how many it already has.
77
+ *
78
+ * You supply the count, because only your application can produce it. This
79
+ * is `getLimit` plus `planLimitAllows`, which exists so the comparison is
80
+ * not rewritten per call site - see that helper for the three ways of
81
+ * getting it wrong.
82
+ *
83
+ * ```ts
84
+ * if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
85
+ * return refuse("Your plan's site limit is reached.");
86
+ * }
87
+ * ```
88
+ */
89
+ async canAdd(workspaceId, key, currentCount, additional = 1) {
90
+ return planLimitAllows(await this.getLimit(workspaceId, key), currentCount, additional);
91
+ }
45
92
  /**
46
93
  * One page of the workspaces subscribed to your application, newest first.
47
94
  *
@@ -0,0 +1,222 @@
1
+ import { request } from "../http.js";
2
+ /**
3
+ * The header a bridge forwards the visitor's own address on.
4
+ *
5
+ * It exists because `x-forwarded-for` cannot do this job here: by the time your
6
+ * backend calls Connect, that header describes the hop from *your server*, and
7
+ * the address Connect's per-visitor limiter needs is the one on the hop before
8
+ * it - which only your bridge has seen.
9
+ *
10
+ * **What it is and is not trusted for.** It partitions your own application's
11
+ * budget fairly between visitors; it does not grant anything. A caller sending a
12
+ * fresh value per request defeats only the per-visitor limiter and is still
13
+ * bounded by the per-key burst limit and the per-application daily ceiling -
14
+ * which are the two that actually cap spending, and neither is client-supplied.
15
+ * Omit it and every visitor shares one bucket, which is a stricter limit rather
16
+ * than an exemption.
17
+ */
18
+ const VISITOR_IP_HEADER = "x-connect-visitor-ip";
19
+ function visitorHeaders(options) {
20
+ return options?.visitorIp ? { [VISITOR_IP_HEADER]: options.visitorIp } : {};
21
+ }
22
+ /**
23
+ * The public chat bubble on your own marketing site.
24
+ *
25
+ * The one resource here whose caller is **not** one of your users. There is no
26
+ * `asUser`, no `workspaceId` and no session anywhere in it: the counterpart is a
27
+ * stranger who has no account, and everything the assistant may say or do is
28
+ * configuration a platform admin set on your application record.
29
+ *
30
+ * That is why none of these methods takes an id naming anything. The workspace a
31
+ * call books into, the event type it books, the knowledge it answers from and the
32
+ * daily budget it spends are all read from your application - so nothing a
33
+ * visitor types, and nothing an injection attempt in your own chat window
34
+ * achieves, reaches a different tenant.
35
+ *
36
+ * **Never call these from a browser.** They take your API key, like every other
37
+ * resource on this client. `createConnectHandler` already exposes them on your
38
+ * own origin for `<ConnectVisitorChat>` to use, which is how the credential stays
39
+ * server-side.
40
+ */
41
+ export class VisitorResource {
42
+ transport;
43
+ /**
44
+ * The knowledge base behind the assistant - see `VisitorKnowledgeResource`.
45
+ *
46
+ * Nested rather than a top-level resource, because it is meaningless on its
47
+ * own: it exists to decide what *this* assistant may say.
48
+ */
49
+ knowledge;
50
+ constructor(transport) {
51
+ this.transport = transport;
52
+ this.knowledge = new VisitorKnowledgeResource(transport);
53
+ }
54
+ /**
55
+ * Open a conversation, or reopen one.
56
+ *
57
+ * Generates nothing - the greeting and the suggested questions come off the
58
+ * application record - so this is the one call in the flow that is always fast,
59
+ * which is what lets a bubble paint real copy instead of a spinner.
60
+ *
61
+ * Pass `resume` with a `sessionId` you kept and the transcript comes back with
62
+ * it. A `resume` that is expired, unknown or another application's silently
63
+ * starts a fresh conversation rather than failing: the token is the only thing
64
+ * being offered and it is being rejected, so there is nothing for the visitor
65
+ * to do about an error.
66
+ */
67
+ startSession(input, options = {}) {
68
+ return request(this.transport, {
69
+ method: "POST",
70
+ path: "/visitor/session",
71
+ json: {
72
+ workspaceId: input.workspaceId,
73
+ page: input.page ?? null,
74
+ resume: input.resume ?? null,
75
+ },
76
+ headers: visitorHeaders(options),
77
+ signal: options.signal,
78
+ });
79
+ }
80
+ /**
81
+ * One turn, streamed.
82
+ *
83
+ * Returns the raw `Response` rather than a parsed body, the same way
84
+ * `ai.chat` does: this answers server-sent events, and buffering them here
85
+ * would throw away the only reason to stream. The events are `delta`
86
+ * (`{text}`), `done` (`VisitorTurnDone`) and `error` (`{message}`).
87
+ *
88
+ * **The transcript is not a parameter**, which is the security property this
89
+ * endpoint is built around: the caller is ultimately a browser on a public
90
+ * page, and a client that can supply its own transcript can supply one in
91
+ * which anything has already been established. Only the conversation id and
92
+ * the new message travel.
93
+ */
94
+ chat(input, options = {}) {
95
+ return request(this.transport, {
96
+ method: "POST",
97
+ path: "/visitor/chat",
98
+ json: input,
99
+ headers: visitorHeaders(options),
100
+ raw: true,
101
+ signal: options.signal,
102
+ });
103
+ }
104
+ /**
105
+ * Book the call a visitor chose.
106
+ *
107
+ * `startAt` must be a time the turn's own slot lookup returned - an arbitrary
108
+ * instant is refused. That, plus a name and phone collected in real fields,
109
+ * is what makes it impossible to book an appointment at a time nobody offered
110
+ * or with a phone number a model parsed out of prose.
111
+ *
112
+ * A conversation books at most one appointment; a second call answers `409`.
113
+ */
114
+ book(input, options = {}) {
115
+ return request(this.transport, {
116
+ method: "POST",
117
+ path: "/visitor/book",
118
+ headers: visitorHeaders(options),
119
+ signal: options.signal,
120
+ json: {
121
+ ...input,
122
+ startAt: input.startAt instanceof Date ? input.startAt.toISOString() : input.startAt,
123
+ email: input.email ?? null,
124
+ notes: input.notes ?? null,
125
+ },
126
+ });
127
+ }
128
+ }
129
+ /**
130
+ * A subscribing workspace's **public** knowledge base - what their website chat
131
+ * may tell strangers.
132
+ *
133
+ * ## It is deliberately not their Context Index
134
+ *
135
+ * `ai.chat` answers from a workspace's Drive-fed corpus, for a signed-in member
136
+ * of that workspace. This is a separate store that holds only what somebody
137
+ * explicitly published, and the separation is the reason a publishable widget
138
+ * credential is safe to publish: everything the widget can reach was already
139
+ * meant for the public.
140
+ *
141
+ * Do not try to fill it by uploading to Drive. Nothing reaches this store as a
142
+ * side effect of anything else - that is the invariant, not an inconvenience.
143
+ *
144
+ * ## Publishing is its own call
145
+ *
146
+ * `save` always writes a draft. `publish` is what makes text readable by
147
+ * strangers, so no edit can do it by accident, and `unpublish` can fail - which
148
+ * means the item is **still live**, not that the call was pointless.
149
+ */
150
+ export class VisitorKnowledgeResource {
151
+ transport;
152
+ constructor(transport) {
153
+ this.transport = transport;
154
+ }
155
+ /** Everything, drafts included - the list a customer audits. */
156
+ list(workspaceId) {
157
+ return request(this.transport, {
158
+ method: "GET",
159
+ path: "/visitor/knowledge",
160
+ query: { workspaceId },
161
+ });
162
+ }
163
+ /**
164
+ * Create or edit a draft.
165
+ *
166
+ * A body over the cap is **refused rather than truncated** - split a long page
167
+ * into sections, which is also what makes retrieval work.
168
+ */
169
+ async save(input) {
170
+ const result = await request(this.transport, {
171
+ method: "POST",
172
+ path: "/visitor/knowledge",
173
+ json: {
174
+ ...input,
175
+ itemId: input.itemId ?? null,
176
+ sourceRef: input.sourceRef ?? null,
177
+ },
178
+ });
179
+ return result.item;
180
+ }
181
+ /**
182
+ * Make one item readable by strangers.
183
+ *
184
+ * May be slow: the publish that outgrows the prompt provisions a retrieval
185
+ * corpus and indexes everything published so far. An indexing failure still
186
+ * publishes and comes back with `indexState: "failed"` and `indexError` set,
187
+ * so a provider hiccup never makes your button look broken.
188
+ */
189
+ async publish(input) {
190
+ const result = await request(this.transport, {
191
+ method: "POST",
192
+ path: "/visitor/knowledge/publish",
193
+ json: input,
194
+ });
195
+ return result.item;
196
+ }
197
+ /**
198
+ * Take one item out of what strangers can read.
199
+ *
200
+ * **A failure means it is still live.** The retrievable copy is removed before
201
+ * the record is flipped, so a provider that refuses the delete aborts rather
202
+ * than leaving an item your screen shows as withdrawn that the widget goes on
203
+ * quoting. Surface the error and let them retry; do not grey the row out
204
+ * optimistically.
205
+ */
206
+ async unpublish(input) {
207
+ const result = await request(this.transport, {
208
+ method: "POST",
209
+ path: "/visitor/knowledge/unpublish",
210
+ json: input,
211
+ });
212
+ return result.item;
213
+ }
214
+ /** Remove it entirely. A published item is unpublished first. */
215
+ async delete(input) {
216
+ await request(this.transport, {
217
+ method: "POST",
218
+ path: "/visitor/knowledge/delete",
219
+ json: input,
220
+ });
221
+ }
222
+ }
@@ -23,6 +23,15 @@ export type ConnectAccessGateState = "loading"
23
23
  | "clear";
24
24
  export interface ConnectAccessGateResult {
25
25
  state: ConnectAccessGateState;
26
+ /**
27
+ * Re-reads entitlements, evicting the cached answer first.
28
+ *
29
+ * Exposed because a blocker is almost always resolved **somewhere this tab
30
+ * cannot see** - on Connect, in another tab or on a phone - and nothing
31
+ * about that reaches this one. The gate wires it to the tab regaining
32
+ * focus; call it yourself if your app learns of a change some other way.
33
+ */
34
+ refresh(): void;
26
35
  /** The blocker driving a hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`). `null` otherwise. */
27
36
  blocker: ReadinessBlocker | null;
28
37
  /** The `payment_retry_pending` blocker, only when `state === "payment_retry"`. Carries `graceEndsAt`. */