@growthub/cli 0.9.13 → 0.9.14

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 (24) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +692 -223
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  23. package/dist/index.js +1764 -40677
  24. package/package.json +1 -1
@@ -27,6 +27,33 @@ Use `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` when the deployed a
27
27
 
28
28
  For first boot, the bundled app also supports a hybrid path: keep `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` and set `WINDSOR_API_KEY` locally. That overlays connected state for Windsor AI and Google Sheets blended data without moving the rest of the workspace off the hosted bridge authority path.
29
29
 
30
+ ## Data sources and API registry
31
+
32
+ Use the Data Model page to configure API-backed Data Sources and reusable API Registry records. Credentials stay in workspace settings or server env; Data Model records store only non-secret references such as `authRef`.
33
+
34
+ See [`docs/data-sources-api-registry.md`](./docs/data-sources-api-registry.md) for the setup guide, test flow, widget source eligibility rules, and the LeadShark example.
35
+
36
+ ## Integration resolvers
37
+
38
+ Drop one `.js` file per integration into `lib/adapters/integrations/resolvers/`. Each file registers a provider-agnostic resolver that the workspace routes (`test-source`, `refresh-sources`) dispatch to. The bridge confirms which integrations are connected — resolvers call the provider API directly using env-var tokens.
39
+
40
+ ```bash
41
+ # List registered resolvers (JSON)
42
+ curl -s http://localhost:3000/api/workspace/resolvers
43
+
44
+ # Test a resolver before saving to data model (JSON)
45
+ curl -s -X POST http://localhost:3000/api/workspace/test-source \
46
+ -H "Content-Type: application/json" \
47
+ -d '{"integrationId":"google-analytics","binding":{"entityType":"ga4.traffic","sourceStorage":"workspace-source-records","sourceId":"ga4-traffic"}}'
48
+
49
+ # Register a resolver-backed data model object (persists to growthub.config.json)
50
+ curl -s -X PATCH http://localhost:3000/api/workspace \
51
+ -H "Content-Type: application/json" \
52
+ -d '{"dataModel":{"objects":[...]}}'
53
+ ```
54
+
55
+ See [`lib/adapters/integrations/resolvers/README.md`](./lib/adapters/integrations/resolvers/README.md) for the full resolver shape, all CLI commands with JSON response contracts, and the complete data model → source dropdown → refresh flow.
56
+
30
57
  ## Run
31
58
 
