@cosmoops/connect-sdk 0.48.0 → 0.50.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,33 @@
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.50.0
7
+
8
+ ### Background tasks
9
+
10
+ `connect.tasks.enqueue({ workspaceId, queue, payload, name?, scheduleAt? })`
11
+ puts a task on one of your application's Cloud Tasks queues, declared in
12
+ Console (scope `tasks:write`). Cloud Tasks delivers `{ workspaceId, queue,
13
+ payload }` to the queue's URL with a Google-signed OIDC token;
14
+ `createConnectTaskHandler({ handle })` - or `verifyConnectTask(request)` -
15
+ from `./server` or `./next` verifies it. Set `CONNECT_TASK_INVOKER` to the
16
+ invoker account the platform gives you. `name` dedups per workspace;
17
+ `status: "unavailable"` means the Connect deployment has no queue, so keep a
18
+ fallback. Additive; nothing else changed.
19
+
20
+ ## 0.49.0
21
+
22
+ ### Calling readiness
23
+
24
+ `connect.calls.getCallingReadiness({ asUser })` answers whether the
25
+ workspace may legally make each kind of call - `runs` covers service and
26
+ sales (`promotional`) calls, dialled by a rep (`preview`) or by software
27
+ (`automatic`), each with the first `reason` in the way - plus the steps left
28
+ on Connect's calling compliance journey and a `setupUrl` to finish them.
29
+ Member-only, on the `calls:place` scope. Read `runs` rather than deriving
30
+ anything from `steps`: Connect evaluates them with the gates its dialer
31
+ refuses on. Additive; nothing else changed.
32
+
6
33
  ## 0.48.0
7
34
 
8
35
  ### Calls declare a purpose
@@ -140,7 +140,7 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
140
140
  // card used to do. The description rides along as a
141
141
  // title so a feature that needs explaining can have one
142
142
  // without a second line in every card.
