@cosmoops/connect-sdk 0.47.0 → 0.49.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 CHANGED
@@ -3,6 +3,37 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.49.0
7
+
8
+ ### Calling readiness
9
+
10
+ `connect.calls.getCallingReadiness({ asUser })` answers whether the
11
+ workspace may legally make each kind of call - `runs` covers service and
12
+ sales (`promotional`) calls, dialled by a rep (`preview`) or by software
13
+ (`automatic`), each with the first `reason` in the way - plus the steps left
14
+ on Connect's calling compliance journey and a `setupUrl` to finish them.
15
+ Member-only, on the `calls:place` scope. Read `runs` rather than deriving
16
+ anything from `steps`: Connect evaluates them with the gates its dialer
17
+ refuses on. Additive; nothing else changed.
18
+
19
+ ## 0.48.0
20
+
21
+ ### Calls declare a purpose
22
+
23
+ `<ConnectCallButton purpose="promotional">`, `startCall({ phone, purpose })`
24
+ and `connect.calls.startBrowserCall({ phone, asUser, purpose })` take an
25
+ optional `ConnectCallPurpose` - `"service"`, `"transactional"` or
26
+ `"promotional"`. Connect records it on the call and places the call from a
27
+ workspace number allowed to carry it: in India a regular number carries
28
+ service and transactional calls only, and promotional calls need a 140-series
29
+ number with an approved DLT header. Omit it and the call is placed as before.
30
+
31
+ While the workspace's Connect deployment runs `CALL_PURPOSE_ENFORCEMENT=shadow`
32
+ (the default), a call no number may carry is still placed and only logged;
33
+ under `enforce` it is refused with a `412` whose message names the number
34
+ series needed. The bridge drops a purpose that is not one of the three
35
+ rather than forwarding it to be refused.
36
+
6
37
  ## 0.47.0
7
38
 
8
39
  ### `connect.integrations.request()` reaches the endpoints a toolkit has no action for
