@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/callback/route.js +35 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
- 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
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +1 -49
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +3 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +2 -19
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +607 -63
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/provider.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { readWorkspaceConfig, writeWorkspaceConfig } from "@/lib/workspace-config";
|
|
5
|
+
import {
|
|
6
|
+
getMarketplaceProvider,
|
|
7
|
+
getMarketplaceProduct,
|
|
8
|
+
listProviderProductReadiness,
|
|
9
|
+
withMarketplaceProductRegistry,
|
|
10
|
+
} from "@/lib/workspace-add-ons";
|
|
11
|
+
import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
|
|
12
|
+
import { readEnvVar, resolveRequiredEnv } from "@/lib/server-secrets";
|
|
13
|
+
import { requireWorkspaceOperator } from "@/lib/workspace-operator-auth";
|
|
14
|
+
|
|
15
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
16
|
+
|
|
17
|
+
function clean(value) {
|
|
18
|
+
return String(value == null ? "" : value).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function jsonError(message, status = 400, extra = {}) {
|
|
22
|
+
return NextResponse.json({ error: message, ...extra }, { status });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Canonical concrete-key read — same contract as readiness + schedule runtime.
|
|
26
|
+
function envValue(key) {
|
|
27
|
+
return clean(readEnvVar(key, process.env)?.value || "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function selectedRegion(product, region) {
|
|
31
|
+
const regionOptions = Array.isArray(product?.regionOptions) ? product.regionOptions : [];
|
|
32
|
+
return regionOptions.find((option) => option.id === region)
|
|
33
|
+
|| (region ? { id: region, label: region, baseUrl: `https://qstash-${region}.upstash.io` } : null)
|
|
34
|
+
|| regionOptions[0]
|
|
35
|
+
|| { id: region, label: region, baseUrl: "" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
41
|
+
try {
|
|
42
|
+
return await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readProbeText(response) {
|
|
49
|
+
try {
|
|
50
|
+
return clean(await response.text()).slice(0, 240);
|
|
51
|
+
} catch {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function safeUrl(baseUrl, path) {
|
|
57
|
+
const base = clean(baseUrl).replace(/\/+$/, "");
|
|
58
|
+
const suffix = clean(path).startsWith("/") ? clean(path) : `/${clean(path)}`;
|
|
59
|
+
return `${base}${suffix}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function quoteEnv(value) {
|
|
63
|
+
return JSON.stringify(String(value || ""));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeLocalEnv(updates) {
|
|
67
|
+
const envPath = path.join(process.cwd(), ".env.local");
|
|
68
|
+
let raw = "";
|
|
69
|
+
try {
|
|
70
|
+
raw = await fs.readFile(envPath, "utf8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== "ENOENT") throw error;
|
|
73
|
+
}
|
|
74
|
+
const keys = Object.keys(updates).filter((key) => updates[key] && process.env[key] !== updates[key]);
|
|
75
|
+
if (!keys.length) return [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const lines = raw.split(/\n/).map((line) => {
|
|
78
|
+
const match = line.match(/^\s*([A-Z0-9_]+)\s*=/);
|
|
79
|
+
if (!match || !keys.includes(match[1])) return line;
|
|
80
|
+
seen.add(match[1]);
|
|
81
|
+
return `${match[1]}=${quoteEnv(updates[match[1]])}`;
|
|
82
|
+
});
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
if (!seen.has(key)) lines.push(`${key}=${quoteEnv(updates[key])}`);
|
|
85
|
+
process.env[key] = updates[key];
|
|
86
|
+
}
|
|
87
|
+
await fs.writeFile(envPath, `${lines.filter((line, index) => index < lines.length - 1 || line.trim()).join("\n")}\n`, "utf8");
|
|
88
|
+
return keys;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readJsonSafe(response) {
|
|
92
|
+
try {
|
|
93
|
+
return await response.json();
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pickArray(payload) {
|
|
100
|
+
if (Array.isArray(payload)) return payload;
|
|
101
|
+
for (const key of ["databases", "indexes", "indices", "schedules", "queues", "resources", "items", "data"]) {
|
|
102
|
+
if (Array.isArray(payload?.[key])) return payload[key];
|
|
103
|
+
}
|
|
104
|
+
if (payload && typeof payload === "object") return [payload];
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resourceId(item, index) {
|
|
109
|
+
return clean(
|
|
110
|
+
item?.database_id
|
|
111
|
+
|| item?.databaseId
|
|
112
|
+
|| item?.id
|
|
113
|
+
|| item?.uuid
|
|
114
|
+
|| item?.customer_id
|
|
115
|
+
|| item?.customerId
|
|
116
|
+
|| item?.created_by
|
|
117
|
+
|| item?.createdBy
|
|
118
|
+
|| item?.index_id
|
|
119
|
+
|| item?.indexId
|
|
120
|
+
|| item?.index_name
|
|
121
|
+
|| item?.indexName
|
|
122
|
+
|| item?.name
|
|
123
|
+
|| item?.endpoint
|
|
124
|
+
|| `resource-${index + 1}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resourceFieldValue(row, mapping) {
|
|
129
|
+
const candidates = Array.isArray(mapping.fieldCandidates) && mapping.fieldCandidates.length
|
|
130
|
+
? mapping.fieldCandidates
|
|
131
|
+
: [mapping.field].filter(Boolean);
|
|
132
|
+
for (const field of candidates) {
|
|
133
|
+
const value = clean(row?.[field]);
|
|
134
|
+
if (!value) continue;
|
|
135
|
+
if (mapping.ensureHttps && !/^https?:\/\//i.test(value)) return `https://${value}`;
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function resolveProviderResource({ provider, product, selectedResourceId }) {
|
|
142
|
+
const discovery = product?.resourceDiscovery || {};
|
|
143
|
+
const envFromResource = Array.isArray(discovery.envFromResource) ? discovery.envFromResource : [];
|
|
144
|
+
if (!selectedResourceId || discovery.auth !== "provider-basic" || !envFromResource.length) return { writtenEnv: [] };
|
|
145
|
+
const emailKey = provider.accountProbe?.emailEnv;
|
|
146
|
+
const apiKey = provider.accountProbe?.keyEnv;
|
|
147
|
+
const email = envValue(emailKey);
|
|
148
|
+
const apiKeyValue = envValue(apiKey);
|
|
149
|
+
if (!email || !apiKeyValue) return { writtenEnv: [], missingProviderEnv: [emailKey, apiKey].filter(Boolean) };
|
|
150
|
+
|
|
151
|
+
const authHeader = `Basic ${Buffer.from(`${email}:${apiKeyValue}`).toString("base64")}`;
|
|
152
|
+
const paths = Array.isArray(discovery.paths) ? discovery.paths : [];
|
|
153
|
+
const candidates = [];
|
|
154
|
+
const failures = [];
|
|
155
|
+
for (const probePath of paths) {
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetchWithTimeout(safeUrl(provider.baseUrl, probePath), {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { authorization: authHeader, accept: "application/json" },
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
failures.push({ path: probePath, status: response.status });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const rows = pickArray(await readJsonSafe(response));
|
|
166
|
+
rows.forEach((row, index) => candidates.push({ row, id: resourceId(row, index), source: probePath }));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
failures.push({ path: probePath, status: 0, error: error?.message || "network error" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const selected = candidates.find((candidate) => candidate.id === selectedResourceId) || candidates[0] || null;
|
|
172
|
+
if (!selected) return { writtenEnv: [], failures };
|
|
173
|
+
const updates = {};
|
|
174
|
+
for (const mapping of envFromResource) {
|
|
175
|
+
const envRef = clean(mapping.envRef);
|
|
176
|
+
const value = resourceFieldValue(selected.row, mapping);
|
|
177
|
+
if (envRef && value) updates[envRef] = value;
|
|
178
|
+
}
|
|
179
|
+
const writtenEnv = await writeLocalEnv(updates);
|
|
180
|
+
return { writtenEnv, resource: selected, failures };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function probeJsonPaths({ baseUrl, token, paths, label }) {
|
|
184
|
+
let last = null;
|
|
185
|
+
for (const path of paths) {
|
|
186
|
+
const url = safeUrl(baseUrl, path);
|
|
187
|
+
const response = await fetchWithTimeout(url, {
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers: { authorization: `Bearer ${token}` },
|
|
190
|
+
});
|
|
191
|
+
const text = await readProbeText(response);
|
|
192
|
+
last = { status: response.status, path, text };
|
|
193
|
+
if (response.ok) {
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
baseUrl,
|
|
197
|
+
testedAt: new Date().toISOString(),
|
|
198
|
+
proof: `${label} probe ${path} returned HTTP ${response.status}`,
|
|
199
|
+
summary: `${label} sync verified with a read-only REST probe (${path}).`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const details = last ? `${last.path} returned HTTP ${last.status}` : "no endpoint returned";
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
baseUrl,
|
|
207
|
+
testedAt: new Date().toISOString(),
|
|
208
|
+
proof: `${label} probe failed: ${details}`,
|
|
209
|
+
summary: `${label} REST probe failed: ${details}.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function probeProviderProduct({ providerId, productId, region }) {
|
|
214
|
+
const product = getMarketplaceProduct(providerId, productId);
|
|
215
|
+
if (!product) return { ok: false, status: 400, error: "unknown provider product" };
|
|
216
|
+
|
|
217
|
+
const readiness = listProviderProductReadiness(providerId, process.env).find((item) => item.productId === product.productId);
|
|
218
|
+
const requiredEnv = resolveRequiredEnv(product.requiredEnv, process.env);
|
|
219
|
+
if (!readiness?.configured || !requiredEnv.ok) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
status: 422,
|
|
223
|
+
error: `${product.label} provider credentials are not connected`,
|
|
224
|
+
missingEnv: requiredEnv.missing.length ? requiredEnv.missing : (readiness?.missingEnv || product.requiredEnv),
|
|
225
|
+
resolvedEnv: requiredEnv.resolvedKeys,
|
|
226
|
+
summary: `${product.label} provider credentials are not connected. Complete provider setup, then sync again.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const probe = product.probe || {};
|
|
231
|
+
if (!probe.baseUrlEnv || !probe.tokenEnv || !Array.isArray(probe.paths) || !probe.paths.length) {
|
|
232
|
+
return { ok: false, status: 400, error: "unsupported provider product probe" };
|
|
233
|
+
}
|
|
234
|
+
const regionOption = selectedRegion(product, region);
|
|
235
|
+
const configuredUrl = envValue(probe.baseUrlEnv) || (probe.fallbackRegionBaseUrl ? regionOption.baseUrl : "");
|
|
236
|
+
const result = await probeJsonPaths({
|
|
237
|
+
baseUrl: configuredUrl,
|
|
238
|
+
token: envValue(probe.tokenEnv),
|
|
239
|
+
paths: probe.paths,
|
|
240
|
+
label: product.label,
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
...result,
|
|
244
|
+
resolvedEnv: requiredEnv.resolvedKeys,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function POST(request, context) {
|
|
249
|
+
const params = await context?.params;
|
|
250
|
+
const providerId = clean(params?.providerId);
|
|
251
|
+
const provider = getMarketplaceProvider(providerId);
|
|
252
|
+
if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
|
|
253
|
+
|
|
254
|
+
const auth = requireWorkspaceOperator(request);
|
|
255
|
+
if (!auth.ok) return jsonError(auth.error, auth.status);
|
|
256
|
+
|
|
257
|
+
let body = {};
|
|
258
|
+
try {
|
|
259
|
+
body = await request.json();
|
|
260
|
+
} catch {
|
|
261
|
+
return jsonError("invalid json body", 400);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const productId = clean(body.productId);
|
|
265
|
+
const region = clean(body.region || "us-east-1");
|
|
266
|
+
const plan = clean(body.plan || "free");
|
|
267
|
+
const selectedResourceId = clean(body.selectedResourceId);
|
|
268
|
+
const selectedResourceLabel = clean(body.selectedResourceLabel);
|
|
269
|
+
const selectedResourceSource = clean(body.selectedResourceSource);
|
|
270
|
+
const product = getMarketplaceProduct(provider.providerId, productId);
|
|
271
|
+
if (!product) return jsonError("unknown provider product", 400, { providerId: provider.providerId, productId });
|
|
272
|
+
|
|
273
|
+
const resourceResolution = await resolveProviderResource({ provider, product, selectedResourceId });
|
|
274
|
+
const syncResult = await probeProviderProduct({ providerId: provider.providerId, productId: product.productId, region });
|
|
275
|
+
if (selectedResourceId) {
|
|
276
|
+
syncResult.selectedResourceId = selectedResourceId;
|
|
277
|
+
syncResult.selectedResourceLabel = selectedResourceLabel || selectedResourceId;
|
|
278
|
+
syncResult.selectedResourceSource = selectedResourceSource || "provider-account";
|
|
279
|
+
}
|
|
280
|
+
if (resourceResolution.writtenEnv?.length) {
|
|
281
|
+
syncResult.resolvedEnv = Array.from(new Set([...(syncResult.resolvedEnv || []), ...resourceResolution.writtenEnv]));
|
|
282
|
+
}
|
|
283
|
+
if (!syncResult.ok) {
|
|
284
|
+
await appendOutcomeReceipt({
|
|
285
|
+
kind: "workspace-add-on-sync",
|
|
286
|
+
lane: "server-authoritative",
|
|
287
|
+
outcomeStatus: "blocked",
|
|
288
|
+
actor: "workspace-marketplace",
|
|
289
|
+
objectRefs: [{ objectId: "api-registry", objectType: "api-registry", rowName: product.label }],
|
|
290
|
+
summary: syncResult.summary || syncResult.error || `${product.label} sync failed`,
|
|
291
|
+
policyVerdict: { ok: false, violationCodes: syncResult.missingEnv?.length ? ["provider_product_not_connected"] : ["provider_probe_failed"] },
|
|
292
|
+
nextActions: syncResult.missingEnv?.length
|
|
293
|
+
? [`Complete ${product.label} setup from the provider marketplace flow, then sync again.`]
|
|
294
|
+
: [`Open the ${product.label} provider console, verify the product connection, then retry sync.`],
|
|
295
|
+
});
|
|
296
|
+
return jsonError(syncResult.error || syncResult.summary || "Provider product sync failed", syncResult.status || 502, {
|
|
297
|
+
providerId: provider.providerId,
|
|
298
|
+
productId: product.productId,
|
|
299
|
+
missingEnv: syncResult.missingEnv || [],
|
|
300
|
+
sync: {
|
|
301
|
+
ok: false,
|
|
302
|
+
proof: syncResult.proof || "",
|
|
303
|
+
summary: syncResult.summary || "",
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const currentConfig = await readWorkspaceConfig();
|
|
309
|
+
const nextConfig = withMarketplaceProductRegistry(currentConfig, {
|
|
310
|
+
providerId: provider.providerId,
|
|
311
|
+
productId: product.productId,
|
|
312
|
+
region,
|
|
313
|
+
plan,
|
|
314
|
+
syncResult,
|
|
315
|
+
});
|
|
316
|
+
const persisted = await writeWorkspaceConfig({ dataModel: nextConfig.dataModel });
|
|
317
|
+
const { receipt } = await appendOutcomeReceipt({
|
|
318
|
+
kind: "workspace-add-on-sync",
|
|
319
|
+
lane: "server-authoritative",
|
|
320
|
+
outcomeStatus: "published",
|
|
321
|
+
actor: "workspace-marketplace",
|
|
322
|
+
objectRefs: [{ objectId: "api-registry", objectType: "api-registry", rowName: product.label }],
|
|
323
|
+
changedFields: ["dataModel.api-registry"],
|
|
324
|
+
policyVerdict: { ok: true },
|
|
325
|
+
schemaVerdict: { ok: true },
|
|
326
|
+
summary: `${product.label} installed after provider sync probe.`,
|
|
327
|
+
nextActions: product.capabilities?.includes("workflow")
|
|
328
|
+
? [`Workflow Canvas can now bind ${product.shortLabel || product.label} from the installed product card.`]
|
|
329
|
+
: ["Use this workspace add-on from the relevant governed workspace surfaces."],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return NextResponse.json({
|
|
333
|
+
ok: true,
|
|
334
|
+
providerId: provider.providerId,
|
|
335
|
+
productId: product.productId,
|
|
336
|
+
workspaceConfig: persisted,
|
|
337
|
+
sync: {
|
|
338
|
+
ok: true,
|
|
339
|
+
proof: syncResult.proof,
|
|
340
|
+
summary: syncResult.summary,
|
|
341
|
+
testedAt: syncResult.testedAt,
|
|
342
|
+
},
|
|
343
|
+
receiptId: receipt.receiptId,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export { POST };
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/add-ons/[providerId]/sync
|
|
3
|
+
*
|
|
4
|
+
* Provider-account (not product) sync. Provider-agnostic: if the provider
|
|
5
|
+
* declares an `accountProbe`, this performs a real account-management-lane probe
|
|
6
|
+
* (HTTP Basic) and only writes `verified` on a live success. A configured-but-
|
|
7
|
+
* unprovable account (e.g. a third-party Upstash account with no Developer API)
|
|
8
|
+
* is recorded as `account-linked` — a weaker, honest state — never `verified`.
|
|
9
|
+
*
|
|
10
|
+
* Products still prove themselves independently via the product sync route; a
|
|
11
|
+
* verified provider account does not imply any product is installed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fs } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { NextResponse } from "next/server";
|
|
17
|
+
import { readWorkspaceConfig, writeWorkspaceConfig } from "@/lib/workspace-config";
|
|
18
|
+
import {
|
|
19
|
+
getMarketplaceProvider,
|
|
20
|
+
listProviderProductReadiness,
|
|
21
|
+
withMarketplaceProviderRegistry,
|
|
22
|
+
} from "@/lib/workspace-add-ons";
|
|
23
|
+
import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
|
|
24
|
+
import { requireWorkspaceOperator } from "@/lib/workspace-operator-auth";
|
|
25
|
+
import { readEnvVar } from "@/lib/server-secrets";
|
|
26
|
+
|
|
27
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
28
|
+
|
|
29
|
+
function clean(value) {
|
|
30
|
+
return String(value == null ? "" : value).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonError(message, status = 400, extra = {}) {
|
|
34
|
+
return NextResponse.json({ error: message, ...extra }, { status });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function envValue(key) {
|
|
38
|
+
return clean(readEnvVar(key, process.env)?.value || "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolvedEnvKeys(keys) {
|
|
42
|
+
return (Array.isArray(keys) ? keys : []).filter((key) => Boolean(readEnvVar(key, process.env)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
48
|
+
try {
|
|
49
|
+
return await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readJsonSafe(response) {
|
|
56
|
+
try {
|
|
57
|
+
return await response.json();
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compactAccountOptions(payload, source) {
|
|
64
|
+
const rawItems = Array.isArray(payload)
|
|
65
|
+
? payload
|
|
66
|
+
: Array.isArray(payload?.teams)
|
|
67
|
+
? payload.teams
|
|
68
|
+
: Array.isArray(payload?.accounts)
|
|
69
|
+
? payload.accounts
|
|
70
|
+
: Array.isArray(payload?.data)
|
|
71
|
+
? payload.data
|
|
72
|
+
: [];
|
|
73
|
+
return rawItems
|
|
74
|
+
.map((item, index) => {
|
|
75
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
|
76
|
+
const id = clean(item.id || item.team_id || item.teamId || item.account_id || item.accountId || item.slug || item.name || `account-${index + 1}`);
|
|
77
|
+
const label = clean(item.name || item.team_name || item.teamName || item.email || item.slug || id);
|
|
78
|
+
if (!id || !label) return null;
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
label,
|
|
82
|
+
source,
|
|
83
|
+
role: clean(item.role || item.user_role || item.userRole || ""),
|
|
84
|
+
plan: clean(item.plan || item.tier || ""),
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeUrl(baseUrl, path) {
|
|
91
|
+
const base = clean(baseUrl).replace(/\/+$/, "");
|
|
92
|
+
const suffix = clean(path).startsWith("/") ? clean(path) : `/${clean(path)}`;
|
|
93
|
+
return `${base}${suffix}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function quoteEnv(value) {
|
|
97
|
+
return JSON.stringify(String(value || ""));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function writeLocalEnv(updates) {
|
|
101
|
+
const envPath = path.join(process.cwd(), ".env.local");
|
|
102
|
+
let raw = "";
|
|
103
|
+
try {
|
|
104
|
+
raw = await fs.readFile(envPath, "utf8");
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT") throw error;
|
|
107
|
+
}
|
|
108
|
+
const keys = Object.keys(updates).filter((key) => updates[key] && process.env[key] !== updates[key]);
|
|
109
|
+
if (!keys.length) return [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const lines = raw.split(/\n/).map((line) => {
|
|
112
|
+
const match = line.match(/^\s*([A-Z0-9_]+)\s*=/);
|
|
113
|
+
if (!match || !keys.includes(match[1])) return line;
|
|
114
|
+
seen.add(match[1]);
|
|
115
|
+
return `${match[1]}=${quoteEnv(updates[match[1]])}`;
|
|
116
|
+
});
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
if (!seen.has(key)) lines.push(`${key}=${quoteEnv(updates[key])}`);
|
|
119
|
+
process.env[key] = updates[key];
|
|
120
|
+
}
|
|
121
|
+
await fs.writeFile(envPath, `${lines.filter((line, index) => index < lines.length - 1 || line.trim()).join("\n")}\n`, "utf8");
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function deriveUpstashQstashRuntimeEnv(provider, email, apiKey) {
|
|
126
|
+
if (provider?.providerId !== "upstash") return { writtenEnv: [], resolvedEnv: [] };
|
|
127
|
+
const authHeader = `Basic ${Buffer.from(`${email}:${apiKey}`).toString("base64")}`;
|
|
128
|
+
const userResponse = await fetchWithTimeout(safeUrl(provider.baseUrl, "/v2/qstash/user"), {
|
|
129
|
+
method: "GET",
|
|
130
|
+
headers: { authorization: authHeader, accept: "application/json" },
|
|
131
|
+
});
|
|
132
|
+
if (!userResponse.ok) return { writtenEnv: [], resolvedEnv: [] };
|
|
133
|
+
const userPayload = await readJsonSafe(userResponse);
|
|
134
|
+
const token = clean(userPayload?.token);
|
|
135
|
+
if (!token) return { writtenEnv: [], resolvedEnv: [] };
|
|
136
|
+
|
|
137
|
+
const updates = { QSTASH_TOKEN: token };
|
|
138
|
+
const keysResponse = await fetchWithTimeout("https://qstash.upstash.io/v2/keys", {
|
|
139
|
+
method: "GET",
|
|
140
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
141
|
+
});
|
|
142
|
+
if (keysResponse.ok) {
|
|
143
|
+
const keysPayload = await readJsonSafe(keysResponse);
|
|
144
|
+
if (clean(keysPayload?.current)) updates.QSTASH_CURRENT_SIGNING_KEY = clean(keysPayload.current);
|
|
145
|
+
if (clean(keysPayload?.next)) updates.QSTASH_NEXT_SIGNING_KEY = clean(keysPayload.next);
|
|
146
|
+
}
|
|
147
|
+
const writtenEnv = await writeLocalEnv(updates);
|
|
148
|
+
return { writtenEnv, resolvedEnv: Object.keys(updates) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Live account-management probe via HTTP Basic. Returns:
|
|
153
|
+
* { ok:true, ... } → verified (live success)
|
|
154
|
+
* { ok:false, syncStatus:"account-linked" } → creds absent / Developer API N/A
|
|
155
|
+
* { ok:false } → creds present but probe rejected
|
|
156
|
+
*/
|
|
157
|
+
async function probeProviderAccount(provider, now) {
|
|
158
|
+
const probe = provider.accountProbe;
|
|
159
|
+
if (!probe?.emailEnv || !probe?.keyEnv) {
|
|
160
|
+
return { ok: false, syncStatus: "account-linked", testedAt: now };
|
|
161
|
+
}
|
|
162
|
+
const email = envValue(probe.emailEnv);
|
|
163
|
+
const key = envValue(probe.keyEnv);
|
|
164
|
+
if (!email || !key) {
|
|
165
|
+
const required = [probe.emailEnv, probe.keyEnv];
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
syncStatus: "setup-required",
|
|
169
|
+
status: "draft",
|
|
170
|
+
missingEnv: required.filter((envKey) => !readEnvVar(envKey, process.env)),
|
|
171
|
+
resolvedEnv: resolvedEnvKeys(required),
|
|
172
|
+
testedAt: now,
|
|
173
|
+
proof: "",
|
|
174
|
+
summary: `${provider.label} account API credentials are required to show connected account details.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const authHeader = `Basic ${Buffer.from(`${email}:${key}`).toString("base64")}`;
|
|
178
|
+
const paths = ["/v2/teams", ...(Array.isArray(probe.paths) && probe.paths.length ? probe.paths : ["/v2"])];
|
|
179
|
+
let last = null;
|
|
180
|
+
let accountOptions = [];
|
|
181
|
+
for (const path of paths) {
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetchWithTimeout(safeUrl(provider.baseUrl, path), {
|
|
184
|
+
method: "GET",
|
|
185
|
+
headers: { authorization: authHeader, accept: "application/json" },
|
|
186
|
+
});
|
|
187
|
+
last = { status: response.status, path };
|
|
188
|
+
if (response.ok) {
|
|
189
|
+
const payload = await readJsonSafe(response);
|
|
190
|
+
const options = compactAccountOptions(payload, path);
|
|
191
|
+
if (options.length) accountOptions = options;
|
|
192
|
+
const selected = accountOptions[0] || null;
|
|
193
|
+
const runtimeCredentials = await deriveUpstashQstashRuntimeEnv(provider, email, key);
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
testedAt: now,
|
|
197
|
+
proof: `${provider.label} Developer API account verified (GET ${path} → HTTP ${response.status}).`,
|
|
198
|
+
summary: `${provider.label} provider account verified via live account-API probe.`,
|
|
199
|
+
resolvedEnv: Array.from(new Set([...resolvedEnvKeys([probe.emailEnv, probe.keyEnv]), ...(runtimeCredentials.resolvedEnv || [])])),
|
|
200
|
+
runtimeCredentials,
|
|
201
|
+
providerAccountOptions: accountOptions,
|
|
202
|
+
selectedProviderAccountId: selected?.id || "",
|
|
203
|
+
selectedProviderAccountLabel: selected?.label || "",
|
|
204
|
+
providerAccountSource: selected?.source || path,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
last = { status: 0, path, error: err?.message || "network error" };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const detail = last ? `${last.path} → HTTP ${last.status}` : "no endpoint responded";
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
testedAt: now,
|
|
215
|
+
proof: `${provider.label} account-API probe failed: ${detail}.`,
|
|
216
|
+
summary: `${provider.label} account-API probe failed: ${detail}.`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function POST(request, context) {
|
|
221
|
+
const params = await context?.params;
|
|
222
|
+
const providerId = clean(params?.providerId);
|
|
223
|
+
const provider = getMarketplaceProvider(providerId);
|
|
224
|
+
if (!provider) return jsonError("unknown marketplace provider", 404, { providerId });
|
|
225
|
+
|
|
226
|
+
const auth = requireWorkspaceOperator(request);
|
|
227
|
+
if (!auth.ok) return jsonError(auth.error, auth.status);
|
|
228
|
+
|
|
229
|
+
const now = new Date().toISOString();
|
|
230
|
+
const readiness = listProviderProductReadiness(provider.providerId, process.env);
|
|
231
|
+
const configured = readiness.filter((item) => item.configured);
|
|
232
|
+
const syncResult = await probeProviderAccount(provider, now);
|
|
233
|
+
|
|
234
|
+
// Hard failure or missing account API credentials → blocked, do not write a
|
|
235
|
+
// connected provider row. Product install may validate product credentials,
|
|
236
|
+
// but provider-account setup is not complete without account details.
|
|
237
|
+
if (!syncResult.ok) {
|
|
238
|
+
const currentConfig = await readWorkspaceConfig();
|
|
239
|
+
const nextConfig = withMarketplaceProviderRegistry(currentConfig, { providerId: provider.providerId, syncResult });
|
|
240
|
+
const persisted = await writeWorkspaceConfig({ dataModel: nextConfig.dataModel });
|
|
241
|
+
await appendOutcomeReceipt({
|
|
242
|
+
kind: "workspace-add-on-provider-sync",
|
|
243
|
+
lane: "server-authoritative",
|
|
244
|
+
outcomeStatus: "blocked",
|
|
245
|
+
actor: "workspace-marketplace",
|
|
246
|
+
objectRefs: [{ objectId: "api-registry", objectType: "api-registry", rowName: provider.label }],
|
|
247
|
+
summary: syncResult.summary || `${provider.label} account probe failed`,
|
|
248
|
+
policyVerdict: { ok: false, violationCodes: [syncResult.missingEnv?.length ? "provider_account_credentials_missing" : "provider_account_probe_failed"] },
|
|
249
|
+
nextActions: [`Configure ${provider.label} account API credentials, then retry provider sync.`],
|
|
250
|
+
});
|
|
251
|
+
return jsonError(syncResult.summary || "provider account probe failed", syncResult.missingEnv?.length ? 422 : 502, {
|
|
252
|
+
providerId: provider.providerId,
|
|
253
|
+
missingEnv: syncResult.missingEnv || [],
|
|
254
|
+
sync: {
|
|
255
|
+
ok: false,
|
|
256
|
+
summary: syncResult.summary || "",
|
|
257
|
+
resolvedEnv: syncResult.resolvedEnv || [],
|
|
258
|
+
},
|
|
259
|
+
workspaceConfig: persisted,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const currentConfig = await readWorkspaceConfig();
|
|
264
|
+
const nextConfig = withMarketplaceProviderRegistry(currentConfig, { providerId: provider.providerId, syncResult });
|
|
265
|
+
const persisted = await writeWorkspaceConfig({ dataModel: nextConfig.dataModel });
|
|
266
|
+
const verified = syncResult.ok === true;
|
|
267
|
+
const { receipt } = await appendOutcomeReceipt({
|
|
268
|
+
kind: "workspace-add-on-provider-sync",
|
|
269
|
+
lane: "server-authoritative",
|
|
270
|
+
outcomeStatus: "published",
|
|
271
|
+
actor: "workspace-marketplace",
|
|
272
|
+
objectRefs: [{ objectId: "api-registry", objectType: "api-registry", rowName: provider.label }],
|
|
273
|
+
changedFields: ["dataModel.api-registry"],
|
|
274
|
+
policyVerdict: { ok: true },
|
|
275
|
+
schemaVerdict: { ok: true },
|
|
276
|
+
summary: syncResult.summary,
|
|
277
|
+
nextActions: [`Install ${provider.label} products from the marketplace page. Product sync verifies each product's credential refs server-side.`],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return NextResponse.json({
|
|
281
|
+
ok: true,
|
|
282
|
+
providerId: provider.providerId,
|
|
283
|
+
verified,
|
|
284
|
+
accountState: verified ? "verified" : "account-linked",
|
|
285
|
+
workspaceConfig: persisted,
|
|
286
|
+
connectedProducts: configured.map((item) => ({ productId: item.productId, integrationId: item.integrationId, label: item.label })),
|
|
287
|
+
readiness,
|
|
288
|
+
sync: syncResult,
|
|
289
|
+
receiptId: receipt.receiptId,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { POST };
|