@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +692 -223
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
- package/dist/index.js +1764 -40677
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Data Sources and API Registry
|
|
2
|
+
|
|
3
|
+
This workspace supports provider-agnostic API-backed data sources without storing credentials in Data Model records.
|
|
4
|
+
|
|
5
|
+
Use this pattern for any external API:
|
|
6
|
+
|
|
7
|
+
1. Store the provider secret in workspace settings or server env.
|
|
8
|
+
2. Create an API Registry object.
|
|
9
|
+
3. Create a Data Source object.
|
|
10
|
+
4. Set the Data Source `registryId` to the API Registry row `integrationId`.
|
|
11
|
+
5. Test the record from the Data Model drawer.
|
|
12
|
+
6. Use the tested Data Source as the widget source.
|
|
13
|
+
|
|
14
|
+
## Object Types
|
|
15
|
+
|
|
16
|
+
### Data Source
|
|
17
|
+
|
|
18
|
+
Data Source rows represent the data a widget can consume.
|
|
19
|
+
|
|
20
|
+
Required fields:
|
|
21
|
+
|
|
22
|
+
- `Name`: display name.
|
|
23
|
+
- `registryId`: reference to an API Registry row by `integrationId`.
|
|
24
|
+
- `endpoint`: provider endpoint path or full URL.
|
|
25
|
+
- `authRef`: named secret reference, not the secret value.
|
|
26
|
+
- `baseUrl`: provider base URL.
|
|
27
|
+
- `status`: `untested`, `connected`, or `failed`.
|
|
28
|
+
- `lastTested`: timestamp from the latest test.
|
|
29
|
+
- `lastResponse`: saved JSON response shape from a successful test.
|
|
30
|
+
|
|
31
|
+
### API Registry
|
|
32
|
+
|
|
33
|
+
API Registry rows represent reusable API request configuration.
|
|
34
|
+
|
|
35
|
+
Required fields:
|
|
36
|
+
|
|
37
|
+
- `integrationId`: stable identifier used by Data Source `registryId`.
|
|
38
|
+
- `authRef`: named secret reference, not the secret value.
|
|
39
|
+
- `baseUrl`: provider base URL.
|
|
40
|
+
- `endpoint`: default endpoint path.
|
|
41
|
+
- `method`: HTTP method.
|
|
42
|
+
- `status`: connection status from testing.
|
|
43
|
+
- `lastTested`: timestamp from the latest test.
|
|
44
|
+
- `lastResponse`: saved JSON response shape.
|
|
45
|
+
- `entityTypes`: comma-separated source types this registry can power.
|
|
46
|
+
- `description`: human-readable notes.
|
|
47
|
+
|
|
48
|
+
## Credential Rules
|
|
49
|
+
|
|
50
|
+
Never add secret fields such as `apiKey`, `authToken`, password, or bearer token to Data Model objects.
|
|
51
|
+
|
|
52
|
+
Records store only `authRef`. The server resolves that reference to env/settings values. For example, an `authRef` of `LEADSHARK` can resolve to `LEADSHARK`, `LEADSHARK_API_KEY`, or `LEADSHARK_TOKEN`.
|
|
53
|
+
|
|
54
|
+
Provider-specific request headers belong in the server-side test route or provider adapter. The Data Model surface stays credential-free.
|
|
55
|
+
|
|
56
|
+
For APIs that do not use `x-api-key`, add non-secret request metadata to the API Registry row:
|
|
57
|
+
|
|
58
|
+
- `authHeaderName`: header name such as `Authorization`, `X-API-Token`, or `x-api-key`.
|
|
59
|
+
- `authPrefix`: optional value prefix such as `Bearer`.
|
|
60
|
+
|
|
61
|
+
Do not store the secret value in either field.
|
|
62
|
+
|
|
63
|
+
## Test Flow
|
|
64
|
+
|
|
65
|
+
The Data Model record drawer has a `Test connection` action for Data Source and API Registry rows.
|
|
66
|
+
|
|
67
|
+
For Data Source rows:
|
|
68
|
+
|
|
69
|
+
1. The server loads the referenced API Registry row from `registryId`.
|
|
70
|
+
2. It merges registry defaults with the Data Source row.
|
|
71
|
+
3. It resolves `authRef` server-side.
|
|
72
|
+
4. It sends the request from the API route.
|
|
73
|
+
5. A successful response saves `status: connected`, `lastTested`, and JSON `lastResponse`.
|
|
74
|
+
6. A failed response saves `status: failed` and must not mark the source connected.
|
|
75
|
+
|
|
76
|
+
Only rows with a successful test and parseable saved `lastResponse` qualify as configured sources.
|
|
77
|
+
|
|
78
|
+
## Widget Source Rules
|
|
79
|
+
|
|
80
|
+
Widget source pickers show Data Model objects from workspace config.
|
|
81
|
+
|
|
82
|
+
API Registry objects are not selectable as widget data sources. They are request configuration records.
|
|
83
|
+
|
|
84
|
+
Data Source objects are selectable only when at least one row is:
|
|
85
|
+
|
|
86
|
+
- `status` equal to `connected`, `approved`, `ok`, or `success`.
|
|
87
|
+
- `lastResponse` is valid saved JSON.
|
|
88
|
+
|
|
89
|
+
This ensures widgets bind only to tested, configured sources with a known returned shape.
|
|
90
|
+
|
|
91
|
+
## LeadShark Example
|
|
92
|
+
|
|
93
|
+
LeadShark uses:
|
|
94
|
+
|
|
95
|
+
- Base URL: `https://apex.leadshark.io/api`
|
|
96
|
+
- Leads endpoint: `/leads`
|
|
97
|
+
- Header: `x-api-key`
|
|
98
|
+
- Workspace secret reference: `LEADSHARK`
|
|
99
|
+
|
|
100
|
+
Example API Registry row:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"integrationId": "leadshark",
|
|
105
|
+
"authRef": "LEADSHARK",
|
|
106
|
+
"baseUrl": "https://apex.leadshark.io/api",
|
|
107
|
+
"endpoint": "/leads",
|
|
108
|
+
"method": "GET",
|
|
109
|
+
"status": "untested",
|
|
110
|
+
"entityTypes": "leads",
|
|
111
|
+
"description": "LeadShark leads API"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Example Data Source row:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"Name": "LeadShark Leads",
|
|
120
|
+
"registryId": "leadshark",
|
|
121
|
+
"endpoint": "/leads?page=1&limit=5",
|
|
122
|
+
"authRef": "LEADSHARK",
|
|
123
|
+
"baseUrl": "https://apex.leadshark.io/api",
|
|
124
|
+
"status": "untested"
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
After a successful test, `lastResponse` should contain the provider JSON response shape. For LeadShark leads, the expected top-level shape includes `data` and `pagination`.
|
|
129
|
+
|
|
130
|
+
## Scaling Pattern
|
|
131
|
+
|
|
132
|
+
This is intentionally API-agnostic. The same primitive works for any provider when it follows the same contract:
|
|
133
|
+
|
|
134
|
+
- One API Registry row per reusable provider request definition.
|
|
135
|
+
- One or more Data Source rows that reference registry records.
|
|
136
|
+
- No secrets in Data Model rows.
|
|
137
|
+
- Server-side test execution.
|
|
138
|
+
- Saved response shape after successful validation.
|
|
139
|
+
- Widgets bind only to tested Data Sources.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver dynamic loader — filesystem-safe, ESM-compatible.
|
|
3
|
+
*
|
|
4
|
+
* Reads every `.js` file from `lib/adapters/integrations/resolvers/` and
|
|
5
|
+
* side-effect-imports it so each file can call `registerSourceResolver()`.
|
|
6
|
+
*
|
|
7
|
+
* This runs server-side only. The browser never sees or calls this module.
|
|
8
|
+
* In read-only / production runtimes with no resolver files the registry
|
|
9
|
+
* stays empty — the refresh button and test route gracefully skip unknown
|
|
10
|
+
* integrationIds and surface them in the `skipped` array.
|
|
11
|
+
*
|
|
12
|
+
* Called once per route handler invocation (Next.js module cache means it
|
|
13
|
+
* will only do real I/O on the first call in each worker process).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
const loaded = new Set();
|
|
21
|
+
let loadAttempted = false;
|
|
22
|
+
|
|
23
|
+
async function loadAllResolvers() {
|
|
24
|
+
if (loadAttempted) return;
|
|
25
|
+
loadAttempted = true;
|
|
26
|
+
const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
|
|
27
|
+
try {
|
|
28
|
+
const entries = await fs.readdir(resolversDir);
|
|
29
|
+
const jsFiles = entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
|
|
30
|
+
await Promise.all(
|
|
31
|
+
jsFiles.map(async (file) => {
|
|
32
|
+
if (loaded.has(file)) return;
|
|
33
|
+
try {
|
|
34
|
+
const absolutePath = path.join(resolversDir, file);
|
|
35
|
+
await import(/*turbopackIgnore: true*/ pathToFileURL(absolutePath).href);
|
|
36
|
+
loaded.add(file);
|
|
37
|
+
} catch {
|
|
38
|
+
// Malformed resolver — skip silently; operator needs to fix the file
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
// resolvers directory missing or empty — normal for fresh upstream kit
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function listResolverFiles() {
|
|
48
|
+
const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
|
|
49
|
+
try {
|
|
50
|
+
const entries = await fs.readdir(resolversDir);
|
|
51
|
+
return entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { loadAllResolvers, listResolverFiles };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Integration Resolvers
|
|
2
|
+
|
|
3
|
+
Drop one `.js` file per integration here. Each file calls `registerSourceResolver()` once at module load.
|
|
4
|
+
|
|
5
|
+
**No resolver files ship in the upstream kit.** This directory is the extension point — operators add their own files for whatever integrations they connect (Asana, Linear, HubSpot, a custom API, a BYO token endpoint, a webhook, anything).
|
|
6
|
+
|
|
7
|
+
## Resolver shape
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { registerSourceResolver } from "../source-resolver-registry.js";
|
|
11
|
+
|
|
12
|
+
registerSourceResolver({
|
|
13
|
+
integrationId: "your-provider-slug", // must match binding.integrationId in dataModel
|
|
14
|
+
entityTypes: ["object.type"], // list of types this resolver handles
|
|
15
|
+
listEntities: async (config, connection) => NormalizedEntity[],
|
|
16
|
+
fetchRecords: async (config, connection, binding) => Record[]
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Agent CLI commands
|
|
21
|
+
|
|
22
|
+
All commands output JSON. Pipe through `| jq` for filtering.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# List registered resolver IDs and on-disk files
|
|
26
|
+
curl -s http://localhost:3000/api/workspace/resolvers
|
|
27
|
+
|
|
28
|
+
# Test a resolver without saving (returns preview rows or error reason)
|
|
29
|
+
curl -s -X POST http://localhost:3000/api/workspace/test-source \
|
|
30
|
+
-H "Content-Type: application/json" \
|
|
31
|
+
-d '{
|
|
32
|
+
"integrationId": "your-provider-slug",
|
|
33
|
+
"binding": {
|
|
34
|
+
"entityType": "your.entity.type",
|
|
35
|
+
"sourceStorage": "workspace-source-records",
|
|
36
|
+
"sourceId": "your-source-id"
|
|
37
|
+
}
|
|
38
|
+
}'
|
|
39
|
+
|
|
40
|
+
# Trigger a full refresh for one or more source IDs on the active tab
|
|
41
|
+
curl -s -X POST http://localhost:3000/api/workspace/refresh-sources \
|
|
42
|
+
-H "Content-Type: application/json" \
|
|
43
|
+
-d '{"sourceIds": ["your-source-id"]}'
|
|
44
|
+
|
|
45
|
+
# Register a data model object backed by this resolver (persists to growthub.config.json)
|
|
46
|
+
curl -s -X PATCH http://localhost:3000/api/workspace \
|
|
47
|
+
-H "Content-Type: application/json" \
|
|
48
|
+
-d '{
|
|
49
|
+
"dataModel": {
|
|
50
|
+
"objects": [
|
|
51
|
+
{
|
|
52
|
+
"id": "your-source-id",
|
|
53
|
+
"label": "Your Source Label",
|
|
54
|
+
"objectType": "your.object.type",
|
|
55
|
+
"storageType": "manual-object",
|
|
56
|
+
"columns": ["col1", "col2"],
|
|
57
|
+
"rows": [],
|
|
58
|
+
"sourceId": "your-source-id",
|
|
59
|
+
"binding": {
|
|
60
|
+
"mode": "integration",
|
|
61
|
+
"sourceStorage": "workspace-source-records",
|
|
62
|
+
"integrationId": "your-provider-slug",
|
|
63
|
+
"sourceId": "your-source-id",
|
|
64
|
+
"entityType": "your.entity.type"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}'
|
|
70
|
+
|
|
71
|
+
# Read back the full workspace config (data model objects, canvas, adapters)
|
|
72
|
+
curl -s http://localhost:3000/api/workspace
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Response contracts
|
|
76
|
+
|
|
77
|
+
**`GET /api/workspace/resolvers`**
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"files": ["google-analytics.js"],
|
|
81
|
+
"registeredIds": ["google-analytics"],
|
|
82
|
+
"canUpload": true
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**`POST /api/workspace/test-source` — resolver found, token missing**
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"ok": false,
|
|
90
|
+
"reason": "fetch-error",
|
|
91
|
+
"integrationId": "google-analytics",
|
|
92
|
+
"error": "GOOGLE_ANALYTICS_ACCESS_TOKEN is not set. Add it to .env.local to enable GA4 data refresh."
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**`POST /api/workspace/test-source` — resolver found, records returned**
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"ok": true,
|
|
100
|
+
"integrationId": "google-analytics",
|
|
101
|
+
"recordCount": 120,
|
|
102
|
+
"columns": ["date", "channel", "device", "sessions", "activeUsers", "bounceRate"],
|
|
103
|
+
"preview": [{ "date": "20260501", "channel": "Organic Search", "sessions": 412 }],
|
|
104
|
+
"entityTypes": ["ga4.traffic"]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**`POST /api/workspace/refresh-sources`**
|
|
109
|
+
```json
|
|
110
|
+
{ "refreshed": ["your-source-id"], "skipped": [] }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Data model and source dropdown flow
|
|
114
|
+
|
|
115
|
+
1. Add resolver file here → resolver registers on server start.
|
|
116
|
+
2. `PATCH /api/workspace` with a `dataModel.objects` entry that sets `binding.sourceStorage: "workspace-source-records"` and `binding.integrationId` matching this resolver.
|
|
117
|
+
3. Source dropdown in the workspace builder shows the object as a selectable dynamic source (resolver-backed objects appear in the **Dynamic sources** section).
|
|
118
|
+
4. User clicks Refresh on the tab bar → `POST /api/workspace/refresh-sources` → resolver `fetchRecords` runs → records persisted → widgets re-render.
|
|
119
|
+
|
|
120
|
+
## How the refresh button connects to this
|
|
121
|
+
|
|
122
|
+
1. User configures a `dataModel.object` with `binding.sourceStorage: "workspace-source-records"` and `binding.integrationId: "your-provider-slug"`.
|
|
123
|
+
2. User clicks Refresh on the tab bar.
|
|
124
|
+
3. The builder collects all `sourceId` values from live-backed widgets on the active tab and POSTs them to `/api/workspace/refresh-sources`.
|
|
125
|
+
4. The route looks up the resolver by `integrationId`, calls `fetchRecords`, and persists normalized records via `writeWorkspaceSourceRecords`.
|
|
126
|
+
5. The builder reloads from `/api/workspace` and the widgets render the updated rows.
|
|
127
|
+
|
|
128
|
+
## Auth contract
|
|
129
|
+
|
|
130
|
+
- **Tokens stay server-side.** Provider tokens live in the Growthub bridge / BYO env var store — never in workspace config or client state.
|
|
131
|
+
- The bridge confirms which integrations are connected. It does not proxy data. The resolver reads `process.env.YOUR_PROVIDER_TOKEN` and calls the provider API directly.
|
|
132
|
+
- `config` passed to your resolver is the server-side `adapterConfig` from `readAdapterConfig()`. Read `process.env.YOUR_TOKEN` server-side inside the resolver. Never forward it to the client.
|
|
133
|
+
- Normalize provider responses to display-safe records before returning — no raw API payloads, no token-adjacent fields.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Analytics 4 — Source Resolver
|
|
3
|
+
*
|
|
4
|
+
* Auth path:
|
|
5
|
+
* GROWTHUB_BRIDGE_ACCESS_TOKEN (set in .env.local by `growthub starter init`)
|
|
6
|
+
* → GET /api/cli/profile?view=integration&provider=google-analytics
|
|
7
|
+
* → short-lived Google OAuth accessToken
|
|
8
|
+
* → GA4 Admin API (accountSummaries → property list)
|
|
9
|
+
* → GA4 Data API (runReport)
|
|
10
|
+
*
|
|
11
|
+
* Required env vars (set automatically in .env.local):
|
|
12
|
+
* GROWTHUB_BRIDGE_ACCESS_TOKEN
|
|
13
|
+
* GROWTHUB_BRIDGE_USER_ID (optional — improves routing)
|
|
14
|
+
* GROWTHUB_BRIDGE_BASE_URL (default: https://www.growthub.ai)
|
|
15
|
+
*
|
|
16
|
+
* Optional env vars:
|
|
17
|
+
* GOOGLE_ANALYTICS_PROPERTY_ID override property for fetchRecords
|
|
18
|
+
* GOOGLE_ANALYTICS_DATE_RANGE_DAYS lookback window (default 30)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { registerSourceResolver } from "../source-resolver-registry.js";
|
|
22
|
+
|
|
23
|
+
const GA4_DATA_API = "https://analyticsdata.googleapis.com/v1beta";
|
|
24
|
+
const GA4_ADMIN_API = "https://analyticsadmin.googleapis.com/v1beta";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the GA4 access token.
|
|
28
|
+
*
|
|
29
|
+
* GROWTHUB_BRIDGE_ACCESS_TOKEN is the Google OAuth access token vended by the
|
|
30
|
+
* Growthub bridge for the connected google-analytics integration. It is used
|
|
31
|
+
* directly — no secondary bridge API call required.
|
|
32
|
+
*/
|
|
33
|
+
function resolveGA4AccessToken() {
|
|
34
|
+
const token = process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"GROWTHUB_BRIDGE_ACCESS_TOKEN is not set. Export it from growthub.ai/settings/connections."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return token;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* List all GA4 properties for the authenticated account.
|
|
45
|
+
*/
|
|
46
|
+
async function listGA4Properties(ga4Token) {
|
|
47
|
+
const res = await fetch(`${GA4_ADMIN_API}/accountSummaries`, {
|
|
48
|
+
headers: { Authorization: `Bearer ${ga4Token}`, Accept: "application/json" },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`GA4 Admin API ${res.status}: ${await res.text()}`);
|
|
52
|
+
}
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return (data.accountSummaries || []).flatMap((account) =>
|
|
55
|
+
(account.propertySummaries || []).map((prop) => ({
|
|
56
|
+
id: prop.property,
|
|
57
|
+
label: `${prop.displayName} — ${account.displayName}`,
|
|
58
|
+
type: "ga4.account",
|
|
59
|
+
meta: {
|
|
60
|
+
propertyId: prop.property,
|
|
61
|
+
accountId: account.account,
|
|
62
|
+
accountName: account.displayName,
|
|
63
|
+
},
|
|
64
|
+
}))
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a GA4 traffic report for the given property.
|
|
70
|
+
*/
|
|
71
|
+
async function fetchGA4Report(ga4Token, propertyId, days = 30) {
|
|
72
|
+
const body = {
|
|
73
|
+
dateRanges: [{ startDate: `${days}daysAgo`, endDate: "today" }],
|
|
74
|
+
dimensions: [
|
|
75
|
+
{ name: "date" },
|
|
76
|
+
{ name: "sessionDefaultChannelGroup" },
|
|
77
|
+
{ name: "deviceCategory" },
|
|
78
|
+
],
|
|
79
|
+
metrics: [
|
|
80
|
+
{ name: "sessions" },
|
|
81
|
+
{ name: "activeUsers" },
|
|
82
|
+
{ name: "bounceRate" },
|
|
83
|
+
{ name: "averageSessionDuration" },
|
|
84
|
+
{ name: "conversions" },
|
|
85
|
+
],
|
|
86
|
+
orderBys: [{ dimension: { dimensionName: "date" }, desc: true }],
|
|
87
|
+
limit: 500,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const res = await fetch(`${GA4_DATA_API}/${propertyId}:runReport`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${ga4Token}`,
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Accept: "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throw new Error(`GA4 Data API ${res.status}: ${await res.text()}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const report = await res.json();
|
|
105
|
+
const dimHeaders = (report.dimensionHeaders || []).map((h) => h.name);
|
|
106
|
+
const metHeaders = (report.metricHeaders || []).map((h) => h.name);
|
|
107
|
+
|
|
108
|
+
return (report.rows || []).map((row) => {
|
|
109
|
+
const dims = Object.fromEntries(
|
|
110
|
+
dimHeaders.map((name, i) => [name, row.dimensionValues?.[i]?.value ?? null])
|
|
111
|
+
);
|
|
112
|
+
const mets = Object.fromEntries(
|
|
113
|
+
metHeaders.map((name, i) => {
|
|
114
|
+
const raw = row.metricValues?.[i]?.value ?? null;
|
|
115
|
+
return [name, raw !== null ? Number(raw) : null];
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
return {
|
|
119
|
+
date: dims.date ?? null,
|
|
120
|
+
channel: dims.sessionDefaultChannelGroup ?? null,
|
|
121
|
+
device: dims.deviceCategory ?? null,
|
|
122
|
+
sessions: mets.sessions ?? 0,
|
|
123
|
+
activeUsers: mets.activeUsers ?? 0,
|
|
124
|
+
bounceRate: mets.bounceRate != null ? Number((mets.bounceRate * 100).toFixed(2)) : null,
|
|
125
|
+
avgSessionDuration: mets.averageSessionDuration != null
|
|
126
|
+
? Number(mets.averageSessionDuration.toFixed(1))
|
|
127
|
+
: null,
|
|
128
|
+
conversions: mets.conversions ?? 0,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
registerSourceResolver({
|
|
134
|
+
integrationId: "google-analytics",
|
|
135
|
+
|
|
136
|
+
entityTypes: ["ga4.traffic", "ga4.account"],
|
|
137
|
+
|
|
138
|
+
listEntities: async (_config, _connection) => {
|
|
139
|
+
const ga4Token = resolveGA4AccessToken();
|
|
140
|
+
return await listGA4Properties(ga4Token);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
fetchRecords: async (_config, _connection, binding) => {
|
|
144
|
+
const ga4Token = resolveGA4AccessToken();
|
|
145
|
+
|
|
146
|
+
const propertyId =
|
|
147
|
+
binding?.propertyId ||
|
|
148
|
+
process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
|
|
149
|
+
|
|
150
|
+
if (!propertyId) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"No GA4 property ID configured. Set binding.propertyId in the data model object " +
|
|
153
|
+
"or GOOGLE_ANALYTICS_PROPERTY_ID in .env.local."
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const days = Number(process.env.GOOGLE_ANALYTICS_DATE_RANGE_DAYS || 30);
|
|
158
|
+
return fetchGA4Report(ga4Token, propertyId, days);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source Resolver Registry — provider-agnostic dispatch layer.
|
|
3
|
+
*
|
|
4
|
+
* Contract:
|
|
5
|
+
* Each integration ships its own resolver file under
|
|
6
|
+
* `lib/adapters/integrations/resolvers/<id>.js` and calls
|
|
7
|
+
* `registerSourceResolver` once at module load time.
|
|
8
|
+
*
|
|
9
|
+
* Resolver shape:
|
|
10
|
+
* {
|
|
11
|
+
* integrationId: string, // stable provider slug, e.g. "asana"
|
|
12
|
+
* entityTypes: string[], // e.g. ["project.tasks", "workspace.users"]
|
|
13
|
+
* listEntities: async (config, connection) => NormalizedEntity[],
|
|
14
|
+
* fetchRecords: async (config, connection, binding) => Record[]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* The route and the refresh button reference this registry only — they have
|
|
18
|
+
* zero knowledge of Asana, Linear, HubSpot, or any other provider. Every
|
|
19
|
+
* provider-specific fetch lives in its own resolver file and is completely
|
|
20
|
+
* isolated from the others.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// globalThis singleton so resolver files loaded via dynamic file:// import
|
|
24
|
+
// share the same Map instance as the Next.js-bundled copy of this module.
|
|
25
|
+
if (!globalThis.__growthubSourceResolverRegistry) {
|
|
26
|
+
globalThis.__growthubSourceResolverRegistry = new Map();
|
|
27
|
+
}
|
|
28
|
+
const registry = globalThis.__growthubSourceResolverRegistry;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a source resolver. Called once per provider at module load.
|
|
32
|
+
* Calling again with the same integrationId replaces the existing resolver.
|
|
33
|
+
*/
|
|
34
|
+
function registerSourceResolver(resolver) {
|
|
35
|
+
if (!resolver || typeof resolver !== "object") {
|
|
36
|
+
throw new Error("registerSourceResolver: resolver must be a plain object");
|
|
37
|
+
}
|
|
38
|
+
if (typeof resolver.integrationId !== "string" || !resolver.integrationId.trim()) {
|
|
39
|
+
throw new Error("registerSourceResolver: resolver.integrationId must be a non-empty string");
|
|
40
|
+
}
|
|
41
|
+
if (typeof resolver.fetchRecords !== "function") {
|
|
42
|
+
throw new Error(`registerSourceResolver(${resolver.integrationId}): resolver.fetchRecords must be a function`);
|
|
43
|
+
}
|
|
44
|
+
registry.set(resolver.integrationId.trim(), resolver);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Look up a registered resolver by integration id.
|
|
49
|
+
* Returns null when no resolver has been registered for that id.
|
|
50
|
+
*/
|
|
51
|
+
function getSourceResolver(integrationId) {
|
|
52
|
+
if (typeof integrationId !== "string" || !integrationId.trim()) return null;
|
|
53
|
+
return registry.get(integrationId.trim()) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all registered integration ids. Useful for diagnostics.
|
|
58
|
+
*/
|
|
59
|
+
function listRegisteredResolvers() {
|
|
60
|
+
return Array.from(registry.keys());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Describe all registered resolvers — returns provider-agnostic metadata declared
|
|
65
|
+
* by each resolver file. The UI uses this to render generic controls without any
|
|
66
|
+
* knowledge of specific providers.
|
|
67
|
+
*
|
|
68
|
+
* Shape per entry:
|
|
69
|
+
* {
|
|
70
|
+
* integrationId: string,
|
|
71
|
+
* entityTypes: string[], // declared by the resolver
|
|
72
|
+
* hasListEntities: boolean, // true if resolver.listEntities is a function
|
|
73
|
+
* configSchema: SchemaField[] | null // optional declarative params schema
|
|
74
|
+
* }
|
|
75
|
+
*/
|
|
76
|
+
function describeRegisteredResolvers() {
|
|
77
|
+
return Array.from(registry.entries()).map(([id, resolver]) => ({
|
|
78
|
+
integrationId: id,
|
|
79
|
+
entityTypes: Array.isArray(resolver.entityTypes) ? resolver.entityTypes : [],
|
|
80
|
+
hasListEntities: typeof resolver.listEntities === "function",
|
|
81
|
+
configSchema: Array.isArray(resolver.configSchema) ? resolver.configSchema : null,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { registerSourceResolver, getSourceResolver, listRegisteredResolvers, describeRegisteredResolvers };
|
|
@@ -338,6 +338,82 @@ async function writeWorkspaceApiWebhookSettings(patch) {
|
|
|
338
338
|
return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Source Records persistence — sidecar store for live-backed dataModel objects.
|
|
343
|
+
*
|
|
344
|
+
* Records are written by POST /api/workspace/refresh-sources when a resolver
|
|
345
|
+
* fetches live data for a source with `binding.sourceStorage: "workspace-source-records"`.
|
|
346
|
+
*
|
|
347
|
+
* Persistence is keyed by `sourceId` and stored in a JSON sidecar file
|
|
348
|
+
* (`growthub.source-records.json`) beside `growthub.config.json`. The same
|
|
349
|
+
* filesystem / read-only / database mode rules apply: in read-only mode writes
|
|
350
|
+
* are rejected gracefully so the refresh button surface is disabled.
|
|
351
|
+
*
|
|
352
|
+
* Shape: { [sourceId]: { records: Record[], integrationId: string, fetchedAt: string } }
|
|
353
|
+
*/
|
|
354
|
+
|
|
355
|
+
const SOURCE_RECORDS_FILENAME = "growthub.source-records.json";
|
|
356
|
+
|
|
357
|
+
function resolveSourceRecordsPath() {
|
|
358
|
+
return path.resolve(/*turbopackIgnore: true*/ process.cwd(), SOURCE_RECORDS_FILENAME);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function readWorkspaceSourceRecords(sourceId) {
|
|
362
|
+
const recordsPath = resolveSourceRecordsPath();
|
|
363
|
+
try {
|
|
364
|
+
const raw = await fs.readFile(recordsPath, "utf8");
|
|
365
|
+
const all = JSON.parse(raw);
|
|
366
|
+
if (sourceId) {
|
|
367
|
+
return all[sourceId] || null;
|
|
368
|
+
}
|
|
369
|
+
return all;
|
|
370
|
+
} catch {
|
|
371
|
+
return sourceId ? null : {};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
|
|
376
|
+
const persistence = describePersistenceMode();
|
|
377
|
+
if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
|
|
378
|
+
const error = new Error(persistence.reason);
|
|
379
|
+
error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
|
|
380
|
+
error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
if (typeof sourceId !== "string" || !sourceId.trim()) {
|
|
384
|
+
const error = new Error("sourceId must be a non-empty string");
|
|
385
|
+
error.code = "INVALID_SOURCE_RECORDS_WRITE";
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
if (!Array.isArray(records)) {
|
|
389
|
+
const error = new Error("records must be an array");
|
|
390
|
+
error.code = "INVALID_SOURCE_RECORDS_WRITE";
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
const recordsPath = resolveSourceRecordsPath();
|
|
394
|
+
const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
|
|
395
|
+
if (path.dirname(recordsPath) !== expectedDir) {
|
|
396
|
+
const error = new Error(`refused to write outside workspace cwd: ${recordsPath}`);
|
|
397
|
+
error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
let all = {};
|
|
401
|
+
try {
|
|
402
|
+
const raw = await fs.readFile(recordsPath, "utf8");
|
|
403
|
+
all = JSON.parse(raw);
|
|
404
|
+
} catch {
|
|
405
|
+
all = {};
|
|
406
|
+
}
|
|
407
|
+
all[sourceId.trim()] = {
|
|
408
|
+
records,
|
|
409
|
+
integrationId: metadata.integrationId || null,
|
|
410
|
+
fetchedAt: metadata.fetchedAt || new Date().toISOString(),
|
|
411
|
+
recordCount: records.length
|
|
412
|
+
};
|
|
413
|
+
await fs.writeFile(recordsPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
|
|
414
|
+
return all[sourceId.trim()];
|
|
415
|
+
}
|
|
416
|
+
|
|
341
417
|
export {
|
|
342
418
|
GRID_COLUMNS,
|
|
343
419
|
GRID_ROWS,
|
|
@@ -346,9 +422,11 @@ export {
|
|
|
346
422
|
READ_ONLY_GUIDANCE,
|
|
347
423
|
describePersistenceMode,
|
|
348
424
|
readWorkspaceConfig,
|
|
425
|
+
readWorkspaceSourceRecords,
|
|
349
426
|
resolveWorkspaceConfigPath,
|
|
350
427
|
validateWorkspaceConfig,
|
|
351
428
|
writeWorkspaceConfig,
|
|
352
429
|
writeWorkspaceApiWebhookSettings,
|
|
353
|
-
writeWorkspaceIdentitySettings
|
|
430
|
+
writeWorkspaceIdentitySettings,
|
|
431
|
+
writeWorkspaceSourceRecords
|
|
354
432
|
};
|