@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src/",
@@ -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 file at config.ga.serviceAccountPath (GOOGLE_APPLICATION_CREDENTIALS)
5
- // provides one with no human in the loop — sign a JWT with it, swap that for an
6
- // access token. Nothing is persisted, so a restart costs one token exchange.
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 key files by path, access tokens by client_email, plus the in-flight
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
- // True when a key path is configured — gates the Insights tab, so an
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.serviceAccountPath);
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
- function serviceAccountKey(ga = {}) {
33
- const path = ga.serviceAccountPath;
56
+ const decoded = decodeBase64Json(value);
57
+ if (decoded) {
58
+ return {
59
+ key: decoded,
60
+ label: "GOOGLE_APPLICATION_CREDENTIALS (base64)",
61
+ };
62
+ }
34
63
 
35
- if (!path) {
64
+ try {
65
+ return { key: JSON.parse(readFileSync(value, "utf8")), label: value };
66
+ } catch (err) {
36
67
  throw new Error(
37
- "Google service-account key is not set. Point GOOGLE_APPLICATION_CREDENTIALS at the key file (surfaced via config.ga.serviceAccountPath).",
68
+ `Could not read the service-account key at ${value}: ${err.message}`,
38
69
  );
39
70
  }
71
+ }
40
72
 
41
- if (keyCache.has(path)) return keyCache.get(path);
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
- key = JSON.parse(readFileSync(path, "utf8"));
46
- } catch (err) {
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
- `Could not read the service-account key at ${path}: ${err.message}`,
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 at ${path} has no client_email / private_key.`,
103
+ `The service-account key from ${label} has no client_email / private_key.`,
55
104
  );
56
105
  }
57
106
 
58
- keyCache.set(path, key);
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.serviceAccountPath (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
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 SERVICE_ACCOUNT_PATH = "/tmp/service-account.json";
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
- serviceAccountPath: SERVICE_ACCOUNT_PATH,
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", serviceAccountPath: SERVICE_ACCOUNT_PATH },
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", serviceAccountPath: SERVICE_ACCOUNT_PATH },
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
- serviceAccountPath: "/tmp/service-account.json",
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
- serviceAccountPath: "/tmp/service-account.json",
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 written to a temp file — the module reads the key from
18
- // ga.serviceAccountPath, exactly like it does in the app.
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", serviceAccountPath: keyPath };
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 key path", () => {
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 file and calls the Data API with it", async () => {
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 key paths", async () => {
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", serviceAccountPath: missingPath },
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", serviceAccountPath: brokenPath },
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: { serviceAccountPath: keyPath },
277
+ ga: { serviceAccount: keyJson },
243
278
  }),
244
279
  ).rejects.toThrow(/GA property id is not set/);
245
280
  });
@@ -94,7 +94,7 @@
94
94
  const date = new Date();
95
95
 
96
96
 
97
- date.setDate(date.getDate() + 2);
97
+ date.setDate(date.getDate() + 3);
98
98
 
99
99
  // Skip weekends.
100
100
  while(date.getDay() === 0 || date.getDay() === 6) {