@growthub/cli 0.14.10 → 0.14.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/callback/route.js +35 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/[productId]/resources/route.js +173 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +1 -49
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +3 -20
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +2 -19
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +607 -63
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +21 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/provider.png +0 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
  51. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -0
  52. package/package.json +1 -1
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Workspace Add-on Scheduler — provider-agnostic serverless scheduler capability.
3
+ *
4
+ * This is the reusable entry path the add-ons marketplace installs *into* the
5
+ * governed universe. A marketplace product whose `executionLane` is
6
+ * `serverless-scheduler` declares a `connectorKind`; this module maps that kind
7
+ * to a SchedulerAdapter that knows how to:
8
+ *
9
+ * 1. build a deterministic, idempotent schedule create/update request,
10
+ * 2. build the matching delete request,
11
+ * 3. verify the signed callback the provider POSTs back, and
12
+ * 4. parse that callback into NON-SECRET proof fields.
13
+ *
14
+ * Routes (`/api/workspace/add-ons/[providerId]/schedule|callback|failure`,
15
+ * `/api/workspace/workflows/[providerId]`) stay provider-agnostic and delegate
16
+ * the provider-specific wire details here. Upstash QStash is the first adapter;
17
+ * a second provider is added by registering another adapter — no route changes.
18
+ *
19
+ * Everything here is pure (only `node:crypto`) so the whole loop —
20
+ * schedule-id determinism, signature verification, callback parsing — is
21
+ * deterministically testable offline with `node --test`.
22
+ *
23
+ * SECRET RULE: tokens/signing keys are inputs to request building and signature
24
+ * verification ONLY. Nothing in the returned schedule metadata or parsed
25
+ * callback proof contains a secret value.
26
+ */
27
+
28
+ import { createHmac, createHash, timingSafeEqual } from "node:crypto";
29
+
30
+ const SERVERLESS_SCHEDULER_LANE = "serverless-scheduler";
31
+ const SCHEDULE_ID_NAMESPACE = "growthub";
32
+ // Clock skew tolerance for signed-callback exp/nbf checks (QStash Receiver default).
33
+ const SIGNATURE_CLOCK_TOLERANCE_S = 10;
34
+ const MAX_BODY_PREVIEW_CHARS = 240;
35
+
36
+ function clean(value) {
37
+ return String(value == null ? "" : value).trim();
38
+ }
39
+
40
+ /** Header-safe slug for one schedule-id segment (stable for identical input). */
41
+ function slugSegment(value) {
42
+ return clean(value)
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, 64) || "default";
47
+ }
48
+
49
+ /**
50
+ * Deterministic, idempotent schedule id derived from governed row identity.
51
+ * Same (providerId, workspaceId, objectId, rowId, version) → same id, so a
52
+ * create/update on the provider is an upsert, never a duplicate schedule.
53
+ *
54
+ * growthub-{providerId}-{workspaceId}-{objectId}-{rowId}-{version}
55
+ */
56
+ function deriveScheduleId({ providerId, workspaceId, objectId, rowId, version } = {}) {
57
+ return [
58
+ SCHEDULE_ID_NAMESPACE,
59
+ slugSegment(providerId),
60
+ slugSegment(workspaceId || "workspace"),
61
+ slugSegment(objectId),
62
+ slugSegment(rowId),
63
+ slugSegment(version || "v1"),
64
+ ].join("-");
65
+ }
66
+
67
+ /**
68
+ * A callback/destination origin a provider could never actually reach (or that
69
+ * would leak over plaintext). Registering one produces a false "installed
70
+ * scheduler" that never calls back — exactly what we are trying to avoid.
71
+ */
72
+ function isUnsafeCallbackUrl(url) {
73
+ const u = clean(url).toLowerCase();
74
+ if (!u) return true;
75
+ if (!u.startsWith("https://")) return true; // QStash callbacks require https
76
+ return /^https:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)([:/]|$)/.test(u);
77
+ }
78
+
79
+ /**
80
+ * Resolve the public base URL the provider will call back to. Explicit env
81
+ * override wins (the only reliable value on serverless hosts behind proxies);
82
+ * otherwise fall back to the request origin. Returns "" for an unsafe origin
83
+ * (localhost / non-https) unless `GROWTHUB_ALLOW_INSECURE_CALLBACK_URL=true`
84
+ * is set for a local tunnel test — so callers fail loudly instead of
85
+ * registering a callback the provider can never deliver to.
86
+ */
87
+ function resolveWorkspacePublicUrl(env = process.env, requestOrigin = "") {
88
+ const source = env && typeof env === "object" ? env : {};
89
+ const explicit = clean(
90
+ source.GROWTHUB_WORKSPACE_PUBLIC_URL ||
91
+ source.WORKSPACE_PUBLIC_URL ||
92
+ (source.VERCEL_URL ? `https://${clean(source.VERCEL_URL)}` : ""),
93
+ );
94
+ const allowInsecure = clean(source.GROWTHUB_ALLOW_INSECURE_CALLBACK_URL) === "true";
95
+ const base = (explicit || clean(requestOrigin)).replace(/\/+$/, "");
96
+ if (!base) return "";
97
+ if (allowInsecure) return base;
98
+ return isUnsafeCallbackUrl(base) ? "" : base;
99
+ }
100
+
101
+ /** The three governed URLs a scheduled run needs, all under one provider. */
102
+ function buildSchedulerCallbackUrls(baseUrl, providerId) {
103
+ const root = clean(baseUrl).replace(/\/+$/, "");
104
+ const pid = encodeURIComponent(slugSegment(providerId));
105
+ return {
106
+ destinationUrl: `${root}/api/workspace/workflows/${pid}`,
107
+ callbackUrl: `${root}/api/workspace/add-ons/${pid}/callback`,
108
+ failureCallbackUrl: `${root}/api/workspace/add-ons/${pid}/failure`,
109
+ };
110
+ }
111
+
112
+ function base64UrlToBuffer(value) {
113
+ const normalized = clean(value).replace(/-/g, "+").replace(/_/g, "/");
114
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
115
+ return Buffer.from(padded, "base64");
116
+ }
117
+
118
+ /** Normalize any base64/base64url digest claim to padding-free base64url. */
119
+ function normalizeDigestClaim(value) {
120
+ return clean(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
121
+ }
122
+
123
+ function safeJsonParse(text) {
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function withScheduleIdQuery(url, scheduleId) {
132
+ const raw = clean(url);
133
+ const id = clean(scheduleId);
134
+ if (!raw || !id) return raw;
135
+ try {
136
+ const parsed = new URL(raw);
137
+ parsed.searchParams.set("scheduleId", id);
138
+ return parsed.toString();
139
+ } catch {
140
+ const joiner = raw.includes("?") ? "&" : "?";
141
+ return `${raw}${joiner}scheduleId=${encodeURIComponent(id)}`;
142
+ }
143
+ }
144
+
145
+ /** Normalize a URL for `sub`-claim comparison (trailing slash + case-insensitive host). */
146
+ function normalizeUrlForCompare(value) {
147
+ const raw = clean(value).replace(/\/+$/, "");
148
+ try {
149
+ const u = new URL(raw);
150
+ return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, "")}${u.search}`;
151
+ } catch {
152
+ return raw.toLowerCase();
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Verify a QStash-style signed request. The `Upstash-Signature` header is a
158
+ * JWT (HS256) signed with one of the rotating signing keys. We verify EVERY
159
+ * claim Upstash's manual verification + `Receiver.verify()` require:
160
+ * - HMAC-SHA256 over `${headerB64}.${payloadB64}` matches for the current OR
161
+ * next signing key (key rotation),
162
+ * - `iss === "Upstash"`,
163
+ * - `sub === expectedUrl` (binds the signature to THIS endpoint — prevents
164
+ * replaying a /callback signature against /workflows and vice-versa),
165
+ * - the `body` claim is present for a non-empty body and equals SHA-256
166
+ * (base64) of the RAW request body (raw body must NOT be re-stringified),
167
+ * - `exp`/`nbf` within tolerance.
168
+ *
169
+ * Returns { ok, reason, claims }. Implemented natively (node:crypto) so it is
170
+ * wire-compatible with `@upstash/qstash`'s Receiver without a runtime dep, and
171
+ * fully testable offline. `expectedUrl` MUST come from the canonical public
172
+ * URL / route, never from an attacker-controlled header.
173
+ */
174
+ function verifyQstashSignature({ signature, body, signingKeys, expectedUrl, expectedIssuer = "Upstash", currentTimeS } = {}) {
175
+ const token = clean(signature);
176
+ if (!token) return { ok: false, reason: "missing-signature" };
177
+ const parts = token.split(".");
178
+ if (parts.length !== 3) return { ok: false, reason: "malformed-jwt" };
179
+ const [headerB64, payloadB64, signatureB64] = parts;
180
+ // Lock the algorithm: only HS256 is valid for QStash. Reject `none`/`RS256`/etc
181
+ // explicitly so a future refactor can't treat arbitrary JWT-like material as ok.
182
+ const header = safeJsonParse(base64UrlToBuffer(headerB64).toString("utf8"));
183
+ if (!header || typeof header !== "object") return { ok: false, reason: "malformed-header" };
184
+ if (clean(header.alg) !== "HS256") return { ok: false, reason: "unsupported-alg" };
185
+ const keys = (Array.isArray(signingKeys) ? signingKeys : [signingKeys])
186
+ .map((k) => clean(k))
187
+ .filter(Boolean);
188
+ if (!keys.length) return { ok: false, reason: "no-signing-keys" };
189
+
190
+ const signingInput = `${headerB64}.${payloadB64}`;
191
+ const providedSig = base64UrlToBuffer(signatureB64);
192
+ const signatureMatches = keys.some((key) => {
193
+ const expected = createHmac("sha256", key).update(signingInput).digest();
194
+ return expected.length === providedSig.length && timingSafeEqual(expected, providedSig);
195
+ });
196
+ if (!signatureMatches) return { ok: false, reason: "signature-mismatch" };
197
+
198
+ const claims = safeJsonParse(base64UrlToBuffer(payloadB64).toString("utf8"));
199
+ if (!claims || typeof claims !== "object") return { ok: false, reason: "bad-claims" };
200
+
201
+ // Issuer must be Upstash.
202
+ if (expectedIssuer && clean(claims.iss) !== expectedIssuer) {
203
+ return { ok: false, reason: "issuer-mismatch", claims };
204
+ }
205
+
206
+ // Subject must equal the endpoint this request actually hit (anti-replay).
207
+ if (expectedUrl) {
208
+ if (!clean(claims.sub)) return { ok: false, reason: "missing-subject", claims };
209
+ if (normalizeUrlForCompare(claims.sub) !== normalizeUrlForCompare(expectedUrl)) {
210
+ return { ok: false, reason: "subject-mismatch", claims };
211
+ }
212
+ }
213
+
214
+ // The body claim binds the signature to the exact bytes received.
215
+ const rawBody = typeof body === "string" ? body : "";
216
+ if (rawBody.length > 0 && !clean(claims.body)) {
217
+ return { ok: false, reason: "missing-body-claim", claims };
218
+ }
219
+ if (clean(claims.body)) {
220
+ const expectedDigest = normalizeDigestClaim(createHash("sha256").update(rawBody, "utf8").digest("base64"));
221
+ if (normalizeDigestClaim(claims.body) !== expectedDigest) {
222
+ return { ok: false, reason: "body-mismatch", claims };
223
+ }
224
+ }
225
+
226
+ const now = Number.isFinite(currentTimeS) ? currentTimeS : Math.floor(Date.now() / 1000);
227
+ if (Number.isFinite(claims.exp) && now > claims.exp + SIGNATURE_CLOCK_TOLERANCE_S) {
228
+ return { ok: false, reason: "expired", claims };
229
+ }
230
+ if (Number.isFinite(claims.nbf) && now + SIGNATURE_CLOCK_TOLERANCE_S < claims.nbf) {
231
+ return { ok: false, reason: "not-yet-valid", claims };
232
+ }
233
+ return { ok: true, reason: "verified", claims };
234
+ }
235
+
236
+ /* ------------------------------------------------------------------ *
237
+ * Upstash QStash adapter *
238
+ * ------------------------------------------------------------------ */
239
+
240
+ function qstashBaseUrl({ product, region, env = process.env }) {
241
+ const source = env && typeof env === "object" ? env : {};
242
+ const configured = clean(source.QSTASH_URL);
243
+ if (configured) return configured.replace(/\/+$/, "");
244
+ const options = Array.isArray(product?.regionOptions) ? product.regionOptions : [];
245
+ const selected = options.find((option) => option.id === region) || options[0];
246
+ return clean(selected?.baseUrl).replace(/\/+$/, "");
247
+ }
248
+
249
+ const upstashQstashAdapter = {
250
+ connectorKind: "upstash-qstash",
251
+ /**
252
+ * Build the QStash schedule create/update request. QStash upserts by
253
+ * `Upstash-Schedule-Id`, so re-issuing with the same id edits in place.
254
+ * The bearer token is placed in the Authorization header only.
255
+ */
256
+ buildScheduleRequest({ product, region, token, scheduleId, cron, destinationUrl, callbackUrl, failureCallbackUrl, forward = {}, env = process.env } = {}) {
257
+ const base = qstashBaseUrl({ product, region, env });
258
+ if (!base) throw new Error("could not resolve QStash base URL (set QSTASH_URL or pick a region)");
259
+ if (!clean(token)) throw new Error("QSTASH_TOKEN is required to create a schedule");
260
+ if (!clean(destinationUrl)) throw new Error("destination URL is required");
261
+ if (!clean(cron)) throw new Error("cron expression is required");
262
+ const headers = {
263
+ authorization: `Bearer ${clean(token)}`,
264
+ "content-type": "application/json",
265
+ "upstash-cron": clean(cron),
266
+ "upstash-schedule-id": clean(scheduleId),
267
+ };
268
+ const callbackWithSchedule = withScheduleIdQuery(callbackUrl, scheduleId);
269
+ const failureWithSchedule = withScheduleIdQuery(failureCallbackUrl, scheduleId);
270
+ if (clean(callbackWithSchedule)) headers["upstash-callback"] = clean(callbackWithSchedule);
271
+ if (clean(failureWithSchedule)) headers["upstash-failure-callback"] = clean(failureWithSchedule);
272
+ // QStash STRIPS the `Upstash-Forward-` prefix before delivering to the
273
+ // destination, so we forward canonical `x-growthub-*` names that the
274
+ // destination route reads back verbatim (e.g. `x-growthub-object-id`).
275
+ const forwardHeaderMap = {
276
+ "x-growthub-workspace-id": forward.workspaceId,
277
+ "x-growthub-object-id": forward.objectId,
278
+ "x-growthub-row-id": forward.rowId,
279
+ "x-growthub-version": forward.version,
280
+ "x-growthub-schedule-id": forward.scheduleId,
281
+ };
282
+ for (const [name, value] of Object.entries(forwardHeaderMap)) {
283
+ if (clean(value)) headers[`upstash-forward-${name}`] = clean(value);
284
+ }
285
+ return {
286
+ url: `${base}/v2/schedules/${clean(destinationUrl)}`,
287
+ method: "POST",
288
+ headers,
289
+ // Governed, non-secret run pointer. The destination resolves the row from
290
+ // these ids and the canonical env entry — secrets never ride the body.
291
+ body: JSON.stringify({
292
+ kind: "growthub-scheduled-run-v1",
293
+ scheduleId: clean(scheduleId),
294
+ runInputs: safeJsonParse(forward.triggerInput) || undefined,
295
+ ...forward,
296
+ }),
297
+ };
298
+ },
299
+ buildDeleteRequest({ product, region, token, scheduleId, env = process.env } = {}) {
300
+ const base = qstashBaseUrl({ product, region, env });
301
+ if (!base) throw new Error("could not resolve QStash base URL");
302
+ if (!clean(token)) throw new Error("QSTASH_TOKEN is required to delete a schedule");
303
+ if (!clean(scheduleId)) throw new Error("scheduleId is required");
304
+ return {
305
+ url: `${base}/v2/schedules/${encodeURIComponent(clean(scheduleId))}`,
306
+ method: "DELETE",
307
+ headers: { authorization: `Bearer ${clean(token)}` },
308
+ };
309
+ },
310
+ buildReadRequest({ product, region, token, scheduleId, env = process.env } = {}) {
311
+ const base = qstashBaseUrl({ product, region, env });
312
+ if (!base) throw new Error("could not resolve QStash base URL");
313
+ if (!clean(token)) throw new Error("QSTASH_TOKEN is required to read a schedule");
314
+ if (!clean(scheduleId)) throw new Error("scheduleId is required");
315
+ return {
316
+ url: `${base}/v2/schedules/${encodeURIComponent(clean(scheduleId))}`,
317
+ method: "GET",
318
+ headers: { authorization: `Bearer ${clean(token)}` },
319
+ };
320
+ },
321
+ buildControlRequest({ product, region, token, scheduleId, action, env = process.env } = {}) {
322
+ const base = qstashBaseUrl({ product, region, env });
323
+ const normalized = clean(action);
324
+ if (!base) throw new Error("could not resolve QStash base URL");
325
+ if (!clean(token)) throw new Error("QSTASH_TOKEN is required to control a schedule");
326
+ if (!clean(scheduleId)) throw new Error("scheduleId is required");
327
+ if (!["pause", "resume"].includes(normalized)) throw new Error("schedule action must be pause or resume");
328
+ return {
329
+ url: `${base}/v2/schedules/${encodeURIComponent(clean(scheduleId))}/${normalized}`,
330
+ method: "PATCH",
331
+ headers: { authorization: `Bearer ${clean(token)}` },
332
+ };
333
+ },
334
+ buildRunRequest({ product, region, token, scheduleId, destinationUrl, callbackUrl, failureCallbackUrl, forward = {}, env = process.env } = {}) {
335
+ const base = qstashBaseUrl({ product, region, env });
336
+ if (!base) throw new Error("could not resolve QStash base URL");
337
+ if (!clean(token)) throw new Error("QSTASH_TOKEN is required to run the scheduler");
338
+ if (!clean(scheduleId)) throw new Error("scheduleId is required");
339
+ if (!clean(destinationUrl)) throw new Error("destination URL is required");
340
+ const headers = {
341
+ authorization: `Bearer ${clean(token)}`,
342
+ "content-type": "application/json",
343
+ // Manual proof runs must not create retry storms while an operator is
344
+ // actively validating the workflow path.
345
+ "upstash-retries": "0",
346
+ };
347
+ const callbackWithSchedule = withScheduleIdQuery(callbackUrl, scheduleId);
348
+ const failureWithSchedule = withScheduleIdQuery(failureCallbackUrl, scheduleId);
349
+ if (clean(callbackWithSchedule)) headers["upstash-callback"] = clean(callbackWithSchedule);
350
+ if (clean(failureWithSchedule)) headers["upstash-failure-callback"] = clean(failureWithSchedule);
351
+ const forwardHeaderMap = {
352
+ "x-growthub-workspace-id": forward.workspaceId,
353
+ "x-growthub-object-id": forward.objectId,
354
+ "x-growthub-row-id": forward.rowId,
355
+ "x-growthub-version": forward.version,
356
+ "x-growthub-schedule-id": forward.scheduleId || scheduleId,
357
+ };
358
+ for (const [name, value] of Object.entries(forwardHeaderMap)) {
359
+ if (clean(value)) headers[`upstash-forward-${name}`] = clean(value);
360
+ }
361
+ return {
362
+ url: `${base}/v2/publish/${clean(destinationUrl)}`,
363
+ method: "POST",
364
+ headers,
365
+ body: JSON.stringify({
366
+ kind: "growthub-scheduled-run-v1",
367
+ scheduleId: clean(scheduleId),
368
+ runInputs: safeJsonParse(forward.triggerInput) || forward.runInputs || undefined,
369
+ ...forward,
370
+ }),
371
+ };
372
+ },
373
+ parseRunResponse({ status, body } = {}) {
374
+ const ok = Number.isFinite(status) && status >= 200 && status < 300;
375
+ const parsed = typeof body === "string" ? safeJsonParse(body) : body;
376
+ return {
377
+ ok,
378
+ messageId: clean(parsed?.messageId) || clean(parsed?.messageID) || clean(parsed?.id),
379
+ proof: ok ? `QStash manual run published (HTTP ${status}).` : `QStash manual run publish failed (HTTP ${status}).`,
380
+ };
381
+ },
382
+ parseReadResponse({ status, body, scheduleId } = {}) {
383
+ const ok = Number.isFinite(status) && status >= 200 && status < 300;
384
+ const parsed = typeof body === "string" ? safeJsonParse(body) : body;
385
+ const remoteId = clean(parsed?.scheduleId) || clean(parsed?.id) || clean(parsed?.schedule_id);
386
+ const cron = clean(parsed?.cron) || clean(parsed?.schedule);
387
+ return {
388
+ ok,
389
+ exists: ok,
390
+ scheduleId: remoteId || (ok ? clean(scheduleId) : ""),
391
+ cron,
392
+ proof: ok
393
+ ? `QStash schedule ${remoteId || clean(scheduleId)} exists remotely (HTTP ${status}).`
394
+ : `QStash schedule ${clean(scheduleId)} was not verified remotely (HTTP ${status}).`,
395
+ };
396
+ },
397
+ parseControlResponse({ status, body, action, scheduleId } = {}) {
398
+ const ok = Number.isFinite(status) && status >= 200 && status < 300;
399
+ const parsed = typeof body === "string" ? safeJsonParse(body) : body;
400
+ const detail = clean(parsed?.error) || clean(parsed?.message) || clean(parsed?.detail) || clean(body).slice(0, 220);
401
+ return {
402
+ ok,
403
+ scheduleId: clean(parsed?.scheduleId) || clean(parsed?.id) || clean(scheduleId),
404
+ proof: ok
405
+ ? `QStash schedule ${clean(scheduleId)} ${clean(action)}d remotely (HTTP ${status}).`
406
+ : `QStash schedule ${clean(action)} failed (HTTP ${status})${detail ? `: ${detail}` : ""}.`,
407
+ };
408
+ },
409
+ /** Map the schedule HTTP response into non-secret proof. */
410
+ parseScheduleResponse({ status, body, scheduleId } = {}) {
411
+ const ok = Number.isFinite(status) && status >= 200 && status < 300;
412
+ const parsed = typeof body === "string" ? safeJsonParse(body) : body;
413
+ const returnedId = clean(parsed?.scheduleId) || clean(scheduleId);
414
+ const failureDetail = clean(parsed?.error) || clean(parsed?.message) || clean(parsed?.detail) || clean(body).slice(0, 220);
415
+ return {
416
+ ok,
417
+ scheduleId: returnedId,
418
+ proof: ok
419
+ ? `QStash schedule ${returnedId} upserted (HTTP ${status}).`
420
+ : `QStash schedule create failed (HTTP ${status})${failureDetail ? `: ${failureDetail}` : ""}.`,
421
+ };
422
+ },
423
+ /** Signing keys for callback verification, resolved from the run env. */
424
+ resolveSigningKeys(env = process.env) {
425
+ const source = env && typeof env === "object" ? env : {};
426
+ return [clean(source.QSTASH_CURRENT_SIGNING_KEY), clean(source.QSTASH_NEXT_SIGNING_KEY)].filter(Boolean);
427
+ },
428
+ verifyCallback({ signature, rawBody, expectedUrl, env = process.env, currentTimeS } = {}) {
429
+ return verifyQstashSignature({
430
+ signature,
431
+ body: rawBody,
432
+ signingKeys: this.resolveSigningKeys(env),
433
+ expectedUrl,
434
+ currentTimeS,
435
+ });
436
+ },
437
+ /**
438
+ * Parse a QStash callback envelope into NON-SECRET proof. QStash posts the
439
+ * destination's response wrapped as { status, body(base64), sourceMessageId,
440
+ * scheduleId, ... }. We decode a short preview only — never persist the full
441
+ * body or any header.
442
+ */
443
+ parseCallback({ rawBody, kind = "callback" } = {}) {
444
+ const envelope = typeof rawBody === "string" ? safeJsonParse(rawBody) : rawBody;
445
+ const status = Number(envelope?.status);
446
+ // QStash wraps the destination's response in `body` (base64). Decode it once
447
+ // so we can both preview it AND recover the scheduleId the destination echoed
448
+ // — QStash does not guarantee a top-level scheduleId on the callback.
449
+ let decodedBody = "";
450
+ if (clean(envelope?.body)) {
451
+ try {
452
+ decodedBody = base64UrlToBuffer(envelope.body).toString("utf8");
453
+ } catch {
454
+ decodedBody = clean(envelope.body);
455
+ }
456
+ }
457
+ const innerResponse = safeJsonParse(decodedBody);
458
+ const bodyPreview = clean(decodedBody).slice(0, MAX_BODY_PREVIEW_CHARS);
459
+ // scheduleId: top-level envelope → nested destination response → forwarded header echo.
460
+ const scheduleId = clean(envelope?.scheduleId) || clean(innerResponse?.scheduleId) || clean(envelope?.["x-growthub-schedule-id"]);
461
+ const succeeded = kind !== "failure" && Number.isFinite(status) && status >= 200 && status < 300;
462
+ const retried = Number(envelope?.retried);
463
+ const maxRetries = Number(envelope?.maxRetries);
464
+ return {
465
+ kind,
466
+ succeeded,
467
+ status: Number.isFinite(status) ? status : null,
468
+ messageId: clean(envelope?.sourceMessageId) || clean(envelope?.messageId),
469
+ runId: clean(innerResponse?.runId),
470
+ scheduleId,
471
+ bodyPreview,
472
+ responsePreview: clean(innerResponse?.response) || clean(innerResponse?.stdout) || bodyPreview,
473
+ // Retry counters distinguish "first attempt failed" from "retries exhausted".
474
+ retried: Number.isFinite(retried) ? retried : null,
475
+ maxRetries: Number.isFinite(maxRetries) ? maxRetries : null,
476
+ failureReason: succeeded ? "" : clean(envelope?.error) || clean(envelope?.dlqId) || (Number.isFinite(status) ? `HTTP ${status}` : "unknown"),
477
+ };
478
+ },
479
+ };
480
+
481
+ const SCHEDULER_ADAPTERS = [upstashQstashAdapter];
482
+
483
+ /** Resolve the scheduler adapter for a marketplace product (by connectorKind). */
484
+ function getSchedulerAdapter(product) {
485
+ const kind = clean(product?.connectorKind);
486
+ if (!kind) return null;
487
+ return SCHEDULER_ADAPTERS.find((adapter) => adapter.connectorKind === kind) || null;
488
+ }
489
+
490
+ function isSchedulerProduct(product) {
491
+ return clean(product?.executionLane) === SERVERLESS_SCHEDULER_LANE && Boolean(getSchedulerAdapter(product));
492
+ }
493
+
494
+ /**
495
+ * Pure decision for whether a signed callback may mutate workspace config. A
496
+ * governed scheduled callback MUST carry the installed schedule identity:
497
+ * the registry row must own a scheduleId, the callback must carry one, and
498
+ * they must match. Returns { ok, code } where code maps to a violation/HTTP.
499
+ */
500
+ function evaluateCallbackScheduleMatch({ rowScheduleId, parsedScheduleId } = {}) {
501
+ if (!clean(rowScheduleId)) return { ok: false, code: "callback_no_installed_schedule" };
502
+ if (!clean(parsedScheduleId)) return { ok: false, code: "callback_missing_schedule_id" };
503
+ if (clean(parsedScheduleId) !== clean(rowScheduleId)) return { ok: false, code: "callback_schedule_id_mismatch" };
504
+ return { ok: true, code: "" };
505
+ }
506
+
507
+ export {
508
+ SERVERLESS_SCHEDULER_LANE,
509
+ SIGNATURE_CLOCK_TOLERANCE_S,
510
+ deriveScheduleId,
511
+ resolveWorkspacePublicUrl,
512
+ buildSchedulerCallbackUrls,
513
+ verifyQstashSignature,
514
+ isUnsafeCallbackUrl,
515
+ getSchedulerAdapter,
516
+ isSchedulerProduct,
517
+ evaluateCallbackScheduleMatch,
518
+ upstashQstashAdapter,
519
+ };