@cosmoops/connect-sdk 0.49.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,20 @@
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
+
6
20
  ## 0.49.0
7
21
 
8
22
  ### Calling readiness
@@ -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.49.0";
190
+ exports.SDK_VERSION = "0.50.0";
@@ -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; } });
@@ -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.49.0";
186
+ export const SDK_VERSION = "0.50.0";
@@ -13,3 +13,8 @@ export { createState } from "./resources/identity.js";
13
13
  * obvious reason: the signing secret must never reach a browser bundle.
14
14
  */
15
15
  export { createConnectWebhookHandler, verifyConnectWebhook, } from "./webhooks.js";
16
+ /**
17
+ * Receiving a Connect task - verify Google's OIDC token, then run. See
18
+ * `TasksResource` for the sending half.
19
+ */
20
+ export { verifyConnectTask, createConnectTaskHandler, ConnectTaskVerificationError, } from "./tasks.js";
@@ -0,0 +1,22 @@
1
+ import { request } from "../http.js";
2
+ /**
3
+ * Background work, delivered back to your own endpoint by Google Cloud Tasks.
4
+ *
5
+ * Declare a queue in Console - its URL, its rate, its retries - then enqueue
6
+ * here. Cloud Tasks `POST`s `{ workspaceId, queue, payload }` to the queue's
7
+ * URL with a Google-signed OIDC token; verify it with `verifyConnectTask` (or
8
+ * let `createConnectTaskHandler` do it) before trusting a byte of the body.
9
+ *
10
+ * Deliveries are **at least once**. Make each step safe to run twice, and
11
+ * give each enqueue a `name` derived from the step so a retried enqueue is a
12
+ * `duplicate` rather than a second task.
13
+ */
14
+ export class TasksResource {
15
+ transport;
16
+ constructor(transport) {
17
+ this.transport = transport;
18
+ }
19
+ enqueue(input) {
20
+ return request(this.transport, { method: "POST", path: "/tasks", json: input });
21
+ }
22
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Receiving a task Connect enqueued: verify, then run.
3
+ *
4
+ * Cloud Tasks calls your queue's URL directly - not through Connect - with a
5
+ * bearer **OIDC token Google signs** for Connect's invoker service account.
6
+ * Three claims make a request a genuine delivery, and all three are needed:
7
+ *
8
+ * - the signature, against Google's published keys - or anyone can mint one;
9
+ * - `email`, the invoker account - or any Google-signed token for any
10
+ * service account, including one an attacker owns, would pass;
11
+ * - `aud`, your own origin - or a token minted for somebody else's endpoint
12
+ * could be replayed against yours.
13
+ *
14
+ * Checked with WebCrypto, so this needs no JWT library and runs on Node and
15
+ * on edge runtimes alike.
16
+ */
17
+ const GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
18
+ const GOOGLE_ISSUERS = new Set(["https://accounts.google.com", "accounts.google.com"]);
19
+ /** Clock skew tolerated between Google and this server. */
20
+ const SKEW_MS = 60_000;
21
+ const DEFAULT_CERTS_TTL_MS = 60 * 60 * 1000;
22
+ export class ConnectTaskVerificationError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "ConnectTaskVerificationError";
26
+ }
27
+ }
28
+ let certsCache = null;
29
+ /** Test seam - drops the cached Google keys. */
30
+ export function clearConnectTaskKeyCache() {
31
+ certsCache = null;
32
+ }
33
+ async function googleKeys(fetchImpl, now, force) {
34
+ if (!force && certsCache && certsCache.expiresAt > now)
35
+ return certsCache.keys;
36
+ const response = await fetchImpl(GOOGLE_CERTS_URL, { method: "GET" });
37
+ if (!response.ok)
38
+ throw new ConnectTaskVerificationError(`Could not fetch Google's signing keys (${response.status}).`);
39
+ const body = (await response.json());
40
+ const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "")?.[1];
41
+ certsCache = {
42
+ keys: body.keys ?? [],
43
+ expiresAt: now + (maxAge ? Number(maxAge) * 1000 : DEFAULT_CERTS_TTL_MS),
44
+ };
45
+ return certsCache.keys;
46
+ }
47
+ function base64UrlToBytes(segment) {
48
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
49
+ const binary = atob(base64);
50
+ const bytes = new Uint8Array(binary.length);
51
+ for (let i = 0; i < binary.length; i += 1)
52
+ bytes[i] = binary.charCodeAt(i);
53
+ return bytes;
54
+ }
55
+ function decodeSegment(segment) {
56
+ try {
57
+ return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
58
+ }
59
+ catch {
60
+ throw new ConnectTaskVerificationError("The token is not a JWT.");
61
+ }
62
+ }
63
+ /**
64
+ * Verify a delivery and return it, or throw `ConnectTaskVerificationError`.
65
+ *
66
+ * Reads the body, so call it once and use what it returns.
67
+ */
68
+ export async function verifyConnectTask(request, options = {}) {
69
+ const invoker = options.invoker ?? process.env.CONNECT_TASK_INVOKER;
70
+ if (!invoker) {
71
+ throw new ConnectTaskVerificationError("No invoker configured - set CONNECT_TASK_INVOKER to the invoker service account the platform gave you.");
72
+ }
73
+ const audience = options.audience ?? process.env.CONNECT_TASK_AUDIENCE ?? new URL(request.url).origin;
74
+ const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
75
+ const now = (options.now ?? Date.now)();
76
+ const header = request.headers.get("authorization") ?? "";
77
+ const token = /^Bearer\s+(.+)$/i.exec(header)?.[1];
78
+ if (!token)
79
+ throw new ConnectTaskVerificationError("No bearer token.");
80
+ const parts = token.split(".");
81
+ if (parts.length !== 3)
82
+ throw new ConnectTaskVerificationError("The token is not a JWT.");
83
+ const [headerSegment, claimsSegment, signatureSegment] = parts;
84
+ const jwtHeader = decodeSegment(headerSegment);
85
+ if (jwtHeader.alg !== "RS256" || typeof jwtHeader.kid !== "string") {
86
+ throw new ConnectTaskVerificationError("Unexpected token algorithm.");
87
+ }
88
+ // An unknown `kid` is usually a rotation the cache has not seen - refetch once.
89
+ let jwk = (await googleKeys(fetchImpl, now, false)).find((key) => key.kid === jwtHeader.kid);
90
+ if (!jwk)
91
+ jwk = (await googleKeys(fetchImpl, now, true)).find((key) => key.kid === jwtHeader.kid);
92
+ if (!jwk)
93
+ throw new ConnectTaskVerificationError("The token was not signed by a current Google key.");
94
+ 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"]);
95
+ const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, base64UrlToBytes(signatureSegment), new TextEncoder().encode(`${headerSegment}.${claimsSegment}`));
96
+ if (!valid)
97
+ throw new ConnectTaskVerificationError("The token's signature does not verify.");
98
+ const claims = decodeSegment(claimsSegment);
99
+ if (typeof claims.iss !== "string" || !GOOGLE_ISSUERS.has(claims.iss)) {
100
+ throw new ConnectTaskVerificationError("The token was not issued by Google.");
101
+ }
102
+ if (claims.aud !== audience)
103
+ throw new ConnectTaskVerificationError("The token was minted for another audience.");
104
+ if (claims.email !== invoker || claims.email_verified !== true) {
105
+ throw new ConnectTaskVerificationError("The token is not Connect's invoker.");
106
+ }
107
+ if (typeof claims.exp !== "number" || claims.exp * 1000 < now - SKEW_MS) {
108
+ throw new ConnectTaskVerificationError("The token has expired.");
109
+ }
110
+ if (typeof claims.iat === "number" && claims.iat * 1000 > now + SKEW_MS) {
111
+ throw new ConnectTaskVerificationError("The token is from the future.");
112
+ }
113
+ let body;
114
+ try {
115
+ body = (await request.json());
116
+ }
117
+ catch {
118
+ throw new ConnectTaskVerificationError("The delivery body is not JSON.");
119
+ }
120
+ if (typeof body.workspaceId !== "string" || typeof body.queue !== "string") {
121
+ throw new ConnectTaskVerificationError("The delivery body is not a Connect task.");
122
+ }
123
+ return {
124
+ workspaceId: body.workspaceId,
125
+ queue: body.queue,
126
+ payload: body.payload,
127
+ retryCount: Number(request.headers.get("x-cloudtasks-taskretrycount") ?? 0) || 0,
128
+ taskName: request.headers.get("x-cloudtasks-taskname"),
129
+ };
130
+ }
131
+ /**
132
+ * A ready-made `POST` route for a queue's URL:
133
+ *
134
+ * ```ts
135
+ * // app/api/tasks/campaign/route.ts
136
+ * export const POST = createConnectTaskHandler<{ campaignId: string }>({
137
+ * handle: async ({ workspaceId, payload }) => runCampaignPage(workspaceId, payload.campaignId),
138
+ * });
139
+ * ```
140
+ *
141
+ * `401` for anything that does not verify, `500` when `handle` throws (or
142
+ * Google's keys cannot be fetched), `200` otherwise.
143
+ */
144
+ export function createConnectTaskHandler(options) {
145
+ const { handle, ...verifyOptions } = options;
146
+ return async (request) => {
147
+ let delivery;
148
+ try {
149
+ delivery = await verifyConnectTask(request, verifyOptions);
150
+ }
151
+ catch (error) {
152
+ if (error instanceof ConnectTaskVerificationError) {
153
+ return Response.json({ error: error.message }, { status: 401 });
154
+ }
155
+ // Google's keys unreachable, say - not the sender's fault, so retryable.
156
+ console.error("[connect-task] could not verify a delivery:", error);
157
+ return Response.json({ error: "Could not verify the delivery." }, { status: 500 });
158
+ }
159
+ try {
160
+ await handle(delivery);
161
+ }
162
+ catch (error) {
163
+ console.error(`[connect-task] ${delivery.queue} failed (attempt ${delivery.retryCount + 1}):`, error);
164
+ return Response.json({ error: "Task failed - Cloud Tasks will retry it." }, { status: 500 });
165
+ }
166
+ return Response.json({ ok: true });
167
+ };
168
+ }
@@ -237,3 +237,4 @@ export declare function requireConnectSession(request?: Request, options?: {
237
237
  export { createConnectHandler } from "../server/handler.js";
238
238
  export type { ConnectSessionStore } from "../server/handler.js";
239
239
  export { ConnectClient } from "../server/client.js";
240
+ export { verifyConnectTask, createConnectTaskHandler, ConnectTaskVerificationError } from "../server/tasks.js";
@@ -23,6 +23,7 @@ import { SessionsResource } from "./resources/sessions.js";
23
23
  import { PlatformResource } from "./resources/platform.js";
24
24
  import { VisitorResource } from "./resources/visitor.js";
25
25
  import { PushResource } from "./resources/push.js";
26
+ import { TasksResource } from "./resources/tasks.js";
26
27
  import { WhatsAppResource } from "./resources/whatsapp.js";
27
28
  export declare const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
28
29
  export interface ConnectClientOptions {
@@ -125,6 +126,8 @@ export declare class ConnectClient {
125
126
  * preferences; you supply a title, a body and a URL. See `PushResource`.
126
127
  */
127
128
  readonly push: PushResource;
129
+ /** Background tasks on this application's declared Cloud Tasks queues - see `TasksResource`. */
130
+ readonly tasks: TasksResource;
128
131
  /** Your application's own approved WhatsApp templates, to a workspace's customers. */
129
132
  readonly whatsapp: WhatsAppResource;
130
133
  /**
@@ -141,4 +144,4 @@ export declare class ConnectClient {
141
144
  constructor(options: ConnectClientOptions);
142
145
  }
143
146
  /** Kept in step with `package.json` by `sdk.test.ts`. */
144
- export declare const SDK_VERSION = "0.49.0";
147
+ export declare const SDK_VERSION = "0.50.0";
@@ -25,6 +25,7 @@ export type { WorkspacesResource } from "./resources/workspaces.js";
25
25
  export type { OtpResource } from "./resources/otp.js";
26
26
  export type { LogsResource } from "./resources/logs.js";
27
27
  export type { AnalyticsResource } from "./resources/analytics.js";
28
+ export type { TasksResource } from "./resources/tasks.js";
28
29
  export type { SessionsResource } from "./resources/sessions.js";
29
30
  export type { ExecuteIntegrationActionInput, InitiateIntegrationConnectionInput, IntegrationsResource, ProxyIntegrationRequestInput, } from "./resources/integrations.js";
30
31
  export type { PlatformResource } from "./resources/platform.js";
@@ -37,3 +38,9 @@ export type { CallsResource, ListCallsInput } from "./resources/calls.js";
37
38
  * obvious reason: the signing secret must never reach a browser bundle.
38
39
  */
39
40
  export { createConnectWebhookHandler, verifyConnectWebhook, type ConnectWebhookHandlers, type ConnectWebhookHandlerOptions, } from "./webhooks.js";
41
+ /**
42
+ * Receiving a Connect task - verify Google's OIDC token, then run. See
43
+ * `TasksResource` for the sending half.
44
+ */
45
+ export { verifyConnectTask, createConnectTaskHandler, ConnectTaskVerificationError, } from "./tasks.js";
46
+ export type { VerifyConnectTaskOptions, ConnectTaskHandlerOptions } from "./tasks.js";
@@ -0,0 +1,19 @@
1
+ import { type TransportOptions } from "../http.js";
2
+ import type { EnqueueTaskInput, EnqueueTaskResponse } from "../../types.js";
3
+ /**
4
+ * Background work, delivered back to your own endpoint by Google Cloud Tasks.
5
+ *
6
+ * Declare a queue in Console - its URL, its rate, its retries - then enqueue
7
+ * here. Cloud Tasks `POST`s `{ workspaceId, queue, payload }` to the queue's
8
+ * URL with a Google-signed OIDC token; verify it with `verifyConnectTask` (or
9
+ * let `createConnectTaskHandler` do it) before trusting a byte of the body.
10
+ *
11
+ * Deliveries are **at least once**. Make each step safe to run twice, and
12
+ * give each enqueue a `name` derived from the step so a retried enqueue is a
13
+ * `duplicate` rather than a second task.
14
+ */
15
+ export declare class TasksResource {
16
+ private readonly transport;
17
+ constructor(transport: TransportOptions);
18
+ enqueue(input: EnqueueTaskInput): Promise<EnqueueTaskResponse>;
19
+ }
@@ -0,0 +1,51 @@
1
+ import type { FetchLike } from "./http.js";
2
+ import type { ConnectTaskDelivery } from "../types.js";
3
+ export declare class ConnectTaskVerificationError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ export interface VerifyConnectTaskOptions {
7
+ /**
8
+ * The invoker service account's email - the one the platform gives you
9
+ * with your API key. Defaults to `CONNECT_TASK_INVOKER`.
10
+ */
11
+ invoker?: string;
12
+ /**
13
+ * Your own origin, which Connect sets as the token's audience. Defaults to
14
+ * `CONNECT_TASK_AUDIENCE`, then to the request URL's origin.
15
+ */
16
+ audience?: string;
17
+ /** Injectable for tests. Defaults to the global `fetch`. */
18
+ fetch?: FetchLike;
19
+ /** Injectable for tests. Milliseconds since the epoch. */
20
+ now?: () => number;
21
+ }
22
+ /** Test seam - drops the cached Google keys. */
23
+ export declare function clearConnectTaskKeyCache(): void;
24
+ /**
25
+ * Verify a delivery and return it, or throw `ConnectTaskVerificationError`.
26
+ *
27
+ * Reads the body, so call it once and use what it returns.
28
+ */
29
+ export declare function verifyConnectTask<TPayload = unknown>(request: Request, options?: VerifyConnectTaskOptions): Promise<ConnectTaskDelivery<TPayload>>;
30
+ export interface ConnectTaskHandlerOptions<TPayload> extends VerifyConnectTaskOptions {
31
+ /**
32
+ * Run one task. Return normally and Cloud Tasks counts it done; **throw**
33
+ * and it is retried with backoff, up to the queue's declared attempts. A
34
+ * failure no retry can fix (the record is gone) should return, not throw.
35
+ */
36
+ handle: (delivery: ConnectTaskDelivery<TPayload>) => Promise<void> | void;
37
+ }
38
+ /**
39
+ * A ready-made `POST` route for a queue's URL:
40
+ *
41
+ * ```ts
42
+ * // app/api/tasks/campaign/route.ts
43
+ * export const POST = createConnectTaskHandler<{ campaignId: string }>({
44
+ * handle: async ({ workspaceId, payload }) => runCampaignPage(workspaceId, payload.campaignId),
45
+ * });
46
+ * ```
47
+ *
48
+ * `401` for anything that does not verify, `500` when `handle` throws (or
49
+ * Google's keys cannot be fetched), `200` otherwise.
50
+ */
51
+ export declare function createConnectTaskHandler<TPayload = unknown>(options: ConnectTaskHandlerOptions<TPayload>): (request: Request) => Promise<Response>;
@@ -267,9 +267,16 @@ export interface PlanAddonConsumable {
267
267
  export interface PlanAddon {
268
268
  id: string;
269
269
  name: string;
270
- /** GST-exclusive, in paise. */
270
+ /** GST-exclusive, in paise. Per plan cycle, or per year when `billingCadence` is `"annual"`. */
271
271
  price: number;
272
272
  consumable?: PlanAddonConsumable | null;
273
+ /**
274
+ * When the add-on is billed. `"annual"` = once on the first cycle that holds
275
+ * it, then on each anniversary - on a monthly plan too - so render its price
276
+ * as "/year". `"plan"` (or absent, from a Connect that predates the field) =
277
+ * every plan cycle, as before.
278
+ */
279
+ billingCadence?: "plan" | "annual" | null;
273
280
  }
274
281
  /**
275
282
  * A plan feature with its label, as the **catalogue** read reports it.
@@ -3237,3 +3244,43 @@ export interface RegisterPushDeviceResponse {
3237
3244
  /** Hand this to `revokeDevice` to turn the same browser off again. */
3238
3245
  id: string;
3239
3246
  }
3247
+ /**
3248
+ * One task, onto one of this application's declared task queues, for one
3249
+ * workspace it serves. Hand-written like every wire type here;
3250
+ * `conformance.test.ts` holds it to `EnqueueApplicationTaskInputSchema`.
3251
+ */
3252
+ export interface EnqueueTaskInput {
3253
+ workspaceId: string;
3254
+ /** A queue declared for this application in Console. */
3255
+ queue: string;
3256
+ /** Any JSON. Delivered back as `payload`. Keep it to ids - 100 KB at most. */
3257
+ payload?: unknown;
3258
+ /**
3259
+ * A dedup key. A second enqueue with the same name for the same workspace
3260
+ * answers `duplicate` and queues nothing - name a self-chaining step after
3261
+ * the step (`campaign-42-page-3`) so two runners cannot both queue it.
3262
+ */
3263
+ name?: string;
3264
+ /** ISO-8601, at most 30 days ahead. Omit to run as soon as the queue's rate allows. */
3265
+ scheduleAt?: string;
3266
+ }
3267
+ export interface EnqueueTaskResponse {
3268
+ /**
3269
+ * `unavailable` means this Connect deployment has no task queue (local
3270
+ * development). Nothing was queued; fall back to whatever you would do
3271
+ * without one.
3272
+ */
3273
+ status: "queued" | "duplicate" | "unavailable";
3274
+ scheduleAt: string | null;
3275
+ }
3276
+ /** What Cloud Tasks POSTs to a queue's URL - and what `verifyConnectTask` returns. */
3277
+ export interface ConnectTaskDelivery<TPayload = unknown> {
3278
+ /** The workspace the enqueue was gated on - trust this, not anything in `payload`. */
3279
+ workspaceId: string;
3280
+ queue: string;
3281
+ payload: TPayload;
3282
+ /** From Cloud Tasks' own headers: 0 on the first attempt. */
3283
+ retryCount: number;
3284
+ /** Cloud Tasks' task id, stable across retries. */
3285
+ taskName: string | null;
3286
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.49.0",
3
+ "version": "0.50.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",