32
59
  ```bash
@@ -16,8 +16,10 @@
16
16
  * 400 { error: string }
17
17
  */
18
18
  import { NextResponse } from "next/server";
19
- import { listEntityMetadataForIntegration } from "@/lib/adapters/integrations";
19
+ import { listEntityMetadataForIntegration, listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
20
20
  import { readAdapterConfig } from "@/lib/adapters/env";
21
+ import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
22
+ import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
21
23
 
22
24
  async function GET(request) {
23
25
  const { searchParams } = new URL(request.url);
@@ -30,21 +32,51 @@ async function GET(request) {
30
32
  );
31
33
  }
32
34
 
35
+ const id = integrationId.trim();
33
36
  const config = readAdapterConfig();
34
37
  const isBridgeMode =
35
38
  config.integrationAdapter === "growthub-bridge" &&
36
39
  config.growthubBridge?.baseUrl &&
37
40
  !!process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
38
41
 
39
- const entities = await listEntityMetadataForIntegration(integrationId.trim());
42
+ // 1. Try the Bridge / catalog path first
43
+ let entities = await listEntityMetadataForIntegration(id);
44
+ let source = entities.length ? "bridge" : "none";
40
45
 
41
- return NextResponse.json({
42
- integrationId: integrationId.trim(),
43
- entities,
44
- source: entities.length ? "resolver" : "none",
45
- requiresObjectResolver: entities.length === 0,
46
- authority: isBridgeMode ? "growthub-bridge" : "local"
47
- });
46
+ // 2. If Bridge returned nothing, fall back to resolver registry listEntities()
47
+ if (!entities.length) {
48
+ try {
49
+ await loadAllResolvers();
50
+ const resolver = getSourceResolver(id);
51
+ if (resolver && typeof resolver.listEntities === "function") {
52
+ const allConnections = await listGovernedWorkspaceIntegrations().catch(() => []);
53
+ const connection = allConnections.find((c) => c.provider === id || c.id === id) || null;
54
+ const raw = await resolver.listEntities(config, connection);
55
+ if (Array.isArray(raw) && raw.length) {
56
+ entities = raw.map((item) => ({
57
+ id: String(item.id || item.entityId || item.propertyId || item.accountId || ""),
58
+ label: String(item.label || item.name || item.displayName || item.id || ""),
59
+ secondaryLabel: item.secondaryLabel || item.domain || item.accountId || undefined,
60
+ entityType: item.entityType || item.type || undefined,
61
+ provider: id,
62
+ status: item.status || undefined,
63
+ metadata: item.metadata || undefined
64
+ })).filter((e) => e.id);
65
+ source = "resolver";
66
+ }
67
+ }
68
+ } catch {
69
+ // resolver not available or threw — leave entities empty
70
+ }
71
+ }
72
+
73
+ return NextResponse.json({
74
+ integrationId: id,
75
+ entities,
76
+ source,
77
+ requiresObjectResolver: entities.length === 0,
78
+ authority: isBridgeMode ? "growthub-bridge" : "local"
79
+ });
48
80
  }
49
81
 
50
82
  export { GET };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * GET /api/workspace/list-entities?integrationId=<id>
3
+ *
4
+ * Calls the registered resolver's `listEntities()` function and returns a
5
+ * normalized entity list. Fully composable — no provider-specific logic here.
6
+ * The resolver file is the only place that knows what an "entity" means for its
7
+ * integration.
8
+ *
9
+ * Response (success):
10
+ * { entities: { id: string, label: string, meta?: object }[], hasListEntities: true }
11
+ *
12
+ * Response (resolver has no listEntities):
13
+ * { entities: [], hasListEntities: false }
14
+ *
15
+ * Response (resolver not found):
16
+ * { error: "no-resolver", integrationId } — 404
17
+ */
18
+
19
+ import { NextResponse } from "next/server";
20
+ import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
21
+ import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
22
+
23
+ async function GET(request) {
24
+ const { searchParams } = new URL(request.url);
25
+ const integrationId = searchParams.get("integrationId");
26
+
27
+ if (!integrationId) {
28
+ return NextResponse.json(
29
+ { error: "integrationId query param required" },
30
+ { status: 400 }
31
+ );
32
+ }
33
+
34
+ await loadAllResolvers();
35
+ const resolver = getSourceResolver(integrationId);
36
+
37
+ if (!resolver) {
38
+ return NextResponse.json(
39
+ { error: "no-resolver", integrationId },
40
+ { status: 404 }
41
+ );
42
+ }
43
+
44
+ if (typeof resolver.listEntities !== "function") {
45
+ return NextResponse.json({ entities: [], hasListEntities: false });
46
+ }
47
+
48
+ try {
49
+ const entities = await resolver.listEntities({}, {});
50
+ return NextResponse.json({
51
+ entities: Array.isArray(entities) ? entities : [],
52
+ hasListEntities: true,
53
+ });
54
+ } catch (err) {
55
+ return NextResponse.json(
56
+ {
57
+ error: err.message || "Failed to list entities",
58
+ reason: "list-error",
59
+ entities: [],
60
+ hasListEntities: true,
61
+ },
62
+ { status: 500 }
63
+ );
64
+ }
65
+ }
66
+
67
+ export { GET };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * POST /api/workspace/refresh-source
3
+ *
4
+ * Calls the registered resolver's `fetchRecords()` with the supplied binding,
5
+ * then persists the resulting rows into the specified data model object.
6
+ *
7
+ * Fully composable — the route has no knowledge of any specific provider.
8
+ * All provider logic lives in the resolver file.
9
+ *
10
+ * Request body:
11
+ * {
12
+ * integrationId: string, // matches resolver.integrationId
13
+ * binding: object, // forwarded verbatim to resolver.fetchRecords
14
+ * objectId: string | null // data model object to persist rows into
15
+ * }
16
+ *
17
+ * Response (success):
18
+ * {
19
+ * ok: true,
20
+ * rowCount: number,
21
+ * columns: string[],
22
+ * objectId: string | null,
23
+ * persisted: boolean, // true when rows were written to growthub.config.json
24
+ * dataModel: object | null // full updated dataModel (for local state sync)
25
+ * }
26
+ *
27
+ * Response (failure):
28
+ * { ok: false, reason: string, error: string }
29
+ */
30
+
31
+ import { NextResponse } from "next/server";
32
+ import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
33
+ import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
34
+ import { readWorkspaceConfig, writeWorkspaceConfig, describePersistenceMode } from "@/lib/workspace-config";
35
+
36
+ async function POST(request) {
37
+ let body;
38
+ try {
39
+ body = await request.json();
40
+ } catch {
41
+ return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 });
42
+ }
43
+
44
+ const { integrationId, binding, objectId } = body || {};
45
+
46
+ if (!integrationId) {
47
+ return NextResponse.json({ ok: false, reason: "integrationId required" }, { status: 400 });
48
+ }
49
+
50
+ await loadAllResolvers();
51
+ const resolver = getSourceResolver(integrationId);
52
+
53
+ if (!resolver) {
54
+ return NextResponse.json(
55
+ { ok: false, reason: "no-resolver", integrationId },
56
+ { status: 404 }
57
+ );
58
+ }
59
+
60
+ let rows;
61
+ try {
62
+ rows = await resolver.fetchRecords({}, {}, binding || {});
63
+ if (!Array.isArray(rows)) rows = [];
64
+ } catch (err) {
65
+ return NextResponse.json(
66
+ { ok: false, reason: "fetch-error", error: err.message || "Resolver threw" },
67
+ { status: 500 }
68
+ );
69
+ }
70
+
71
+ const columns = rows.length > 0
72
+ ? Object.keys(rows[0])
73
+ : (Array.isArray(binding?.columns) ? binding.columns : []);
74
+
75
+ // Persist rows to the data model object when objectId is provided and FS writes are allowed
76
+ const persistence = describePersistenceMode();
77
+ let persisted = false;
78
+ let dataModel = null;
79
+
80
+ if (objectId && persistence.canSave) {
81
+ try {
82
+ const current = await readWorkspaceConfig();
83
+ const wc = current.workspaceConfig || current;
84
+ const existingObjects = Array.isArray(wc.dataModel?.objects) ? wc.dataModel.objects : [];
85
+
86
+ const updatedObjects = existingObjects.map((obj) => {
87
+ if (obj.id !== objectId) return obj;
88
+ return {
89
+ ...obj,
90
+ columns: columns.length ? columns : obj.columns,
91
+ rows,
92
+ refreshedAt: new Date().toISOString(),
93
+ };
94
+ });
95
+
96
+ // If objectId wasn't found in the list, nothing to update but don't error
97
+ dataModel = { ...(wc.dataModel || {}), objects: updatedObjects };
98
+
99
+ await writeWorkspaceConfig({ dataModel });
100
+ persisted = true;
101
+ } catch (err) {
102
+ return NextResponse.json({
103
+ ok: true,
104
+ rowCount: rows.length,
105
+ columns,
106
+ objectId: objectId || null,
107
+ persisted: false,
108
+ persistError: err.message,
109
+ dataModel: null,
110
+ });
111
+ }
112
+ }
113
+
114
+ return NextResponse.json({
115
+ ok: true,
116
+ rowCount: rows.length,
117
+ columns,
118
+ objectId: objectId || null,
119
+ persisted,
120
+ dataModel,
121
+ });
122
+ }
123
+
124
+ export { POST };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * POST /api/workspace/refresh-sources
3
+ *
4
+ * Thin dispatcher for live-backed data sources. Zero provider-specific logic
5
+ * lives here. Each integration ships its own resolver file under
6
+ * `lib/adapters/integrations/resolvers/<id>.js` and registers itself via
7
+ * `registerSourceResolver()` from source-resolver-registry.js.
8
+ *
9
+ * This route never imports any provider directly. It reads the registry and
10
+ * dispatches to whatever resolvers the operator has registered. An upstream
11
+ * kit with no resolver files registered still works — it just skips sources
12
+ * that have no resolver and returns them in the `skipped` array.
13
+ *
14
+ * Request body:
15
+ * { sourceIds: string[] } — IDs of dataModel.objects to refresh
16
+ *
17
+ * Response shape:
18
+ * 200 { refreshed: SourceRefreshResult[], skipped: string[] }
19
+ * 400 { error: string }
20
+ * 500 { error: string }
21
+ *
22
+ * SourceRefreshResult:
23
+ * { sourceId: string, integrationId: string, recordCount: number, fetchedAt: string }
24
+ *
25
+ * Authority contract (GOVERNED_WORKSPACE_TOPOLOGY_V1):
26
+ * - Browser sends only non-secret sourceIds. No tokens, no provider auth.
27
+ * - Server reads env credentials via readAdapterConfig().
28
+ * - Normalized records are persisted via writeWorkspaceSourceRecords().
29
+ * - Raw provider payloads are never forwarded to the client.
30
+ * - The Growthub bridge owns provider auth — this route never holds tokens.
31
+ */
32
+
33
+ import { NextResponse } from "next/server";
34
+ import { readAdapterConfig } from "@/lib/adapters/env";
35
+ import { listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
36
+ import { readWorkspaceConfig, writeWorkspaceSourceRecords } from "@/lib/workspace-config";
37
+ import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
38
+ import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
39
+
40
+ async function POST(request) {
41
+ let body;
42
+ try {
43
+ body = await request.json();
44
+ } catch {
45
+ return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
46
+ }
47
+
48
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
49
+ return NextResponse.json({ error: "body must be a plain object" }, { status: 400 });
50
+ }
51
+
52
+ const { sourceIds } = body;
53
+ if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
54
+ return NextResponse.json({ error: "sourceIds must be a non-empty array" }, { status: 400 });
55
+ }
56
+
57
+ const invalidIds = sourceIds.filter((id) => typeof id !== "string" || !id.trim());
58
+ if (invalidIds.length) {
59
+ return NextResponse.json({ error: "every sourceId must be a non-empty string" }, { status: 400 });
60
+ }
61
+
62
+ let workspaceConfig;
63
+ try {
64
+ workspaceConfig = await readWorkspaceConfig();
65
+ } catch (err) {
66
+ return NextResponse.json({ error: `failed to read workspace config: ${err.message}` }, { status: 500 });
67
+ }
68
+
69
+ await loadAllResolvers();
70
+
71
+ const adapterConfig = readAdapterConfig();
72
+ const dataObjects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
73
+
74
+ // Resolve bridge connections once for this batch so every resolver
75
+ // receives the full live connection object (connectionId, authPath, metadata).
76
+ let bridgeIntegrations = [];
77
+ try {
78
+ bridgeIntegrations = await listGovernedWorkspaceIntegrations();
79
+ } catch {
80
+ // Non-fatal — resolvers fall back to env-only auth
81
+ }
82
+
83
+ const refreshed = [];
84
+ const skipped = [];
85
+
86
+ for (const sourceId of sourceIds) {
87
+ const obj = dataObjects.find((o) => o.id === sourceId);
88
+ if (!obj) {
89
+ skipped.push(sourceId);
90
+ continue;
91
+ }
92
+
93
+ const binding = obj.binding;
94
+ if (!binding || binding.sourceStorage !== "workspace-source-records") {
95
+ skipped.push(sourceId);
96
+ continue;
97
+ }
98
+
99
+ const integrationId = binding.integrationId;
100
+ if (!integrationId) {
101
+ skipped.push(sourceId);
102
+ continue;
103
+ }
104
+
105
+ const resolver = getSourceResolver(integrationId);
106
+ if (!resolver) {
107
+ skipped.push(sourceId);
108
+ continue;
109
+ }
110
+
111
+ try {
112
+ const connection = bridgeIntegrations.find(
113
+ (i) => i.provider === integrationId || i.id === integrationId
114
+ ) || null;
115
+ const records = await resolver.fetchRecords(adapterConfig, connection, binding);
116
+ const fetchedAt = new Date().toISOString();
117
+ await writeWorkspaceSourceRecords(sourceId, records, { integrationId, fetchedAt });
118
+ refreshed.push({ sourceId, integrationId, recordCount: records.length, fetchedAt });
119
+ } catch {
120
+ skipped.push(sourceId);
121
+ }
122
+ }
123
+
124
+ return NextResponse.json({ refreshed, skipped });
125
+ }
126
+
127
+ export { POST };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * POST /api/workspace/register-resolver
3
+ *
4
+ * Accepts a `.js` resolver file upload and saves it to
5
+ * `lib/adapters/integrations/resolvers/<filename>.js`.
6
+ *
7
+ * Only available in filesystem mode (local development with
8
+ * WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or NODE_ENV=development).
9
+ * In read-only runtimes this returns 409 with guidance.
10
+ *
11
+ * The uploaded file must:
12
+ * - be a valid UTF-8 text JavaScript module
13
+ * - call registerSourceResolver() at module level
14
+ * - have a .js extension
15
+ *
16
+ * After upload, call POST /api/workspace/test-source to verify the resolver
17
+ * registers correctly and fetchRecords returns well-formed records.
18
+ *
19
+ * Request: multipart/form-data
20
+ * file — the .js resolver file
21
+ * filename — optional override for the saved filename (slug.js)
22
+ *
23
+ * Response:
24
+ * 201 { saved: true, filename, path }
25
+ * 400 { error }
26
+ * 409 { error, guidance }
27
+ * 500 { error }
28
+ */
29
+
30
+ import { NextResponse } from "next/server";
31
+ import { promises as fs } from "node:fs";
32
+ import path from "node:path";
33
+ import { describePersistenceMode } from "@/lib/workspace-config";
34
+
35
+ const MAX_RESOLVER_SIZE = 256 * 1024; // 256 KB — resolvers should be small
36
+
37
+ function slugify(name) {
38
+ return name
39
+ .toLowerCase()
40
+ .replace(/\.js$/, "")
41
+ .replace(/[^a-z0-9-]/g, "-")
42
+ .replace(/-+/g, "-")
43
+ .replace(/^-|-$/g, "")
44
+ .slice(0, 64) || "resolver";
45
+ }
46
+
47
+ async function POST(request) {
48
+ const persistence = describePersistenceMode();
49
+ if (!persistence.canSave) {
50
+ return NextResponse.json({
51
+ error: "resolver upload requires a writable filesystem runtime",
52
+ guidance: persistence.guidance || "Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or use local Next.js development mode."
53
+ }, { status: 409 });
54
+ }
55
+
56
+ let formData;
57
+ try {
58
+ formData = await request.formData();
59
+ } catch {
60
+ return NextResponse.json({ error: "expected multipart/form-data with a file field" }, { status: 400 });
61
+ }
62
+
63
+ const file = formData.get("file");
64
+ const filenameOverride = formData.get("filename");
65
+
66
+ if (!file || typeof file.text !== "function") {
67
+ return NextResponse.json({ error: "file field is required" }, { status: 400 });
68
+ }
69
+
70
+ const originalName = typeof file.name === "string" ? file.name : "resolver.js";
71
+ if (!originalName.endsWith(".js")) {
72
+ return NextResponse.json({ error: "resolver file must have a .js extension" }, { status: 400 });
73
+ }
74
+
75
+ if (file.size > MAX_RESOLVER_SIZE) {
76
+ return NextResponse.json({ error: `resolver file must be smaller than ${MAX_RESOLVER_SIZE / 1024} KB` }, { status: 400 });
77
+ }
78
+
79
+ let text;
80
+ try {
81
+ text = await file.text();
82
+ } catch {
83
+ return NextResponse.json({ error: "could not read uploaded file" }, { status: 400 });
84
+ }
85
+
86
+ if (!text.includes("registerSourceResolver")) {
87
+ return NextResponse.json({
88
+ error: "resolver file must call registerSourceResolver() — see lib/adapters/integrations/resolvers/README.md for the required shape"
89
+ }, { status: 400 });
90
+ }
91
+
92
+ const rawName = typeof filenameOverride === "string" && filenameOverride.trim()
93
+ ? filenameOverride.trim()
94
+ : originalName;
95
+ const filename = `${slugify(rawName)}.js`;
96
+
97
+ const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
98
+ const outPath = path.join(resolversDir, filename);
99
+
100
+ if (path.dirname(outPath) !== resolversDir) {
101
+ return NextResponse.json({ error: "invalid filename — path traversal not allowed" }, { status: 400 });
102
+ }
103
+
104
+ try {
105
+ await fs.mkdir(resolversDir, { recursive: true });
106
+ await fs.writeFile(outPath, text, "utf8");
107
+ } catch (err) {
108
+ return NextResponse.json({ error: `failed to write resolver file: ${err.message}` }, { status: 500 });
109
+ }
110
+
111
+ return NextResponse.json({
112
+ saved: true,
113
+ filename,
114
+ path: `lib/adapters/integrations/resolvers/${filename}`,
115
+ hint: "Run POST /api/workspace/test-source with integrationId to verify the resolver registers and fetchRecords works."
116
+ }, { status: 201 });
117
+ }
118
+
119
+ export { POST };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GET /api/workspace/resolvers
3
+ *
4
+ * Lists resolver files present in lib/adapters/integrations/resolvers/ and
5
+ * returns provider-agnostic metadata for each registered resolver.
6
+ * Used by the generic resolver management panel and ResolverControlPanel in the
7
+ * widget inspector. No provider names appear in the response shape.
8
+ *
9
+ * Response:
10
+ * {
11
+ * files: string[],
12
+ * registeredIds: string[],
13
+ * resolvers: {
14
+ * integrationId: string,
15
+ * entityTypes: string[],
16
+ * hasListEntities: boolean,
17
+ * configSchema: SchemaField[] | null
18
+ * }[],
19
+ * canUpload: boolean
20
+ * }
21
+ */
22
+
23
+ import { NextResponse } from "next/server";
24
+ import { loadAllResolvers, listResolverFiles } from "@/lib/adapters/integrations/resolver-loader";
25
+ import { describeRegisteredResolvers } from "@/lib/adapters/integrations/source-resolver-registry";
26
+ import { describePersistenceMode } from "@/lib/workspace-config";
27
+
28
+ async function GET() {
29
+ await loadAllResolvers();
30
+ const files = await listResolverFiles();
31
+ const resolvers = describeRegisteredResolvers();
32
+ const persistence = describePersistenceMode();
33
+ return NextResponse.json({
34
+ files,
35
+ registeredIds: resolvers.map((r) => r.integrationId),
36
+ resolvers,
37
+ canUpload: persistence.canSave
38
+ });
39
+ }
40
+
41
+ export { GET };