@raketa-cloud/admin 1.9.1 → 1.9.3
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/package.json +1 -1
- package/src/helpers/reports/googleAuth.js +67 -18
- package/src/helpers/reports/reportsUtils.js +1 -1
- package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +5 -4
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +2 -2
- package/src/tests/helpers/reports/quick-insights.test.js +53 -18
- package/src/views/resources/translations/create_form.ejs +1 -1
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Service-account auth for the GA4 Data API (the Insights tab).
|
|
2
2
|
//
|
|
3
3
|
// GA is per-property authorized: it rejects API keys and needs a principal. The
|
|
4
|
-
// key
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// key at config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) provides one
|
|
5
|
+
// with no human in the loop — sign a JWT with it, swap that for an access token.
|
|
6
|
+
// Nothing is persisted, so a restart costs one token exchange.
|
|
7
7
|
//
|
|
8
8
|
// The key's client_email needs Viewer access on the GA property.
|
|
9
9
|
import { createSign } from "node:crypto";
|
|
@@ -18,44 +18,93 @@ const SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
|
|
|
18
18
|
const TOKEN_LIFETIME_S = 3600;
|
|
19
19
|
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
20
20
|
|
|
21
|
-
// Parsed
|
|
22
|
-
// token exchange per client_email (see accessToken).
|
|
21
|
+
// Parsed keys by credential source, access tokens by client_email, plus the
|
|
22
|
+
// in-flight token exchange per client_email (see accessToken).
|
|
23
23
|
const keyCache = new Map();
|
|
24
24
|
const tokenCache = new Map();
|
|
25
25
|
const pendingTokens = new Map();
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const credentialSource = (ga = {}) => ga.serviceAccount;
|
|
28
|
+
|
|
29
|
+
// True when a credential is configured — gates the Insights tab, so an
|
|
28
30
|
// unconfigured admin sees no GA UI at all.
|
|
29
31
|
export const serviceAccountConfigured = (ga = {}) =>
|
|
30
|
-
Boolean(ga
|
|
32
|
+
Boolean(credentialSource(ga));
|
|
33
|
+
|
|
34
|
+
// GOOGLE_APPLICATION_CREDENTIALS now holds the whole key JSON rather than a path
|
|
35
|
+
// to it, so a source can be: an already-parsed object, the JSON itself, that
|
|
36
|
+
// JSON base64-encoded (what most hosts accept without quoting headaches), or a
|
|
37
|
+
// file path.
|
|
38
|
+
function parseCredentialSource(source) {
|
|
39
|
+
if (typeof source === "object") return { key: source, label: "config" };
|
|
40
|
+
|
|
41
|
+
const value = source.trim();
|
|
42
|
+
|
|
43
|
+
if (value.startsWith("{")) {
|
|
44
|
+
try {
|
|
45
|
+
return {
|
|
46
|
+
key: JSON.parse(value),
|
|
47
|
+
label: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Could not parse the service-account key JSON in GOOGLE_APPLICATION_CREDENTIALS: ${err.message}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
31
55
|
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
const decoded = decodeBase64Json(value);
|
|
57
|
+
if (decoded) {
|
|
58
|
+
return {
|
|
59
|
+
key: decoded,
|
|
60
|
+
label: "GOOGLE_APPLICATION_CREDENTIALS (base64)",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
34
63
|
|
|
35
|
-
|
|
64
|
+
try {
|
|
65
|
+
return { key: JSON.parse(readFileSync(value, "utf8")), label: value };
|
|
66
|
+
} catch (err) {
|
|
36
67
|
throw new Error(
|
|
37
|
-
|
|
68
|
+
`Could not read the service-account key at ${value}: ${err.message}`,
|
|
38
69
|
);
|
|
39
70
|
}
|
|
71
|
+
}
|
|
40
72
|
|
|
41
|
-
|
|
73
|
+
// Only claims a value as base64 when it decodes to a JSON object — anything
|
|
74
|
+
// else is a path, and a path must keep reporting a read error, not a parse one.
|
|
75
|
+
function decodeBase64Json(value) {
|
|
76
|
+
if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) return null;
|
|
42
77
|
|
|
43
|
-
let key;
|
|
44
78
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
79
|
+
const decoded = Buffer.from(value, "base64").toString("utf8").trim();
|
|
80
|
+
return decoded.startsWith("{") ? JSON.parse(decoded) : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serviceAccountKey(ga = {}) {
|
|
87
|
+
const source = credentialSource(ga);
|
|
88
|
+
|
|
89
|
+
if (!source) {
|
|
47
90
|
throw new Error(
|
|
48
|
-
|
|
91
|
+
"Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount).",
|
|
49
92
|
);
|
|
50
93
|
}
|
|
51
94
|
|
|
95
|
+
if (typeof source === "string" && keyCache.has(source)) {
|
|
96
|
+
return keyCache.get(source);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { key, label } = parseCredentialSource(source);
|
|
100
|
+
|
|
52
101
|
if (!key.client_email || !key.private_key) {
|
|
53
102
|
throw new Error(
|
|
54
|
-
`The service-account key
|
|
103
|
+
`The service-account key from ${label} has no client_email / private_key.`,
|
|
55
104
|
);
|
|
56
105
|
}
|
|
57
106
|
|
|
58
|
-
keyCache.set(
|
|
107
|
+
if (typeof source === "string") keyCache.set(source, key);
|
|
59
108
|
return key;
|
|
60
109
|
}
|
|
61
110
|
|
|
@@ -135,7 +135,7 @@ async function fetchQuickInsights(ctx, resource, resourceType) {
|
|
|
135
135
|
try {
|
|
136
136
|
if (!gaEnabled(ctx)) {
|
|
137
137
|
throw new Error(
|
|
138
|
-
"Google Analytics is not configured. Set config.ga.propertyId and config.ga.
|
|
138
|
+
"Google Analytics is not configured. Set config.ga.propertyId and config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
const pagePath = gaPagePath(ctx, resource, resourceType);
|
|
@@ -15,7 +15,8 @@ import ReportsAPIHandler from "../../../../handlers/resources/ReportsAPIHandler"
|
|
|
15
15
|
|
|
16
16
|
import Factory from "../../../support/factory";
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const SERVICE_ACCOUNT =
|
|
19
|
+
'{"client_email":"reports@project.iam.gserviceaccount.com","private_key":"pem"}';
|
|
19
20
|
|
|
20
21
|
describe("Reports API Handler", () => {
|
|
21
22
|
function buildCtx(resource, { ga, reports = true } = {}) {
|
|
@@ -63,7 +64,7 @@ describe("Reports API Handler", () => {
|
|
|
63
64
|
const ctx = buildCtx(resource, {
|
|
64
65
|
ga: {
|
|
65
66
|
propertyId: "1234",
|
|
66
|
-
|
|
67
|
+
serviceAccount: SERVICE_ACCOUNT,
|
|
67
68
|
apiKey: "crux-key",
|
|
68
69
|
},
|
|
69
70
|
});
|
|
@@ -98,7 +99,7 @@ describe("Reports API Handler", () => {
|
|
|
98
99
|
test("does not cache the assembled payload", async () => {
|
|
99
100
|
const resource = Factory.build("resource", { translations: {} });
|
|
100
101
|
const ctx = buildCtx(resource, {
|
|
101
|
-
ga: { propertyId: "1234",
|
|
102
|
+
ga: { propertyId: "1234", serviceAccount: SERVICE_ACCOUNT },
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
@@ -117,7 +118,7 @@ describe("Reports API Handler", () => {
|
|
|
117
118
|
test("passes a failed side through as an error", async () => {
|
|
118
119
|
const resource = Factory.build("resource", { translations: {} });
|
|
119
120
|
const ctx = buildCtx(resource, {
|
|
120
|
-
ga: { propertyId: "1234",
|
|
121
|
+
ga: { propertyId: "1234", serviceAccount: SERVICE_ACCOUNT },
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
@@ -46,7 +46,7 @@ describe("Resources Handler show reports section", () => {
|
|
|
46
46
|
const resource = Factory.build("resource", { translations: {} });
|
|
47
47
|
const ctx = buildCtx(resource, {
|
|
48
48
|
propertyId: "1234",
|
|
49
|
-
|
|
49
|
+
serviceAccount: "{}",
|
|
50
50
|
apiKey: "crux-key",
|
|
51
51
|
});
|
|
52
52
|
mockClient(ctx, resource);
|
|
@@ -65,7 +65,7 @@ describe("Resources Handler show reports section", () => {
|
|
|
65
65
|
const resource = Factory.build("resource", { translations: {} });
|
|
66
66
|
const ctx = buildCtx(resource, {
|
|
67
67
|
propertyId: "1234",
|
|
68
|
-
|
|
68
|
+
serviceAccount: "{}",
|
|
69
69
|
});
|
|
70
70
|
mockClient(ctx, resource);
|
|
71
71
|
|
|
@@ -14,31 +14,31 @@ import {
|
|
|
14
14
|
} from "../../../helpers/reports/quick-insights";
|
|
15
15
|
import localCache from "../../../localCache";
|
|
16
16
|
|
|
17
|
-
// One throwaway RSA key
|
|
18
|
-
// ga.
|
|
17
|
+
// One throwaway RSA key. The app passes the whole key JSON through
|
|
18
|
+
// ga.serviceAccount, so that is the form used here; the same key is also written
|
|
19
|
+
// to a temp file, since that config value also accepts a path.
|
|
19
20
|
const { privateKey } = generateKeyPairSync("rsa", {
|
|
20
21
|
modulusLength: 2048,
|
|
21
22
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
22
23
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
const keyJson = JSON.stringify({
|
|
27
|
+
type: "service_account",
|
|
28
|
+
client_email: "reports@project.iam.gserviceaccount.com",
|
|
29
|
+
private_key_id: "key-1",
|
|
30
|
+
private_key: privateKey,
|
|
31
|
+
});
|
|
32
|
+
|
|
25
33
|
const dir = mkdtempSync(join(tmpdir(), "quick-insights-"));
|
|
26
34
|
const keyPath = join(dir, "service-account.json");
|
|
27
35
|
const missingPath = join(dir, "nope.json");
|
|
28
36
|
const brokenPath = join(dir, "broken.json");
|
|
29
37
|
|
|
30
|
-
writeFileSync(
|
|
31
|
-
keyPath,
|
|
32
|
-
JSON.stringify({
|
|
33
|
-
type: "service_account",
|
|
34
|
-
client_email: "reports@project.iam.gserviceaccount.com",
|
|
35
|
-
private_key_id: "key-1",
|
|
36
|
-
private_key: privateKey,
|
|
37
|
-
}),
|
|
38
|
-
);
|
|
38
|
+
writeFileSync(keyPath, keyJson);
|
|
39
39
|
writeFileSync(brokenPath, JSON.stringify({ client_email: "a@b.c" }));
|
|
40
40
|
|
|
41
|
-
const GA = { propertyId: "1234",
|
|
41
|
+
const GA = { propertyId: "1234", serviceAccount: keyJson };
|
|
42
42
|
|
|
43
43
|
const reportBody = {
|
|
44
44
|
rows: [
|
|
@@ -80,13 +80,13 @@ describe("quick-insights auth", () => {
|
|
|
80
80
|
delete global.fetch;
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test("serviceAccountConfigured follows the
|
|
83
|
+
test("serviceAccountConfigured follows the credential", () => {
|
|
84
84
|
expect(serviceAccountConfigured(GA)).toBe(true);
|
|
85
85
|
expect(serviceAccountConfigured({})).toBe(false);
|
|
86
86
|
expect(serviceAccountConfigured()).toBe(false);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
test("signs a JWT from the key
|
|
89
|
+
test("signs a JWT from the key JSON and calls the Data API with it", async () => {
|
|
90
90
|
mockGoogle();
|
|
91
91
|
|
|
92
92
|
const result = await runPageReport({
|
|
@@ -129,6 +129,29 @@ describe("quick-insights auth", () => {
|
|
|
129
129
|
expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Hosts that dislike raw JSON in an env var get the base64 form; a path is
|
|
133
|
+
// still accepted for anything pointing at a key file.
|
|
134
|
+
test.each([
|
|
135
|
+
["base64 key JSON", Buffer.from(keyJson).toString("base64")],
|
|
136
|
+
["a key file path", keyPath],
|
|
137
|
+
])("accepts %s", async (_label, serviceAccount) => {
|
|
138
|
+
mockGoogle();
|
|
139
|
+
|
|
140
|
+
const result = await runPageReport({
|
|
141
|
+
pagePath: "/pricing",
|
|
142
|
+
startDate: "2026-07-01",
|
|
143
|
+
endDate: "2026-07-01",
|
|
144
|
+
ga: { propertyId: "1234", serviceAccount },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const [, tokenOptions] = global.fetch.mock.calls[0];
|
|
148
|
+
const [, claims] = Object.fromEntries(tokenOptions.body).assertion.split(
|
|
149
|
+
".",
|
|
150
|
+
);
|
|
151
|
+
expect(decode(claims).iss).toBe("reports@project.iam.gserviceaccount.com");
|
|
152
|
+
expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
132
155
|
test("reuses the access token across reports", async () => {
|
|
133
156
|
mockGoogle();
|
|
134
157
|
|
|
@@ -203,7 +226,7 @@ describe("quick-insights auth", () => {
|
|
|
203
226
|
await expect(runPageReport(args)).rejects.toThrow(/GA Data API 403/);
|
|
204
227
|
});
|
|
205
228
|
|
|
206
|
-
test("reports unusable
|
|
229
|
+
test("reports unusable credentials", async () => {
|
|
207
230
|
// The property id is checked while building the request URL, so every case
|
|
208
231
|
// here keeps it set — otherwise that error would mask the key one.
|
|
209
232
|
mockGoogle();
|
|
@@ -220,13 +243,25 @@ describe("quick-insights auth", () => {
|
|
|
220
243
|
await expect(
|
|
221
244
|
runPageReport({
|
|
222
245
|
...args,
|
|
223
|
-
ga: { propertyId: "1234",
|
|
246
|
+
ga: { propertyId: "1234", serviceAccount: '{"client_email":' },
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/Could not parse the service-account key JSON/);
|
|
249
|
+
await expect(
|
|
250
|
+
runPageReport({
|
|
251
|
+
...args,
|
|
252
|
+
ga: { propertyId: "1234", serviceAccount: '{"client_email":"a@b.c"}' },
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toThrow(/no client_email \/ private_key/);
|
|
255
|
+
await expect(
|
|
256
|
+
runPageReport({
|
|
257
|
+
...args,
|
|
258
|
+
ga: { propertyId: "1234", serviceAccount: missingPath },
|
|
224
259
|
}),
|
|
225
260
|
).rejects.toThrow(/Could not read the service-account key/);
|
|
226
261
|
await expect(
|
|
227
262
|
runPageReport({
|
|
228
263
|
...args,
|
|
229
|
-
ga: { propertyId: "1234",
|
|
264
|
+
ga: { propertyId: "1234", serviceAccount: brokenPath },
|
|
230
265
|
}),
|
|
231
266
|
).rejects.toThrow(/no client_email \/ private_key/);
|
|
232
267
|
});
|
|
@@ -239,7 +274,7 @@ describe("quick-insights auth", () => {
|
|
|
239
274
|
pagePath: "/pricing",
|
|
240
275
|
startDate: "2026-07-01",
|
|
241
276
|
endDate: "2026-07-01",
|
|
242
|
-
ga: {
|
|
277
|
+
ga: { serviceAccount: keyJson },
|
|
243
278
|
}),
|
|
244
279
|
).rejects.toThrow(/GA property id is not set/);
|
|
245
280
|
});
|