@raketa-cloud/admin 1.10.8 → 1.10.9
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 +7 -3
- package/src/helpers/reports/googleAuth.js +33 -3
- package/src/helpers/reports/quick-insights.js +1 -1
- package/src/helpers/reports/reportsUtils.js +1 -1
- package/src/tests/helpers/reports/quick-insights.test.js +101 -0
- package/src/views/resources/_shared/_history_toolbar.ejs +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raketa-cloud/admin",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.9",
|
|
4
4
|
"main": "src/index.js",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
5
8
|
"files": [
|
|
6
9
|
"src/",
|
|
7
10
|
"dist/"
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
"@tailwindcss/cli": "4.3.3",
|
|
27
30
|
"@tailwindcss/forms": "0.5.11",
|
|
28
31
|
"@tailwindcss/typography": "0.5.20",
|
|
29
|
-
"@types/node": "
|
|
32
|
+
"@types/node": "22.20.5",
|
|
30
33
|
"babel-jest": "30.5.2",
|
|
31
34
|
"jest": "30.5.2",
|
|
32
35
|
"jest-environment-node": "30.5.2",
|
|
@@ -37,8 +40,9 @@
|
|
|
37
40
|
"@hono/node-server": "2.0.12",
|
|
38
41
|
"chart.js": "4.5.1",
|
|
39
42
|
"ejs": "6.0.1",
|
|
43
|
+
"google-auth-library": "11.3.1",
|
|
40
44
|
"graphql-request": "7.4.0",
|
|
41
|
-
"hono": "4.13.
|
|
45
|
+
"hono": "4.13.13",
|
|
42
46
|
"lru-cache": "11.5.2"
|
|
43
47
|
},
|
|
44
48
|
"overrides": {
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
// with no human in the loop — sign a JWT with it, swap that for an access token.
|
|
6
6
|
// Nothing is persisted, so a restart costs one token exchange.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Without a key, config.ga.useAdc falls back to Application Default Credentials
|
|
9
|
+
// via google-auth-library — on Cloud Run / GCE / GKE that is the attached
|
|
10
|
+
// service account through the metadata server. A key, when set, always wins.
|
|
11
|
+
//
|
|
12
|
+
// The key's client_email (or the ADC principal) needs Viewer access on the GA
|
|
13
|
+
// property.
|
|
9
14
|
import { createSign } from "node:crypto";
|
|
10
15
|
import { readFileSync } from "node:fs";
|
|
16
|
+
import { GoogleAuth } from "google-auth-library";
|
|
11
17
|
|
|
12
18
|
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
13
19
|
const JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
@@ -24,12 +30,16 @@ const keyCache = new Map();
|
|
|
24
30
|
const tokenCache = new Map();
|
|
25
31
|
const pendingTokens = new Map();
|
|
26
32
|
|
|
33
|
+
// Single ADC client: it owns its own token cache and refresh.
|
|
34
|
+
let adcAuth = null;
|
|
35
|
+
|
|
27
36
|
const credentialSource = (ga = {}) => ga.serviceAccount;
|
|
37
|
+
const adcEnabled = (ga = {}) => Boolean(ga.useAdc);
|
|
28
38
|
|
|
29
39
|
// True when a credential is configured — gates the Insights tab, so an
|
|
30
40
|
// unconfigured admin sees no GA UI at all.
|
|
31
41
|
export const serviceAccountConfigured = (ga = {}) =>
|
|
32
|
-
Boolean(credentialSource(ga));
|
|
42
|
+
Boolean(credentialSource(ga)) || adcEnabled(ga);
|
|
33
43
|
|
|
34
44
|
// GOOGLE_APPLICATION_CREDENTIALS now holds the whole key JSON rather than a path
|
|
35
45
|
// to it, so a source can be: an already-parsed object, the JSON itself, that
|
|
@@ -88,7 +98,7 @@ function serviceAccountKey(ga = {}) {
|
|
|
88
98
|
|
|
89
99
|
if (!source) {
|
|
90
100
|
throw new Error(
|
|
91
|
-
"Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount).",
|
|
101
|
+
"Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount), or enable Application Default Credentials via config.ga.useAdc.",
|
|
92
102
|
);
|
|
93
103
|
}
|
|
94
104
|
|
|
@@ -139,6 +149,8 @@ function signAssertion(key) {
|
|
|
139
149
|
// they would each mint their own token. The in-flight exchange is shared: the
|
|
140
150
|
// first caller starts it, the rest await the same promise.
|
|
141
151
|
export async function accessToken(ga) {
|
|
152
|
+
if (!credentialSource(ga) && adcEnabled(ga)) return adcAccessToken();
|
|
153
|
+
|
|
142
154
|
const key = serviceAccountKey(ga);
|
|
143
155
|
|
|
144
156
|
const cached = tokenCache.get(key.client_email);
|
|
@@ -190,9 +202,27 @@ async function requestToken(key) {
|
|
|
190
202
|
return json.access_token;
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
async function adcAccessToken() {
|
|
206
|
+
adcAuth ??= new GoogleAuth({ scopes: [SCOPE] });
|
|
207
|
+
|
|
208
|
+
let token;
|
|
209
|
+
try {
|
|
210
|
+
token = await adcAuth.getAccessToken();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
throw new Error(`Google ADC token request failed: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!token) {
|
|
216
|
+
throw new Error("Google ADC token response contained no access_token.");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return token;
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
// Test / credential-rotation helper.
|
|
194
223
|
export function clearAuthCache() {
|
|
195
224
|
keyCache.clear();
|
|
196
225
|
tokenCache.clear();
|
|
197
226
|
pendingTokens.clear();
|
|
227
|
+
adcAuth = null;
|
|
198
228
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Quick Insights for a single page path: summary KPIs with period-over-period
|
|
3
3
|
// trend plus a daily page-views series, both over the last 30 days.
|
|
4
4
|
//
|
|
5
|
-
// `ga` below is the whole config.ga object — property id plus the
|
|
5
|
+
// `ga` below is the whole config.ga object — property id plus the credentials the
|
|
6
6
|
// bearer token is minted from (see ./googleAuth).
|
|
7
7
|
import localCache from "../../localCache";
|
|
8
8
|
import { accessToken } from "./googleAuth";
|
|
@@ -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
|
|
138
|
+
"Google Analytics is not configured. Set config.ga.propertyId plus config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) or config.ga.useAdc to enable reports.",
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
const pagePath = gaPagePath(ctx, resource, resourceType);
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
+
import { GoogleAuth } from "google-auth-library";
|
|
6
7
|
import {
|
|
7
8
|
clearAuthCache,
|
|
8
9
|
serviceAccountConfigured,
|
|
@@ -14,6 +15,14 @@ import {
|
|
|
14
15
|
} from "../../../helpers/reports/quick-insights";
|
|
15
16
|
import localCache from "../../../localCache";
|
|
16
17
|
|
|
18
|
+
// Only the ADC path goes through google-auth-library; the key path signs its
|
|
19
|
+
// own JWT against the mocked fetch below, so it never touches this.
|
|
20
|
+
const mockGetAccessToken = jest.fn();
|
|
21
|
+
|
|
22
|
+
jest.mock("google-auth-library", () => ({
|
|
23
|
+
GoogleAuth: jest.fn(() => ({ getAccessToken: mockGetAccessToken })),
|
|
24
|
+
}));
|
|
25
|
+
|
|
17
26
|
// One throwaway RSA key. The app passes the whole key JSON through
|
|
18
27
|
// ga.serviceAccount, so that is the form used here; the same key is also written
|
|
19
28
|
// to a temp file, since that config value also accepts a path.
|
|
@@ -280,6 +289,98 @@ describe("quick-insights auth", () => {
|
|
|
280
289
|
});
|
|
281
290
|
});
|
|
282
291
|
|
|
292
|
+
describe("quick-insights ADC auth", () => {
|
|
293
|
+
const ADC_GA = { propertyId: "1234", useAdc: true };
|
|
294
|
+
|
|
295
|
+
const ARGS = {
|
|
296
|
+
pagePath: "/pricing",
|
|
297
|
+
startDate: "2026-07-01",
|
|
298
|
+
endDate: "2026-07-01",
|
|
299
|
+
ga: ADC_GA,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
clearAuthCache();
|
|
304
|
+
jest.clearAllMocks();
|
|
305
|
+
mockGetAccessToken.mockResolvedValue("adc-token");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
afterEach(() => {
|
|
309
|
+
delete global.fetch;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("serviceAccountConfigured accepts ADC in place of a key", () => {
|
|
313
|
+
expect(serviceAccountConfigured({ useAdc: true })).toBe(true);
|
|
314
|
+
expect(serviceAccountConfigured({ useAdc: false })).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("calls the Data API with the ADC token, no JWT exchange", async () => {
|
|
318
|
+
mockGoogle();
|
|
319
|
+
|
|
320
|
+
const result = await runPageReport(ARGS);
|
|
321
|
+
|
|
322
|
+
expect(GoogleAuth).toHaveBeenCalledWith({
|
|
323
|
+
scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
|
|
324
|
+
});
|
|
325
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
326
|
+
|
|
327
|
+
const [reportUrl, reportOptions] = global.fetch.mock.calls[0];
|
|
328
|
+
expect(reportUrl).toBe(
|
|
329
|
+
"https://analyticsdata.googleapis.com/v1beta/properties/1234:runReport",
|
|
330
|
+
);
|
|
331
|
+
expect(reportOptions.headers.authorization).toBe("Bearer adc-token");
|
|
332
|
+
expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a configured key wins over ADC", async () => {
|
|
336
|
+
mockGoogle();
|
|
337
|
+
|
|
338
|
+
await runPageReport({
|
|
339
|
+
...ARGS,
|
|
340
|
+
ga: { ...ADC_GA, serviceAccount: keyJson },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(GoogleAuth).not.toHaveBeenCalled();
|
|
344
|
+
expect(global.fetch.mock.calls[0][0]).toBe(
|
|
345
|
+
"https://oauth2.googleapis.com/token",
|
|
346
|
+
);
|
|
347
|
+
expect(global.fetch.mock.calls[1][1].headers.authorization).toBe(
|
|
348
|
+
"Bearer at-1",
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("reuses one ADC client until the cache is cleared", async () => {
|
|
353
|
+
mockGoogle();
|
|
354
|
+
|
|
355
|
+
await runPageReport(ARGS);
|
|
356
|
+
await runPageReport(ARGS);
|
|
357
|
+
expect(GoogleAuth).toHaveBeenCalledTimes(1);
|
|
358
|
+
expect(mockGetAccessToken).toHaveBeenCalledTimes(2);
|
|
359
|
+
|
|
360
|
+
clearAuthCache();
|
|
361
|
+
await runPageReport(ARGS);
|
|
362
|
+
expect(GoogleAuth).toHaveBeenCalledTimes(2);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("surfaces ADC token failures", async () => {
|
|
366
|
+
mockGoogle();
|
|
367
|
+
|
|
368
|
+
mockGetAccessToken.mockRejectedValueOnce(
|
|
369
|
+
new Error("Could not load the default credentials"),
|
|
370
|
+
);
|
|
371
|
+
await expect(runPageReport(ARGS)).rejects.toThrow(
|
|
372
|
+
/Google ADC token request failed: Could not load the default credentials/,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
mockGetAccessToken.mockResolvedValueOnce(null);
|
|
376
|
+
await expect(runPageReport(ARGS)).rejects.toThrow(
|
|
377
|
+
/Google ADC token response contained no access_token/,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
283
384
|
describe("runSummary", () => {
|
|
284
385
|
const metrics = (views, users, engagementSeconds, rate, sessions) => ({
|
|
285
386
|
metricValues: [views, users, engagementSeconds, rate, sessions].map(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div class="actions-toolbar
|
|
1
|
+
<div class="justify-end actions-toolbar">
|
|
2
2
|
<form method="POST" action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/${_context.resourceConfig.resourceType}_changes/reverts` %>" class="inline-block">
|
|
3
3
|
<input type="hidden" name="version" value="<%= change.version %>">
|
|
4
4
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Revert
|
|
8
8
|
</button>
|
|
9
9
|
<% } else { %>
|
|
10
|
-
<button type="submit" disabled class="
|
|
10
|
+
<button type="submit" disabled class="opacity-50 cursor-not-allowed btn-text">
|
|
11
11
|
Revert
|
|
12
12
|
</button>
|
|
13
13
|
<% } %>
|