143
- (0, jsx_runtime_1.jsxs)("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-foreground", children: [addon.name, addon.consumable && ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), (0, jsx_runtime_1.jsx)("span", { className: "shrink-0 text-muted", children: (0, plan_pricing_js_1.formatPrice)(addon.price) })] }, addon.id)))] })), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
143
+ (0, jsx_runtime_1.jsxs)("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-foreground", children: [addon.name, addon.consumable && ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), (0, jsx_runtime_1.jsxs)("span", { className: "shrink-0 text-muted", children: [(0, plan_pricing_js_1.formatPrice)(addon.price), addon.billingCadence === "annual" && "/year"] })] }, addon.id)))] })), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
144
144
  `${plan.seats.included ?? 0} seats included, then ${(0, plan_pricing_js_1.formatPrice)(plan.seats.pricePerExtraSeat ?? 0)} each`] })] }), (0, jsx_runtime_1.jsx)(react_1.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_1.Button, { className: "w-full", variant: action === "upgrade" || action === "subscribe" || action === "switch-to-annual"
145
145
  ? "primary"
146
146
  : "secondary",
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.ConnectClient = exports.createConnectHandler = void 0;
37
+ exports.ConnectTaskVerificationError = exports.createConnectTaskHandler = exports.verifyConnectTask = exports.ConnectClient = exports.createConnectHandler = void 0;
38
38
  exports.connectClient = connectClient;
39
39
  exports.connectProviderProps = connectProviderProps;
40
40
  exports.cookieSessionStore = cookieSessionStore;
@@ -486,3 +486,7 @@ var handler_js_2 = require("../server/handler.js");
486
486
  Object.defineProperty(exports, "createConnectHandler", { enumerable: true, get: function () { return handler_js_2.createConnectHandler; } });
487
487
  var client_js_2 = require("../server/client.js");
488
488
  Object.defineProperty(exports, "ConnectClient", { enumerable: true, get: function () { return client_js_2.ConnectClient; } });
489
+ var tasks_js_1 = require("../server/tasks.js");
490
+ Object.defineProperty(exports, "verifyConnectTask", { enumerable: true, get: function () { return tasks_js_1.verifyConnectTask; } });
491
+ Object.defineProperty(exports, "createConnectTaskHandler", { enumerable: true, get: function () { return tasks_js_1.createConnectTaskHandler; } });
492
+ Object.defineProperty(exports, "ConnectTaskVerificationError", { enumerable: true, get: function () { return tasks_js_1.ConnectTaskVerificationError; } });
@@ -25,6 +25,7 @@ const sessions_js_1 = require("./resources/sessions.js");
25
25
  const platform_js_1 = require("./resources/platform.js");
26
26
  const visitor_js_1 = require("./resources/visitor.js");
27
27
  const push_js_1 = require("./resources/push.js");
28
+ const tasks_js_1 = require("./resources/tasks.js");
28
29
  const whatsapp_js_1 = require("./resources/whatsapp.js");
29
30
  exports.DEFAULT_BASE_URL = "https://connect.cosmoops.com";
30
31
  const APPLICATION_API_PREFIX = "/api/v1/application";
@@ -129,6 +130,8 @@ class ConnectClient {
129
130
  * preferences; you supply a title, a body and a URL. See `PushResource`.
130
131
  */
131
132
  push;
133
+ /** Background tasks on this application's declared Cloud Tasks queues - see `TasksResource`. */
134
+ tasks;
132
135
  /** Your application's own approved WhatsApp templates, to a workspace's customers. */
133
136
  whatsapp;
134
137
  /**
@@ -177,10 +180,11 @@ class ConnectClient {
177
180
  this.visitor = new visitor_js_1.VisitorResource(transport);
178
181
  this.platform = new platform_js_1.PlatformResource(transport);
179
182
  this.push = new push_js_1.PushResource(transport);
183
+ this.tasks = new tasks_js_1.TasksResource(transport);
180
184
  this.whatsapp = new whatsapp_js_1.WhatsAppResource(transport);
181
185
  this.storeRelay = (spec) => (0, data_js_1.storePassthrough)(transport, spec);
182
186
  }
183
187
  }
184
188
  exports.ConnectClient = ConnectClient;
185
189
  /** Kept in step with `package.json` by `sdk.test.ts`. */
186
- exports.SDK_VERSION = "0.48.0";
190
+ exports.SDK_VERSION = "0.50.0";
@@ -1,10 +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");
6
7
  /** The purposes `/calls/start` accepts - see `ConnectCallPurpose`. */
7
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;
8
17
  /**
9
18
  * A sentinel origin to resolve a candidate destination against. Never reached -
10
19
  * `.invalid` is reserved by RFC 2606 precisely so it cannot resolve.
@@ -310,9 +319,71 @@ function createConnectHandler(options) {
310
319
  }
311
320
  return token;
312
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
+ }
313
384
  /** Signed-in identity plus the workspace it belongs to - the first read every other route needs. */
314
385
  async function whoami(request) {
315
- return client.identity.getUser(await requireToken(request));
386
+ return resolveSession(await requireToken(request));
316
387
  }
317
388
  /**
318
389
  * The caller's identity, plus the workspace every tenant-scoped route below
@@ -346,7 +417,8 @@ function createConnectHandler(options) {
346
417
  /**
347
418
  * The store paths a browser may reach through the bridge.
348
419
  *
349
- * 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.
350
422
  * **`/data-scan/` is deliberately absent**: a scan crosses every tenant, and
351
423
  * Connect refuses one made on behalf of a user - but a browser should not be
352
424
  * able to *ask*, so the refusal happens here rather than a round trip away.
@@ -356,7 +428,8 @@ function createConnectHandler(options) {
356
428
  return (route.startsWith("/data/") ||
357
429
  route.startsWith("/kv/") ||
358
430
  route.startsWith("/data-aggregate/") ||
359
- route === "/data-commit");
431
+ route === "/data-commit" ||
432
+ route === "/data-get-many");
360
433
  }
361
434
  /**
362
435
  * Forwards one store call to Connect on behalf of the caller.
@@ -439,7 +512,7 @@ function createConnectHandler(options) {
439
512
  clientId,
440
513
  });
441
514
  }
442
- return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
515
+ return json({ ...(await resolveSessionFresh(token)), oneTap: oneTapEndpoints, clientId });
443
516
  },
444
517
  "GET /entitlements": async (request) => {
445
518
  const { workspaceId } = await requireWorkspace(request);
@@ -850,6 +923,9 @@ function createConnectHandler(options) {
850
923
  });
851
924
  }
852
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);
853
929
  // The response is built first so `session.write` has something to set
854
930
  // cookies on, matching how `/callback` does it.
855
931
  const response = json({
@@ -1502,6 +1578,8 @@ function createConnectHandler(options) {
1502
1578
  },
1503
1579
  "POST /logout": async (request) => {
1504
1580
  const token = await session.read(request);
1581
+ if (token)
1582
+ sessionCache.delete(token);
1505
1583
  const response = json({ ok: true });
1506
1584
  await session.clear?.(request, response);
1507
1585
  // Connect is told last, and a failure is swallowed: the user's own
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.verifyConnectWebhook = exports.createConnectWebhookHandler = exports.createState = exports.createConnectHandler = exports.SDK_VERSION = exports.DEFAULT_BASE_URL = exports.ConnectClient = void 0;
3
+ exports.ConnectTaskVerificationError = exports.createConnectTaskHandler = exports.verifyConnectTask = exports.verifyConnectWebhook = exports.createConnectWebhookHandler = exports.createState = exports.createConnectHandler = exports.SDK_VERSION = exports.DEFAULT_BASE_URL = exports.ConnectClient = void 0;
4
4
  /**
5
5
  * Server-only half of `@cosmoops/connect-sdk`.
6
6
  *
@@ -23,3 +23,11 @@ Object.defineProperty(exports, "createState", { enumerable: true, get: function
23
23
  var webhooks_js_1 = require("./webhooks.js");
24
24
  Object.defineProperty(exports, "createConnectWebhookHandler", { enumerable: true, get: function () { return webhooks_js_1.createConnectWebhookHandler; } });
25
25
  Object.defineProperty(exports, "verifyConnectWebhook", { enumerable: true, get: function () { return webhooks_js_1.verifyConnectWebhook; } });
26
+ /**
27
+ * Receiving a Connect task - verify Google's OIDC token, then run. See
28
+ * `TasksResource` for the sending half.
29
+ */
30
+ var tasks_js_1 = require("./tasks.js");
31
+ Object.defineProperty(exports, "verifyConnectTask", { enumerable: true, get: function () { return tasks_js_1.verifyConnectTask; } });
32
+ Object.defineProperty(exports, "createConnectTaskHandler", { enumerable: true, get: function () { return tasks_js_1.createConnectTaskHandler; } });
33
+ Object.defineProperty(exports, "ConnectTaskVerificationError", { enumerable: true, get: function () { return tasks_js_1.ConnectTaskVerificationError; } });
@@ -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, {
@@ -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()`
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TasksResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ /**
6
+ * Background work, delivered back to your own endpoint by Google Cloud Tasks.
7
+ *
8
+ * Declare a queue in Console - its URL, its rate, its retries - then enqueue
9
+ * here. Cloud Tasks `POST`s `{ workspaceId, queue, payload }` to the queue's
10
+ * URL with a Google-signed OIDC token; verify it with `verifyConnectTask` (or
11
+ * let `createConnectTaskHandler` do it) before trusting a byte of the body.
12
+ *
13
+ * Deliveries are **at least once**. Make each step safe to run twice, and
14
+ * give each enqueue a `name` derived from the step so a retried enqueue is a
15
+ * `duplicate` rather than a second task.
16
+ */
17
+ class TasksResource {
18
+ transport;
19
+ constructor(transport) {
20
+ this.transport = transport;
21
+ }
22
+ enqueue(input) {
23
+ return (0, http_js_1.request)(this.transport, { method: "POST", path: "/tasks", json: input });
24
+ }
25
+ }
26
+ exports.TasksResource = TasksResource;
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConnectTaskVerificationError = void 0;
4
+ exports.clearConnectTaskKeyCache = clearConnectTaskKeyCache;
5
+ exports.verifyConnectTask = verifyConnectTask;
6
+ exports.createConnectTaskHandler = createConnectTaskHandler;
7
+ /**
8
+ * Receiving a task Connect enqueued: verify, then run.
9
+ *
10
+ * Cloud Tasks calls your queue's URL directly - not through Connect - with a
11
+ * bearer **OIDC token Google signs** for Connect's invoker service account.
12
+ * Three claims make a request a genuine delivery, and all three are needed:
13
+ *
14
+ * - the signature, against Google's published keys - or anyone can mint one;
15
+ * - `email`, the invoker account - or any Google-signed token for any
16
+ * service account, including one an attacker owns, would pass;
17
+ * - `aud`, your own origin - or a token minted for somebody else's endpoint
18
+ * could be replayed against yours.
19
+ *
20
+ * Checked with WebCrypto, so this needs no JWT library and runs on Node and
21
+ * on edge runtimes alike.
22
+ */
23
+ const GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
24
+ const GOOGLE_ISSUERS = new Set(["https://accounts.google.com", "accounts.google.com"]);
25
+ /** Clock skew tolerated between Google and this server. */
26
+ const SKEW_MS = 60_000;
27
+ const DEFAULT_CERTS_TTL_MS = 60 * 60 * 1000;
28
+ class ConnectTaskVerificationError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "ConnectTaskVerificationError";
32
+ }
33
+ }
34
+ exports.ConnectTaskVerificationError = ConnectTaskVerificationError;
35
+ let certsCache = null;
36
+ /** Test seam - drops the cached Google keys. */
37
+ function clearConnectTaskKeyCache() {
38
+ certsCache = null;
39
+ }
40
+ async function googleKeys(fetchImpl, now, force) {
41
+ if (!force && certsCache && certsCache.expiresAt > now)
42
+ return certsCache.keys;
43
+ const response = await fetchImpl(GOOGLE_CERTS_URL, { method: "GET" });
44
+ if (!response.ok)
45
+ throw new ConnectTaskVerificationError(`Could not fetch Google's signing keys (${response.status}).`);
46
+ const body = (await response.json());
47
+ const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "")?.[1];
48
+ certsCache = {
49
+ keys: body.keys ?? [],
50
+ expiresAt: now + (maxAge ? Number(maxAge) * 1000 : DEFAULT_CERTS_TTL_MS),
51
+ };
52
+ return certsCache.keys;
53
+ }
54
+ function base64UrlToBytes(segment) {
55
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
56
+ const binary = atob(base64);
57
+ const bytes = new Uint8Array(binary.length);
58
+ for (let i = 0; i < binary.length; i += 1)
59
+ bytes[i] = binary.charCodeAt(i);
60
+ return bytes;
61
+ }
62
+ function decodeSegment(segment) {
63
+ try {
64
+ return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
65
+ }
66
+ catch {
67
+ throw new ConnectTaskVerificationError("The token is not a JWT.");
68
+ }
69
+ }
70
+ /**
71
+ * Verify a delivery and return it, or throw `ConnectTaskVerificationError`.
72
+ *
73
+ * Reads the body, so call it once and use what it returns.
74
+ */
75
+ async function verifyConnectTask(request, options = {}) {
76
+ const invoker = options.invoker ?? process.env.CONNECT_TASK_INVOKER;
77
+ if (!invoker) {
78
+ throw new ConnectTaskVerificationError("No invoker configured - set CONNECT_TASK_INVOKER to the invoker service account the platform gave you.");
79
+ }
80
+ const audience = options.audience ?? process.env.CONNECT_TASK_AUDIENCE ?? new URL(request.url).origin;
81
+ const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
82
+ const now = (options.now ?? Date.now)();
83
+ const header = request.headers.get("authorization") ?? "";
84
+ const token = /^Bearer\s+(.+)$/i.exec(header)?.[1];
85
+ if (!token)
86
+ throw new ConnectTaskVerificationError("No bearer token.");
87
+ const parts = token.split(".");
88
+ if (parts.length !== 3)
89
+ throw new ConnectTaskVerificationError("The token is not a JWT.");
90
+ const [headerSegment, claimsSegment, signatureSegment] = parts;
91
+ const jwtHeader = decodeSegment(headerSegment);
92
+ if (jwtHeader.alg !== "RS256" || typeof jwtHeader.kid !== "string") {
93
+ throw new ConnectTaskVerificationError("Unexpected token algorithm.");
94
+ }
95
+ // An unknown `kid` is usually a rotation the cache has not seen - refetch once.
96
+ let jwk = (await googleKeys(fetchImpl, now, false)).find((key) => key.kid === jwtHeader.kid);
97
+ if (!jwk)
98
+ jwk = (await googleKeys(fetchImpl, now, true)).find((key) => key.kid === jwtHeader.kid);
99
+ if (!jwk)
100
+ throw new ConnectTaskVerificationError("The token was not signed by a current Google key.");
101
+ const key = await crypto.subtle.importKey("jwk", { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
102
+ const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, base64UrlToBytes(signatureSegment), new TextEncoder().encode(`${headerSegment}.${claimsSegment}`));
103
+ if (!valid)
104
+ throw new ConnectTaskVerificationError("The token's signature does not verify.");
105
+ const claims = decodeSegment(claimsSegment);
106
+ if (typeof claims.iss !== "string" || !GOOGLE_ISSUERS.has(claims.iss)) {
107
+ throw new ConnectTaskVerificationError("The token was not issued by Google.");
108
+ }
109
+ if (claims.aud !== audience)
110
+ throw new ConnectTaskVerificationError("The token was minted for another audience.");
111
+ if (claims.email !== invoker || claims.email_verified !== true) {
112
+ throw new ConnectTaskVerificationError("The token is not Connect's invoker.");
113
+ }
114
+ if (typeof claims.exp !== "number" || claims.exp * 1000 < now - SKEW_MS) {
115
+ throw new ConnectTaskVerificationError("The token has expired.");
116
+ }
117
+ if (typeof claims.iat === "number" && claims.iat * 1000 > now + SKEW_MS) {
118
+ throw new ConnectTaskVerificationError("The token is from the future.");
119
+ }
120
+ let body;
121
+ try {
122
+ body = (await request.json());
123
+ }
124
+ catch {
125
+ throw new ConnectTaskVerificationError("The delivery body is not JSON.");
126
+ }
127
+ if (typeof body.workspaceId !== "string" || typeof body.queue !== "string") {
128
+ throw new ConnectTaskVerificationError("The delivery body is not a Connect task.");
129
+ }
130
+ return {
131
+ workspaceId: body.workspaceId,
132
+ queue: body.queue,
133
+ payload: body.payload,
134
+ retryCount: Number(request.headers.get("x-cloudtasks-taskretrycount") ?? 0) || 0,
135
+ taskName: request.headers.get("x-cloudtasks-taskname"),
136
+ };
137
+ }
138
+ /**
139
+ * A ready-made `POST` route for a queue's URL:
140
+ *
141
+ * ```ts
142
+ * // app/api/tasks/campaign/route.ts
143
+ * export const POST = createConnectTaskHandler<{ campaignId: string }>({
144
+ * handle: async ({ workspaceId, payload }) => runCampaignPage(workspaceId, payload.campaignId),
145
+ * });
146
+ * ```
147
+ *
148
+ * `401` for anything that does not verify, `500` when `handle` throws (or
149
+ * Google's keys cannot be fetched), `200` otherwise.
150
+ */
151
+ function createConnectTaskHandler(options) {
152
+ const { handle, ...verifyOptions } = options;
153
+ return async (request) => {
154
+ let delivery;
155
+ try {
156
+ delivery = await verifyConnectTask(request, verifyOptions);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof ConnectTaskVerificationError) {
160
+ return Response.json({ error: error.message }, { status: 401 });
161
+ }
162
+ // Google's keys unreachable, say - not the sender's fault, so retryable.
163
+ console.error("[connect-task] could not verify a delivery:", error);
164
+ return Response.json({ error: "Could not verify the delivery." }, { status: 500 });
165
+ }
166
+ try {
167
+ await handle(delivery);
168
+ }
169
+ catch (error) {
170
+ console.error(`[connect-task] ${delivery.queue} failed (attempt ${delivery.retryCount + 1}):`, error);
171
+ return Response.json({ error: "Task failed - Cloud Tasks will retry it." }, { status: 500 });
172
+ }
173
+ return Response.json({ ok: true });
174
+ };
175
+ }
@@ -137,7 +137,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
137
137
  // card used to do. The description rides along as a
138
138
  // title so a feature that needs explaining can have one
139
139
  // without a second line in every card.
140
- _jsxs("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [_jsx(Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), _jsx("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && (_jsxs("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => (_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsxs("span", { className: "text-foreground", children: [addon.name, addon.consumable && (_jsxs("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), _jsx("span", { className: "shrink-0 text-muted", children: formatPrice(addon.price) })] }, addon.id)))] })), _jsxs("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
140
+ _jsxs("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [_jsx(Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), _jsx("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && (_jsxs("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => (_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsxs("span", { className: "text-foreground", children: [addon.name, addon.consumable && (_jsxs("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), _jsxs("span", { className: "shrink-0 text-muted", children: [formatPrice(addon.price), addon.billingCadence === "annual" && "/year"] })] }, addon.id)))] })), _jsxs("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
141
141
  `${plan.seats.included ?? 0} seats included, then ${formatPrice(plan.seats.pricePerExtraSeat ?? 0)} each`] })] }), _jsx(Card.Footer, { children: _jsxs(Button, { className: "w-full", variant: action === "upgrade" || action === "subscribe" || action === "switch-to-annual"
142
142
  ? "primary"
143
143
  : "secondary",
@@ -452,3 +452,4 @@ async function redirectTo(url) {
452
452
  }
453
453
  export { createConnectHandler } from "../server/handler.js";
454
454
  export { ConnectClient } from "../server/client.js";
455
+ export { verifyConnectTask, createConnectTaskHandler, ConnectTaskVerificationError } from "../server/tasks.js";
@@ -22,6 +22,7 @@ import { SessionsResource } from "./resources/sessions.js";
22
22
  import { PlatformResource } from "./resources/platform.js";
23
23
  import { VisitorResource } from "./resources/visitor.js";
24
24
  import { PushResource } from "./resources/push.js";
25
+ import { TasksResource } from "./resources/tasks.js";
25
26
  import { WhatsAppResource } from "./resources/whatsapp.js";
26
27
  export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
27
28
  const APPLICATION_API_PREFIX = "/api/v1/application";
@@ -126,6 +127,8 @@ export class ConnectClient {
126
127
  * preferences; you supply a title, a body and a URL. See `PushResource`.
127
128
  */
128
129
  push;
130
+ /** Background tasks on this application's declared Cloud Tasks queues - see `TasksResource`. */
131
+ tasks;
129
132
  /** Your application's own approved WhatsApp templates, to a workspace's customers. */
130
133
  whatsapp;
131
134
  /**
@@ -174,9 +177,10 @@ export class ConnectClient {
174
177
  this.visitor = new VisitorResource(transport);
175
178
  this.platform = new PlatformResource(transport);
176
179
  this.push = new PushResource(transport);
180
+ this.tasks = new TasksResource(transport);
177
181
  this.whatsapp = new WhatsAppResource(transport);
178
182
  this.storeRelay = (spec) => storePassthrough(transport, spec);
179
183
  }
180
184
  }
181
185
  /** Kept in step with `package.json` by `sdk.test.ts`. */
182
- export const SDK_VERSION = "0.48.0";
186
+ export const SDK_VERSION = "0.50.0";