@@ -243,6 +243,7 @@ function ConnectCallProvider({ children, onCallEnded, onError, className, }) {
243
243
  const client = await signIn();
244
244
  const started = await mutateResource("/calls/start", {
245
245
  phone: target.phone,
246
+ ...(target.purpose ? { purpose: target.purpose } : {}),
246
247
  });
247
248
  callIdRef.current = started.callId;
248
249
  setCall((current) => current ? { ...current, callId: started.callId, number: started.toNumber } : current);
@@ -308,9 +309,9 @@ function ConnectCallProvider({ children, onCallEnded, onError, className, }) {
308
309
  * above it, and a member with "Make calls" - render it only for them, since a
309
310
  * button the server refuses for everyone else helps nobody.
310
311
  */
311
- function ConnectCallButton({ phone, name, onEnded, variant = "default", children, isDisabled, className, }) {
312
+ function ConnectCallButton({ phone, name, purpose, onEnded, variant = "default", children, isDisabled, className, }) {
312
313
  const { isBusy, startCall, availability } = useConnectCall();
313
- const press = () => void startCall({ phone, name, onEnded });
314
+ const press = () => void startCall({ phone, name, purpose, onEnded });
314
315
  // Not offered at all to someone who may not call - a button that always
315
316
  // refuses helps nobody.
316
317
  if (availability?.status === "no_permission")
@@ -183,4 +183,4 @@ class ConnectClient {
183
183
  }
184
184
  exports.ConnectClient = ConnectClient;
185
185
  /** Kept in step with `package.json` by `sdk.test.ts`. */
186
- exports.SDK_VERSION = "0.47.0";
186
+ exports.SDK_VERSION = "0.49.0";
@@ -1,8 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SESSION_CACHE_TTL_MS = void 0;
3
4
  exports.createConnectHandler = createConnectHandler;
4
5
  const errors_js_1 = require("../errors.js");
5
6
  const identity_js_1 = require("./resources/identity.js");
7
+ /** The purposes `/calls/start` accepts - see `ConnectCallPurpose`. */
8
+ const CALL_PURPOSES = ["service", "transactional", "promotional"];
9
+ /**
10
+ * The bridge's default session reuse window - the longest a revoked session,
11
+ * a removed member or a sign-out on another device keeps working through a
12
+ * warm instance. Seconds on purpose: this gates a person's access.
13
+ */
14
+ exports.SESSION_CACHE_TTL_MS = 15_000;
15
+ /** Sessions held per handler. Least recently used falls out first. */
16
+ const SESSION_CACHE_MAX = 5_000;
6
17
  /**
7
18
  * A sentinel origin to resolve a candidate destination against. Never reached -
8
19
  * `.invalid` is reserved by RFC 2606 precisely so it cannot resolve.
@@ -308,9 +319,71 @@ function createConnectHandler(options) {
308
319
  }
309
320
  return token;
310
321
  }
322
+ /**
323
+ * Resolved sessions, per session token, for `SESSION_CACHE_TTL_MS`.
324
+ *
325
+ * Every tenant-scoped route starts by asking Connect who the caller is, and
326
+ * a page load fires several of them at once - so uncached, the bridge made
327
+ * one `/sso/user` round trip per browser request, and a partner's Connect
328
+ * traffic grew with their users times their routes. A resolved session is
329
+ * kept per token for a few seconds instead, the same trade
330
+ * `crmSession` makes in the reference CRM:
331
+ *
332
+ * - **Staleness is bounded and small.** A revoked session, a removed member
333
+ * or a reduced role keeps working here for at most the TTL.
334
+ * - **Signing out is still immediate.** The cache is keyed by the token the
335
+ * partner's session store hands back; `/logout` drops the entry, and once
336
+ * the cookie is gone there is no token to look up.
337
+ * - **`GET /session` always reads through.** It is what the browser re-reads
338
+ * after a hand-off (a plan bought in a popup, a profile edit), so it must
339
+ * see the change at once; it refreshes the entry for everyone after it.
340
+ *
341
+ * The promise is cached rather than the value, so a burst of parallel
342
+ * requests on a cold token makes one round trip, and a failed lookup is
343
+ * dropped at once - a refusal is never remembered.
344
+ *
345
+ * Per handler and in process, timed on `performance.now()` (a monotonic
346
+ * clock cannot jump when the system clock is corrected).
347
+ */
348
+ const sessionCacheTtlMs = Math.max(0, options.sessionCacheTtlMs ?? exports.SESSION_CACHE_TTL_MS);
349
+ const sessionCache = new Map();
350
+ function rememberSession(token, session) {
351
+ if (sessionCacheTtlMs === 0)
352
+ return session;
353
+ sessionCache.delete(token);
354
+ while (sessionCache.size >= SESSION_CACHE_MAX) {
355
+ const oldest = sessionCache.keys().next();
356
+ if (oldest.done)
357
+ break;
358
+ sessionCache.delete(oldest.value);
359
+ }
360
+ const entry = { session, until: performance.now() + sessionCacheTtlMs };
361
+ sessionCache.set(token, entry);
362
+ session.catch(() => {
363
+ // Only the entry this lookup created - a newer one for the same token
364
+ // must survive an older lookup failing late.
365
+ if (sessionCache.get(token) === entry)
366
+ sessionCache.delete(token);
367
+ });
368
+ return session;
369
+ }
370
+ /** A fresh read from Connect, which then answers the cached routes too. */
371
+ function resolveSessionFresh(token) {
372
+ return rememberSession(token, client.identity.getUser(token));
373
+ }
374
+ function resolveSession(token) {
375
+ const hit = sessionCache.get(token);
376
+ if (hit && hit.until > performance.now()) {
377
+ // Re-inserted so the map's order tracks recency.
378
+ sessionCache.delete(token);
379
+ sessionCache.set(token, hit);
380
+ return hit.session;
381
+ }
382
+ return resolveSessionFresh(token);
383
+ }
311
384
  /** Signed-in identity plus the workspace it belongs to - the first read every other route needs. */
312
385
  async function whoami(request) {
313
- return client.identity.getUser(await requireToken(request));
386
+ return resolveSession(await requireToken(request));
314
387
  }
315
388
  /**
316
389
  * The caller's identity, plus the workspace every tenant-scoped route below
@@ -344,7 +417,8 @@ function createConnectHandler(options) {
344
417
  /**
345
418
  * The store paths a browser may reach through the bridge.
346
419
  *
347
- * Documents, the key/value store, aggregations and atomic commits.
420
+ * Documents (one at a time or a batch by id), the key/value store,
421
+ * aggregations and atomic commits.
348
422
  * **`/data-scan/` is deliberately absent**: a scan crosses every tenant, and
349
423
  * Connect refuses one made on behalf of a user - but a browser should not be
350
424
  * able to *ask*, so the refusal happens here rather than a round trip away.
@@ -354,7 +428,8 @@ function createConnectHandler(options) {
354
428
  return (route.startsWith("/data/") ||
355
429
  route.startsWith("/kv/") ||
356
430
  route.startsWith("/data-aggregate/") ||
357
- route === "/data-commit");
431
+ route === "/data-commit" ||
432
+ route === "/data-get-many");
358
433
  }
359
434
  /**
360
435
  * Forwards one store call to Connect on behalf of the caller.
@@ -437,7 +512,7 @@ function createConnectHandler(options) {
437
512
  clientId,
438
513
  });
439
514
  }
440
- return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
515
+ return json({ ...(await resolveSessionFresh(token)), oneTap: oneTapEndpoints, clientId });
441
516
  },
442
517
  "GET /entitlements": async (request) => {
443
518
  const { workspaceId } = await requireWorkspace(request);
@@ -848,6 +923,9 @@ function createConnectHandler(options) {
848
923
  });
849
924
  }
850
925
  const result = await client.workspaces.switch({ workspaceId: body.workspaceId }, { asUser: token });
926
+ // The old token is retired by the switch; its cached session names the
927
+ // workspace just left.
928
+ sessionCache.delete(token);
851
929
  // The response is built first so `session.write` has something to set
852
930
  // cookies on, matching how `/callback` does it.
853
931
  const response = json({
@@ -1260,7 +1338,10 @@ function createConnectHandler(options) {
1260
1338
  endpoint: "/calls/start",
1261
1339
  });
1262
1340
  }
1263
- return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
1341
+ // Passed on only when it is one of the three - Connect validates it
1342
+ // again, and an unknown value is dropped rather than sent to be refused.
1343
+ const purpose = CALL_PURPOSES.find((value) => value === body.purpose);
1344
+ return json(await client.calls.startBrowserCall({ phone: body.phone, asUser, purpose }), 201);
1264
1345
  },
1265
1346
  "POST /calls/cancel": async (request) => {
1266
1347
  await requireWorkspace(request);
@@ -1497,6 +1578,8 @@ function createConnectHandler(options) {
1497
1578
  },
1498
1579
  "POST /logout": async (request) => {
1499
1580
  const token = await session.read(request);
1581
+ if (token)
1582
+ sessionCache.delete(token);
1500
1583
  const response = json({ ok: true });
1501
1584
  await session.clear?.(request, response);
1502
1585
  // Connect is told last, and a failure is swallowed: the user's own
@@ -104,6 +104,18 @@ class CallsResource {
104
104
  headers: (0, acting_as_js_1.actingAs)(input.asUser),
105
105
  });
106
106
  }
107
+ /**
108
+ * Whether the workspace may legally make each kind of call - service or
109
+ * sales, dialled by a rep or by software - and the steps left on Connect's
110
+ * calling compliance journey. Send the member to `setupUrl` to finish them.
111
+ */
112
+ getCallingReadiness(input) {
113
+ return (0, http_js_1.request)(this.transport, {
114
+ method: "GET",
115
+ path: "/calling/readiness",
116
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
117
+ });
118
+ }
107
119
  /** A carrier token the member's browser signs in to the phone network with. */
108
120
  getBrowserCallingToken(input) {
109
121
  return (0, http_js_1.request)(this.transport, {
@@ -118,13 +130,14 @@ class CallsResource {
118
130
  *
119
131
  * Throws `ConnectError` with status `412` when the workspace has no active
120
132
  * number, is not allowed to place calls, or the member is already on one -
121
- * the message is written to be shown to them.
133
+ * the message is written to be shown to them. With a `purpose`, also when
134
+ * no number may carry it and the workspace enforces purposes.
122
135
  */
123
136
  startBrowserCall(input) {
124
137
  return (0, http_js_1.request)(this.transport, {
125
138
  method: "POST",
126
139
  path: "/calls/start",
127
- json: { phone: input.phone },
140
+ json: input.purpose ? { phone: input.phone, purpose: input.purpose } : { phone: input.phone },
128
141
  headers: (0, acting_as_js_1.actingAs)(input.asUser),
129
142
  });
130
143
  }
@@ -60,6 +60,36 @@ class DataResource {
60
60
  headers: (0, acting_as_js_1.actingAs)(input.asUser),
61
61
  });
62
62
  }
63
+ /**
64
+ * Reads up to 200 documents of one collection by id, in **one request** -
65
+ * the batch form of `get`.
66
+ *
67
+ * Reach for it wherever you would loop over `get`: the contacts on a page of
68
+ * deals, the plans behind a list of subscriptions. It is gated exactly like
69
+ * `get` and spends one request of your rate limit however many ids it names.
70
+ *
71
+ * ```ts
72
+ * const { items } = await connect.data.getMany({
73
+ * workspaceId,
74
+ * collection: "contacts",
75
+ * ids: deals.map((deal) => deal.data.contactId as string),
76
+ * });
77
+ * // items[i] belongs to ids[i]; null where that contact no longer exists.
78
+ * ```
79
+ *
80
+ * Answers positionally, `null` where a document does not exist - unlike
81
+ * `get`, a missing document does not throw.
82
+ */
83
+ getMany(input) {
84
+ return (0, http_js_1.request)(this.transport, {
85
+ method: "POST",
86
+ path: "/data-get-many",
87
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
88
+ // A POST that writes nothing: two hundred ids do not fit in a URL.
89
+ idempotent: true,
90
+ json: { workspaceId: input.workspaceId, collection: input.collection, ids: input.ids },
91
+ });
92
+ }
63
93
  /**
64
94
  * Lists a collection, one page at a time.
65
95
  *
@@ -341,11 +371,12 @@ function isConflict(error) {
341
371
  * a round trip away.
342
372
  */
343
373
  /**
344
- * The store paths a browser may reach through the bridge: documents, the
345
- * key/value store, aggregations, and atomic commits. Anchored, so a path like
346
- * `/data-scan/tickets` cannot slip through on a prefix match.
374
+ * The store paths a browser may reach through the bridge: documents (singly or
375
+ * a batch by id), the key/value store, aggregations, and atomic commits.
376
+ * Anchored, so a path like `/data-scan/tickets` cannot slip through on a
377
+ * prefix match.
347
378
  */
348
- const STORE_PATHS = /^\/(?:data|kv|data-aggregate)\/|^\/data-commit$/;
379
+ const STORE_PATHS = /^\/(?:data|kv|data-aggregate)\/|^\/data-commit$|^\/data-get-many$/;
349
380
  // `async` so the refusal below arrives as a rejected promise rather than a
350
381
  // synchronous throw. A function typed `Promise<Response>` that sometimes
351
382
  // throws before returning one is the kind of thing a caller's `.catch()`
@@ -203,6 +203,7 @@ export function ConnectCallProvider({ children, onCallEnded, onError, className,
203
203
  const client = await signIn();
204
204
  const started = await mutateResource("/calls/start", {
205
205
  phone: target.phone,
206
+ ...(target.purpose ? { purpose: target.purpose } : {}),
206
207
  });
207
208
  callIdRef.current = started.callId;
208
209
  setCall((current) => current ? { ...current, callId: started.callId, number: started.toNumber } : current);
@@ -268,9 +269,9 @@ export function ConnectCallProvider({ children, onCallEnded, onError, className,
268
269
  * above it, and a member with "Make calls" - render it only for them, since a
269
270
  * button the server refuses for everyone else helps nobody.
270
271
  */
271
- export function ConnectCallButton({ phone, name, onEnded, variant = "default", children, isDisabled, className, }) {
272
+ export function ConnectCallButton({ phone, name, purpose, onEnded, variant = "default", children, isDisabled, className, }) {
272
273
  const { isBusy, startCall, availability } = useConnectCall();
273
- const press = () => void startCall({ phone, name, onEnded });
274
+ const press = () => void startCall({ phone, name, purpose, onEnded });
274
275
  // Not offered at all to someone who may not call - a button that always
275
276
  // refuses helps nobody.
276
277
  if (availability?.status === "no_permission")
@@ -179,4 +179,4 @@ export class ConnectClient {
179
179
  }
180
180
  }
181
181
  /** Kept in step with `package.json` by `sdk.test.ts`. */
182
- export const SDK_VERSION = "0.47.0";
182
+ export const SDK_VERSION = "0.49.0";
@@ -1,5 +1,15 @@
1
1
  import { ConnectError } from "../errors.js";
2
2
  import { createPkcePair, createState } from "./resources/identity.js";
3
+ /** The purposes `/calls/start` accepts - see `ConnectCallPurpose`. */
4
+ const CALL_PURPOSES = ["service", "transactional", "promotional"];
5
+ /**
6
+ * The bridge's default session reuse window - the longest a revoked session,
7
+ * a removed member or a sign-out on another device keeps working through a
8
+ * warm instance. Seconds on purpose: this gates a person's access.
9
+ */
10
+ export const SESSION_CACHE_TTL_MS = 15_000;
11
+ /** Sessions held per handler. Least recently used falls out first. */
12
+ const SESSION_CACHE_MAX = 5_000;
3
13
  /**
4
14
  * A sentinel origin to resolve a candidate destination against. Never reached -
5
15
  * `.invalid` is reserved by RFC 2606 precisely so it cannot resolve.
@@ -305,9 +315,71 @@ export function createConnectHandler(options) {
305
315
  }
306
316
  return token;
307
317
  }
318
+ /**
319
+ * Resolved sessions, per session token, for `SESSION_CACHE_TTL_MS`.
320
+ *
321
+ * Every tenant-scoped route starts by asking Connect who the caller is, and
322
+ * a page load fires several of them at once - so uncached, the bridge made
323
+ * one `/sso/user` round trip per browser request, and a partner's Connect
324
+ * traffic grew with their users times their routes. A resolved session is
325
+ * kept per token for a few seconds instead, the same trade
326
+ * `crmSession` makes in the reference CRM:
327
+ *
328
+ * - **Staleness is bounded and small.** A revoked session, a removed member
329
+ * or a reduced role keeps working here for at most the TTL.
330
+ * - **Signing out is still immediate.** The cache is keyed by the token the
331
+ * partner's session store hands back; `/logout` drops the entry, and once
332
+ * the cookie is gone there is no token to look up.
333
+ * - **`GET /session` always reads through.** It is what the browser re-reads
334
+ * after a hand-off (a plan bought in a popup, a profile edit), so it must
335
+ * see the change at once; it refreshes the entry for everyone after it.
336
+ *
337
+ * The promise is cached rather than the value, so a burst of parallel
338
+ * requests on a cold token makes one round trip, and a failed lookup is
339
+ * dropped at once - a refusal is never remembered.
340
+ *
341
+ * Per handler and in process, timed on `performance.now()` (a monotonic
342
+ * clock cannot jump when the system clock is corrected).
343
+ */
344
+ const sessionCacheTtlMs = Math.max(0, options.sessionCacheTtlMs ?? SESSION_CACHE_TTL_MS);
345
+ const sessionCache = new Map();
346
+ function rememberSession(token, session) {
347
+ if (sessionCacheTtlMs === 0)
348
+ return session;
349
+ sessionCache.delete(token);
350
+ while (sessionCache.size >= SESSION_CACHE_MAX) {
351
+ const oldest = sessionCache.keys().next();
352
+ if (oldest.done)
353
+ break;
354
+ sessionCache.delete(oldest.value);
355
+ }
356
+ const entry = { session, until: performance.now() + sessionCacheTtlMs };
357
+ sessionCache.set(token, entry);
358
+ session.catch(() => {
359
+ // Only the entry this lookup created - a newer one for the same token
360
+ // must survive an older lookup failing late.
361
+ if (sessionCache.get(token) === entry)
362
+ sessionCache.delete(token);
363
+ });
364
+ return session;
365
+ }
366
+ /** A fresh read from Connect, which then answers the cached routes too. */
367
+ function resolveSessionFresh(token) {
368
+ return rememberSession(token, client.identity.getUser(token));
369
+ }
370
+ function resolveSession(token) {
371
+ const hit = sessionCache.get(token);
372
+ if (hit && hit.until > performance.now()) {
373
+ // Re-inserted so the map's order tracks recency.
374
+ sessionCache.delete(token);
375
+ sessionCache.set(token, hit);
376
+ return hit.session;
377
+ }
378
+ return resolveSessionFresh(token);
379
+ }
308
380
  /** Signed-in identity plus the workspace it belongs to - the first read every other route needs. */
309
381
  async function whoami(request) {
310
- return client.identity.getUser(await requireToken(request));
382
+ return resolveSession(await requireToken(request));
311
383
  }
312
384
  /**
313
385
  * The caller's identity, plus the workspace every tenant-scoped route below
@@ -341,7 +413,8 @@ export function createConnectHandler(options) {
341
413
  /**
342
414
  * The store paths a browser may reach through the bridge.
343
415
  *
344
- * Documents, the key/value store, aggregations and atomic commits.
416
+ * Documents (one at a time or a batch by id), the key/value store,
417
+ * aggregations and atomic commits.
345
418
  * **`/data-scan/` is deliberately absent**: a scan crosses every tenant, and
346
419
  * Connect refuses one made on behalf of a user - but a browser should not be
347
420
  * able to *ask*, so the refusal happens here rather than a round trip away.
@@ -351,7 +424,8 @@ export function createConnectHandler(options) {
351
424
  return (route.startsWith("/data/") ||
352
425
  route.startsWith("/kv/") ||
353
426
  route.startsWith("/data-aggregate/") ||
354
- route === "/data-commit");
427
+ route === "/data-commit" ||
428
+ route === "/data-get-many");
355
429
  }
356
430
  /**
357
431
  * Forwards one store call to Connect on behalf of the caller.
@@ -434,7 +508,7 @@ export function createConnectHandler(options) {
434
508
  clientId,
435
509
  });
436
510
  }
437
- return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
511
+ return json({ ...(await resolveSessionFresh(token)), oneTap: oneTapEndpoints, clientId });
438
512
  },
439
513
  "GET /entitlements": async (request) => {
440
514
  const { workspaceId } = await requireWorkspace(request);
@@ -845,6 +919,9 @@ export function createConnectHandler(options) {
845
919
  });
846
920
  }
847
921
  const result = await client.workspaces.switch({ workspaceId: body.workspaceId }, { asUser: token });
922
+ // The old token is retired by the switch; its cached session names the
923
+ // workspace just left.
924
+ sessionCache.delete(token);
848
925
  // The response is built first so `session.write` has something to set
849
926
  // cookies on, matching how `/callback` does it.
850
927
  const response = json({
@@ -1257,7 +1334,10 @@ export function createConnectHandler(options) {
1257
1334
  endpoint: "/calls/start",
1258
1335
  });
1259
1336
  }
1260
- return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
1337
+ // Passed on only when it is one of the three - Connect validates it
1338
+ // again, and an unknown value is dropped rather than sent to be refused.
1339
+ const purpose = CALL_PURPOSES.find((value) => value === body.purpose);
1340
+ return json(await client.calls.startBrowserCall({ phone: body.phone, asUser, purpose }), 201);
1261
1341
  },
1262
1342
  "POST /calls/cancel": async (request) => {
1263
1343
  await requireWorkspace(request);
@@ -1494,6 +1574,8 @@ export function createConnectHandler(options) {
1494
1574
  },
1495
1575
  "POST /logout": async (request) => {
1496
1576
  const token = await session.read(request);
1577
+ if (token)
1578
+ sessionCache.delete(token);
1497
1579
  const response = json({ ok: true });
1498
1580
  await session.clear?.(request, response);
1499
1581
  // Connect is told last, and a failure is swallowed: the user's own
@@ -101,6 +101,18 @@ export class CallsResource {
101
101
  headers: actingAs(input.asUser),
102
102
  });
103
103
  }
104
+ /**
105
+ * Whether the workspace may legally make each kind of call - service or
106
+ * sales, dialled by a rep or by software - and the steps left on Connect's
107
+ * calling compliance journey. Send the member to `setupUrl` to finish them.
108
+ */
109
+ getCallingReadiness(input) {
110
+ return request(this.transport, {
111
+ method: "GET",
112
+ path: "/calling/readiness",
113
+ headers: actingAs(input.asUser),
114
+ });
115
+ }
104
116
  /** A carrier token the member's browser signs in to the phone network with. */
105
117
  getBrowserCallingToken(input) {
106
118
  return request(this.transport, {
@@ -115,13 +127,14 @@ export class CallsResource {
115
127
  *
116
128
  * Throws `ConnectError` with status `412` when the workspace has no active
117
129
  * number, is not allowed to place calls, or the member is already on one -
118
- * the message is written to be shown to them.
130
+ * the message is written to be shown to them. With a `purpose`, also when
131
+ * no number may carry it and the workspace enforces purposes.
119
132
  */
120
133
  startBrowserCall(input) {
121
134
  return request(this.transport, {
122
135
  method: "POST",
123
136
  path: "/calls/start",
124
- json: { phone: input.phone },
137
+ json: input.purpose ? { phone: input.phone, purpose: input.purpose } : { phone: input.phone },
125
138
  headers: actingAs(input.asUser),
126
139
  });
127
140
  }
@@ -56,6 +56,36 @@ export class DataResource {
56
56
  headers: actingAs(input.asUser),
57
57
  });
58
58
  }
59
+ /**
60
+ * Reads up to 200 documents of one collection by id, in **one request** -
61
+ * the batch form of `get`.
62
+ *
63
+ * Reach for it wherever you would loop over `get`: the contacts on a page of
64
+ * deals, the plans behind a list of subscriptions. It is gated exactly like
65
+ * `get` and spends one request of your rate limit however many ids it names.
66
+ *
67
+ * ```ts
68
+ * const { items } = await connect.data.getMany({
69
+ * workspaceId,
70
+ * collection: "contacts",
71
+ * ids: deals.map((deal) => deal.data.contactId as string),
72
+ * });
73
+ * // items[i] belongs to ids[i]; null where that contact no longer exists.
74
+ * ```
75
+ *
76
+ * Answers positionally, `null` where a document does not exist - unlike
77
+ * `get`, a missing document does not throw.
78
+ */
79
+ getMany(input) {
80
+ return request(this.transport, {
81
+ method: "POST",
82
+ path: "/data-get-many",
83
+ headers: actingAs(input.asUser),
84
+ // A POST that writes nothing: two hundred ids do not fit in a URL.
85
+ idempotent: true,
86
+ json: { workspaceId: input.workspaceId, collection: input.collection, ids: input.ids },
87
+ });
88
+ }
59
89
  /**
60
90
  * Lists a collection, one page at a time.
61
91
  *
@@ -336,11 +366,12 @@ function isConflict(error) {
336
366
  * a round trip away.
337
367
  */
338
368
  /**
339
- * The store paths a browser may reach through the bridge: documents, the
340
- * key/value store, aggregations, and atomic commits. Anchored, so a path like
341
- * `/data-scan/tickets` cannot slip through on a prefix match.
369
+ * The store paths a browser may reach through the bridge: documents (singly or
370
+ * a batch by id), the key/value store, aggregations, and atomic commits.
371
+ * Anchored, so a path like `/data-scan/tickets` cannot slip through on a
372
+ * prefix match.
342
373
  */
343
- const STORE_PATHS = /^\/(?:data|kv|data-aggregate)\/|^\/data-commit$/;
374
+ const STORE_PATHS = /^\/(?:data|kv|data-aggregate)\/|^\/data-commit$|^\/data-get-many$/;
344
375
  // `async` so the refusal below arrives as a rejected promise rather than a
345
376
  // synchronous throw. A function typed `Promise<Response>` that sometimes
346
377
  // throws before returning one is the kind of thing a caller's `.catch()`
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
- import type { CallingAvailability } from "../../types.js";
2
+ import type { CallingAvailability, ConnectCallPurpose } from "../../types.js";
3
3
  /**
4
4
  * Calling from the browser, through the workspace's own number.
5
5
  *
@@ -60,6 +60,11 @@ export interface ConnectCallTarget {
60
60
  phone: string;
61
61
  /** Shown on the in-call bar. Defaults to the number. */
62
62
  name?: string;
63
+ /**
64
+ * What the call is for - Connect places it from a number allowed to carry
65
+ * it. See `ConnectCallPurpose`; omit it to place the call as before.
66
+ */
67
+ purpose?: ConnectCallPurpose;
63
68
  /**
64
69
  * Called once the call is over, with Connect's id for it - or `null` when it
65
70
  * never got as far as being approved. Read the outcome on your server with
@@ -118,7 +123,7 @@ export interface ConnectCallButtonProps extends ConnectCallTarget {
118
123
  * above it, and a member with "Make calls" - render it only for them, since a
119
124
  * button the server refuses for everyone else helps nobody.
120
125
  */
121
- export declare function ConnectCallButton({ phone, name, onEnded, variant, children, isDisabled, className, }: ConnectCallButtonProps): import("react").JSX.Element | null;
126
+ export declare function ConnectCallButton({ phone, name, purpose, onEnded, variant, children, isDisabled, className, }: ConnectCallButtonProps): import("react").JSX.Element | null;
122
127
  export interface ConnectCallingStatusProps {
123
128
  /** Merged with `cn`. */
124
129
  className?: string;
@@ -141,4 +141,4 @@ export declare class ConnectClient {
141
141
  constructor(options: ConnectClientOptions);
142
142
  }
143
143
  /** Kept in step with `package.json` by `sdk.test.ts`. */
144
- export declare const SDK_VERSION = "0.47.0";
144
+ export declare const SDK_VERSION = "0.49.0";
@@ -39,5 +39,18 @@ export interface ConnectHandlerOptions {
39
39
  * if it collides with something you already set.
40
40
  */
41
41
  stateCookieName?: string;
42
+ /**
43
+ * How long, in milliseconds, this handler reuses a resolved session for the
44
+ * routes that only need to know *whose* request it is. Defaults to
45
+ * `SESSION_CACHE_TTL_MS` (15 seconds); `0` turns the cache off. See
46
+ * `createConnectHandler` for exactly what it trades.
47
+ */
48
+ sessionCacheTtlMs?: number;
42
49
  }
50
+ /**
51
+ * The bridge's default session reuse window - the longest a revoked session,
52
+ * a removed member or a sign-out on another device keeps working through a
53
+ * warm instance. Seconds on purpose: this gates a person's access.
54
+ */
55
+ export declare const SESSION_CACHE_TTL_MS = 15000;
43
56
  export declare function createConnectHandler(options: ConnectHandlerOptions): (request: Request) => Promise<Response>;
@@ -1,5 +1,5 @@
1
1
  import { type TransportOptions } from "../http.js";
2
- import type { BrowserCallingToken, CallingAvailability, CallSettings, CallDetail, CallsPage, StartedBrowserCall } from "../../types.js";
2
+ import type { BrowserCallingToken, CallingAvailability, CallSettings, CallingReadiness, CallDetail, CallsPage, ConnectCallPurpose, StartedBrowserCall } from "../../types.js";
3
3
  export interface ListCallsInput {
4
4
  /** Narrow to one channel. A channel belonging to another application simply matches nothing. */
5
5
  channelId?: string;
@@ -75,6 +75,14 @@ export declare class CallsResource {
75
75
  recordingEnabled: boolean;
76
76
  asUser: string;
77
77
  }): Promise<CallSettings>;
78
+ /**
79
+ * Whether the workspace may legally make each kind of call - service or
80
+ * sales, dialled by a rep or by software - and the steps left on Connect's
81
+ * calling compliance journey. Send the member to `setupUrl` to finish them.
82
+ */
83
+ getCallingReadiness(input: {
84
+ asUser: string;
85
+ }): Promise<CallingReadiness>;
78
86
  /** A carrier token the member's browser signs in to the phone network with. */
79
87
  getBrowserCallingToken(input: {
80
88
  asUser: string;
@@ -84,11 +92,13 @@ export declare class CallsResource {
84
92
  *
85
93
  * Throws `ConnectError` with status `412` when the workspace has no active
86
94
  * number, is not allowed to place calls, or the member is already on one -
87
- * the message is written to be shown to them.
95
+ * the message is written to be shown to them. With a `purpose`, also when
96
+ * no number may carry it and the workspace enforces purposes.
88
97
  */
89
98
  startBrowserCall(input: {
90
99
  phone: string;
91
100
  asUser: string;
101
+ purpose?: ConnectCallPurpose;
92
102
  }): Promise<StartedBrowserCall>;
93
103
  /**
94
104
  * Closes a call the browser never managed to place - recorded as
@@ -1,5 +1,5 @@
1
1
  import { type TransportOptions } from "../http.js";
2
- import type { StoreAggregatePage, StoreAggregation, StoreCommitOperation, StoreCommitResult, StoreDocument, StoreQueryFilter, StoreQueryPage, StoreOrderBy, StoreScanPage } from "../../types.js";
2
+ import type { StoreAggregatePage, StoreAggregation, StoreCommitOperation, StoreCommitResult, StoreDocument, StoreGetManyResult, StoreQueryFilter, StoreQueryPage, StoreOrderBy, StoreScanPage } from "../../types.js";
3
3
  /**
4
4
  * Your application's own document store - a dedicated Firestore database that
5
5
  * only Connect can reach.
@@ -51,6 +51,32 @@ export declare class DataResource {
51
51
  id: string;
52
52
  asUser?: string;
53
53
  }): Promise<StoreDocument>;
54
+ /**
55
+ * Reads up to 200 documents of one collection by id, in **one request** -
56
+ * the batch form of `get`.
57
+ *
58
+ * Reach for it wherever you would loop over `get`: the contacts on a page of
59
+ * deals, the plans behind a list of subscriptions. It is gated exactly like
60
+ * `get` and spends one request of your rate limit however many ids it names.
61
+ *
62
+ * ```ts
63
+ * const { items } = await connect.data.getMany({
64
+ * workspaceId,
65
+ * collection: "contacts",
66
+ * ids: deals.map((deal) => deal.data.contactId as string),
67
+ * });
68
+ * // items[i] belongs to ids[i]; null where that contact no longer exists.
69
+ * ```
70
+ *
71
+ * Answers positionally, `null` where a document does not exist - unlike
72
+ * `get`, a missing document does not throw.
73
+ */
74
+ getMany(input: {
75
+ workspaceId?: string;
76
+ collection: string;
77
+ ids: string[];
78
+ asUser?: string;
79
+ }): Promise<StoreGetManyResult>;
54
80
  /**
55
81
  * Lists a collection, one page at a time.
56
82
  *
@@ -1786,6 +1786,14 @@ export interface StoreCommitResult {
1786
1786
  /** The document id each operation wrote, positionally aligned with the operations sent. */
1787
1787
  ids: string[];
1788
1788
  }
1789
+ /** What `data.getMany` answers - see `POST /data-get-many`. */
1790
+ export interface StoreGetManyResult {
1791
+ /**
1792
+ * Positionally aligned with the ids asked for, `null` where a document does
1793
+ * not exist. A missing document is an ordinary answer here, not a 404.
1794
+ */
1795
+ items: Array<StoreDocument | null>;
1796
+ }
1789
1797
  /** One entry from the `kv` store. */
1790
1798
  export interface KvEntry {
1791
1799
  key: string;
@@ -2911,6 +2919,14 @@ export interface ConnectCallAnalysis {
2911
2919
  * carry a one-time key, valid for two minutes, that lets the call be placed
2912
2920
  * once and by nobody else.
2913
2921
  */
2922
+ /**
2923
+ * What a call is for, declared by the caller - Connect places it from a
2924
+ * workspace number allowed to carry it. In India the series decides: regular
2925
+ * numbers carry `service` and `transactional`, 140-series numbers carry
2926
+ * `promotional` only. Omit it and the call is placed as before and recorded
2927
+ * as undeclared.
2928
+ */
2929
+ export type ConnectCallPurpose = "service" | "transactional" | "promotional";
2914
2930
  export interface StartedBrowserCall {
2915
2931
  callId: string;
2916
2932
  /** The number being rung, E.164. */
@@ -2943,6 +2959,45 @@ export interface CallSettings {
2943
2959
  /** Whether this member may change it - their `channel:manage`. Draw the switch disabled when not. */
2944
2960
  canManage: boolean;
2945
2961
  }
2962
+ /** A step of Connect's calling compliance journey - `connect.calls.getCallingReadiness()`. */
2963
+ export interface CallingReadinessStep {
2964
+ key: "intent" | "business" | "number" | "header" | "autodialer" | "consent" | "rules";
2965
+ label: string;
2966
+ status: "not_started" | "submitted" | "returned" | "done";
2967
+ /** Whether the business needs it for the calls it said it makes. */
2968
+ required: boolean;
2969
+ /** A returned step's reason, or what the numbers say. */
2970
+ note: string | null;
2971
+ }
2972
+ /** One kind of calling, and whether the workspace may run it today. */
2973
+ export interface CallingReadinessRun {
2974
+ /** `service` covers existing customers; `promotional` is any sales call. */
2975
+ purpose: "service" | "promotional";
2976
+ /** `preview`: a rep presses dial. `automatic`: software paces the dialing. */
2977
+ mode: "preview" | "automatic";
2978
+ allowed: boolean;
2979
+ /** The first thing in the way, worded for the member. `null` when allowed. */
2980
+ reason: string | null;
2981
+ }
2982
+ /**
2983
+ * Where the workspace stands on the steps Indian telecom rules ask of a
2984
+ * business before it calls - `connect.calls.getCallingReadiness()`. Read
2985
+ * `runs`, not `steps`, to decide what to offer: Connect evaluates them with
2986
+ * the gates the dialer refuses on.
2987
+ */
2988
+ export interface CallingReadiness {
2989
+ intent: "service_only" | "service_and_sales" | null;
2990
+ progress: {
2991
+ done: number;
2992
+ required: number;
2993
+ };
2994
+ steps: CallingReadinessStep[];
2995
+ runs: CallingReadinessRun[];
2996
+ /** Connect's journey screen, carrying your `client_id` when SSO is on. */
2997
+ setupUrl: string;
2998
+ /** Whether this member may take the steps - their `channel:manage`. */
2999
+ canManage: boolean;
3000
+ }
2946
3001
  export interface CallingAvailability {
2947
3002
  status: CallingAvailabilityStatus;
2948
3003
  /** The workspace's business number, E.164, when it holds one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",