@raketa-cloud/admin 1.9.0 → 1.9.2

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.0",
3
+ "version": "1.9.2",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src/",
@@ -1,3 +1,4 @@
1
+ import { buildMagicPreviewAddress } from "../magicPreview";
1
2
  import { toDateInput, toDateTimeInput, toUTCISO8601String } from "../utils";
2
3
  import RaketaClient from "./RaketaClient";
3
4
  import {
@@ -162,6 +163,14 @@ export default class Resources extends RaketaClient {
162
163
 
163
164
  input.workflow = {
164
165
  type: workflow.type,
166
+ previewUrl: buildMagicPreviewAddress(
167
+ {
168
+ slug: input.resource.slug,
169
+ locale: input.locale,
170
+ resourceType: input.resource.resourceType,
171
+ },
172
+ this.getConfig(),
173
+ ),
165
174
  };
166
175
 
167
176
  if (workflow.due_date) {
@@ -25,7 +25,12 @@ const AuthHelper = {
25
25
  isAuthenticated: ({ path, token }) => {
26
26
  const ALLOWED_PATHS = ["/login", "/auth/google/callback", "/remote/verify"];
27
27
 
28
- if (ALLOWED_PATHS.includes(path)) return true;
28
+ if (
29
+ ALLOWED_PATHS.includes(path) ||
30
+ path?.startsWith("/remote/magic-preview/")
31
+ ) {
32
+ return true;
33
+ }
29
34
 
30
35
  if (token && AuthHelper.verifyJWT(token)) return true;
31
36
 
@@ -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);
@@ -0,0 +1,103 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "node:crypto";
7
+
8
+ const ALGORITHM = "aes-256-gcm";
9
+ const IV_LENGTH = 12;
10
+ const TAG_LENGTH = 16;
11
+
12
+ function getSecretKey(config = {}) {
13
+ const secret = config.secrets.crypto_secret;
14
+
15
+ if (!secret) {
16
+ throw new Error(
17
+ "crypto_secret needs to be setup in the config for the magic preview feature to work",
18
+ );
19
+ }
20
+
21
+ return createHash("sha256").update(secret).digest();
22
+ }
23
+
24
+ export function generateMagicPreviewToken(
25
+ { slug, resourceType, locale } = {},
26
+ config = {},
27
+ ) {
28
+ const payload = {
29
+ slug,
30
+ resourceType,
31
+ locale,
32
+ };
33
+
34
+ const key = getSecretKey(config);
35
+ const iv = randomBytes(IV_LENGTH);
36
+ const cipher = createCipheriv(ALGORITHM, key, iv);
37
+ const encrypted = Buffer.concat([
38
+ cipher.update(JSON.stringify(payload), "utf8"),
39
+ cipher.final(),
40
+ ]);
41
+ const tag = cipher.getAuthTag();
42
+
43
+ return Buffer.concat([iv, tag, encrypted]).toString("base64url");
44
+ }
45
+
46
+ export function decipherMagicPreviewToken(token, config) {
47
+ if (!token || typeof token !== "string") {
48
+ return null;
49
+ }
50
+
51
+ try {
52
+ const key = getSecretKey(config);
53
+ const combined = Buffer.from(token, "base64url");
54
+
55
+ if (combined.length < IV_LENGTH + TAG_LENGTH) {
56
+ return null;
57
+ }
58
+
59
+ const iv = combined.subarray(0, IV_LENGTH);
60
+ const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
61
+ const encrypted = combined.subarray(IV_LENGTH + TAG_LENGTH);
62
+
63
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
64
+ decipher.setAuthTag(tag);
65
+
66
+ const decrypted = Buffer.concat([
67
+ decipher.update(encrypted),
68
+ decipher.final(),
69
+ ]).toString("utf8");
70
+
71
+ const payload = JSON.parse(decrypted);
72
+
73
+ if (!payload || typeof payload !== "object" || !payload.slug) {
74
+ return null;
75
+ }
76
+
77
+ return payload;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export function buildMagicPreviewAddress(
84
+ { slug, resourceType, locale },
85
+ globalConfig,
86
+ ) {
87
+ if (!slug || !resourceType || !locale) {
88
+ throw new Error(
89
+ "Can't generate a token without a slug, resourceType or locale!",
90
+ );
91
+ }
92
+
93
+ const token = generateMagicPreviewToken(
94
+ {
95
+ slug,
96
+ resourceType,
97
+ locale,
98
+ },
99
+ globalConfig,
100
+ );
101
+
102
+ return globalConfig.remoteConfig.magicPreviewUrl(token, resourceType, locale);
103
+ }
@@ -3,6 +3,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
3
  import currentPath from "./currentPath.cjs";
4
4
  import AuthHelper from "./helpers/AuthHelper";
5
5
  import cipher from "./cipher";
6
+ import { decipherMagicPreviewToken } from "./magicPreview";
6
7
 
7
8
  const DELIMITER = ",";
8
9
 
@@ -149,6 +150,17 @@ const setupAuthentication = (app, config) => {
149
150
 
150
151
  return c.json({ token });
151
152
  });
153
+
154
+ app.get("/remote/magic-preview/:token", (c) => {
155
+ const token = c.req.param("token") || "";
156
+ const payload = decipherMagicPreviewToken(token, config);
157
+
158
+ if (!payload?.slug) {
159
+ return c.json({ error: "Invalid or expired preview token" }, 404);
160
+ }
161
+
162
+ return c.json(payload);
163
+ });
152
164
  };
153
165
 
154
166
  export default setupAuthentication;
@@ -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
 
@@ -5,8 +5,26 @@ import Factory from "../../../support/factory";
5
5
 
6
6
  function buildCtx(params, resource) {
7
7
  return {
8
+ globalConfig: {
9
+ i18n: { defaultLocale: "en" },
10
+ remoteConfig: {
11
+ magicPreviewUrl: (token, type, loc) =>
12
+ `https://example.com/${loc}/magic-preview/${type}s/${token}`,
13
+ },
14
+ },
8
15
  client: {
9
- resources: new Resources(() => ({}), "token"),
16
+ resources: new Resources(
17
+ () => ({
18
+ secrets: {
19
+ crypto_secret: "super-secret-for-testing-1234",
20
+ },
21
+ remoteConfig: {
22
+ magicPreviewUrl: (token, type, loc) =>
23
+ `https://example.com/${loc}/magic-preview/${type}s/${token}`,
24
+ },
25
+ }),
26
+ "token",
27
+ ),
10
28
  resourceRevisions: new ResourceRevisions(() => ({}), "token"),
11
29
  },
12
30
  params: { parent_id: resource.id },
@@ -327,4 +345,64 @@ describe("Resource Translations Handler", () => {
327
345
  },
328
346
  });
329
347
  });
348
+
349
+ test("create generates previewUrl when workflow is provided", async () => {
350
+ const resource = Factory.build("resource", {
351
+ translations: {},
352
+ categories: [{ id: "cat1" }],
353
+ });
354
+
355
+ const params = {
356
+ languages: ["fr"],
357
+ workflow: {
358
+ type: "LOKALISE_TT_AI",
359
+ },
360
+ };
361
+
362
+ const ctx = buildCtx(params, resource);
363
+
364
+ jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
365
+ const mockQuery = jest
366
+ .spyOn(ctx.client.resources, "query")
367
+ .mockImplementation(() => ({
368
+ translate0: {
369
+ resource: {
370
+ id: "translated-resource-fr",
371
+ slug: "resource-slug",
372
+ visibility: "public",
373
+ },
374
+ errors: null,
375
+ },
376
+ }));
377
+
378
+ const result = await TranslationsHandler.create(ctx);
379
+
380
+ expect(mockQuery).toHaveBeenCalledWith(expect.any(String), {
381
+ input0: {
382
+ locale: "fr",
383
+ resource: {
384
+ categories: ["cat1"],
385
+ resourceType: "article",
386
+ settings:
387
+ '{"layout":"default","meta":{"title":"Open graph","description":"Open graph???","image":{}}}',
388
+ slug: "resource-slug",
389
+ tags: [],
390
+ title: "Resource Title",
391
+ visibility: "private",
392
+ widgets: "[]",
393
+ },
394
+ sourceResourceId: "2",
395
+ workflow: {
396
+ previewUrl: expect.stringMatching(
397
+ /^https:\/\/example\.com\/fr\/magic-preview\/articles\/[A-Za-z0-9_-]+$/,
398
+ ),
399
+ type: "LOKALISE_TT_AI",
400
+ },
401
+ },
402
+ });
403
+
404
+ expect(result).toEqual({
405
+ redirect: `/en/articles/${resource.id}/translations?translations=fr:translated-resource-fr`,
406
+ });
407
+ });
330
408
  });
@@ -69,6 +69,16 @@ describe("AuthHelper", () => {
69
69
  AuthHelper.isAuthenticated({ path: "/auth/google/callback" }),
70
70
  ).toBe(true);
71
71
  expect(AuthHelper.isAuthenticated({ path: "/remote/verify" })).toBe(true);
72
+ expect(
73
+ AuthHelper.isAuthenticated({
74
+ path: "/remote/magic-preview/any-token-here",
75
+ }),
76
+ ).toBe(true);
77
+ expect(
78
+ AuthHelper.isAuthenticated({
79
+ path: "/remote/magic-preview/:token",
80
+ }),
81
+ ).toBe(true);
72
82
  });
73
83
 
74
84
  test("returns true for valid token", () => {
@@ -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
  });
@@ -0,0 +1,127 @@
1
+ import {
2
+ buildMagicPreviewAddress,
3
+ decipherMagicPreviewToken,
4
+ generateMagicPreviewToken,
5
+ } from "../magicPreview";
6
+
7
+ describe("magicPreview helper", () => {
8
+ const config = {
9
+ secrets: {
10
+ crypto_secret: "super-secret-key-for-testing-123456",
11
+ },
12
+ i18n: {
13
+ defaultLocale: "en",
14
+ locales: { en: {}, de: {}, fr: {} },
15
+ },
16
+ remoteConfig: {
17
+ magicPreviewUrl: (token, resourceType, locale) => {
18
+ return [
19
+ "https://example.com",
20
+ locale === "en" ? "" : locale,
21
+ "magic-preview",
22
+ resourceType === "page" ? "" : resourceType,
23
+ token,
24
+ ]
25
+ .filter(Boolean)
26
+ .join("/");
27
+ },
28
+ },
29
+ };
30
+
31
+ describe("buildMagicPreviewAddress", () => {
32
+ it("builds full preview URL using remoteConfig.magicPreviewUrl", () => {
33
+ const address = buildMagicPreviewAddress(
34
+ {
35
+ slug: "gocardless",
36
+ resourceType: "customer",
37
+ locale: "en",
38
+ },
39
+ config,
40
+ );
41
+
42
+ expect(address).toMatch(
43
+ /^https:\/\/example\.com\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
44
+ );
45
+ });
46
+
47
+ it("builds localized preview URL for non-default locale", () => {
48
+ const address = buildMagicPreviewAddress(
49
+ {
50
+ slug: "gocardless",
51
+ resourceType: "customer",
52
+ locale: "de",
53
+ },
54
+ config,
55
+ );
56
+
57
+ expect(address).toMatch(
58
+ /^https:\/\/example\.com\/de\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
59
+ );
60
+ });
61
+
62
+ it("throws an error for missing slug", () => {
63
+ expect(() => {
64
+ buildMagicPreviewAddress(
65
+ { resourceType: "page", locale: "de" },
66
+ config,
67
+ );
68
+ }).toThrow(
69
+ "Can't generate a token without a slug, resourceType or locale!",
70
+ );
71
+ });
72
+
73
+ it("throws an error for missing resourceType", () => {
74
+ expect(() => {
75
+ buildMagicPreviewAddress({ slug: "page", locale: "de" }, config);
76
+ }).toThrow(
77
+ "Can't generate a token without a slug, resourceType or locale!",
78
+ );
79
+ });
80
+
81
+ it("throws an error for missing locale", () => {
82
+ expect(() => {
83
+ buildMagicPreviewAddress(
84
+ { resourceType: "page", slug: "dada" },
85
+ config,
86
+ );
87
+ }).toThrow(
88
+ "Can't generate a token without a slug, resourceType or locale!",
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("generateMagicPreviewToken & decipherMagicPreviewToken", () => {
94
+ it("generates an encrypted token and deciphers it back", () => {
95
+ const token = generateMagicPreviewToken(
96
+ {
97
+ slug: "my-first-ebook",
98
+ resourceType: "ebook",
99
+ locale: "en",
100
+ },
101
+ config,
102
+ );
103
+
104
+ expect(typeof token).toBe("string");
105
+ expect(token).not.toContain("my-first-ebook");
106
+
107
+ const payload = decipherMagicPreviewToken(token, config);
108
+ expect(payload).not.toBeNull();
109
+ expect(payload.slug).toBe("my-first-ebook");
110
+ expect(payload.resourceType).toBe("ebook");
111
+ expect(payload.locale).toBe("en");
112
+ });
113
+
114
+ it("returns null on invalid / tampered token", () => {
115
+ expect(decipherMagicPreviewToken("", config)).toBeNull();
116
+ expect(decipherMagicPreviewToken(null, config)).toBeNull();
117
+ expect(decipherMagicPreviewToken("invalid-token", config)).toBeNull();
118
+
119
+ const token = generateMagicPreviewToken(
120
+ { slug: "secure", resourceType: "page", locale: "en" },
121
+ config,
122
+ );
123
+ const tampered = `${token.slice(0, -4)}XXXX`;
124
+ expect(decipherMagicPreviewToken(tampered, config)).toBeNull();
125
+ });
126
+ });
127
+ });
@@ -50,6 +50,7 @@ function makeCtx(overrides = {}) {
50
50
  path: "/en/pages",
51
51
  url: "https://example.com/en/pages",
52
52
  query: jest.fn().mockReturnValue({}),
53
+ param: jest.fn().mockReturnValue(""),
53
54
  json: jest.fn().mockResolvedValue({}),
54
55
  ...overrides.req,
55
56
  },
@@ -72,6 +73,10 @@ const config = {
72
73
  remoteLogin: "https://remote/login",
73
74
  remoteLogout: "https://remote/logout",
74
75
  },
76
+ secrets: {
77
+ crypto_secret:
78
+ "cafeb74e0fc1079d384f68620dc0b0919c82bd36351a74fb48f3c96562c1bf672ff5fd4db42180bc9550d06f3621edba5305a443a232337e2945d7bb1271cafe",
79
+ },
75
80
  };
76
81
 
77
82
  describe("setupAuthentication", () => {
@@ -96,6 +101,10 @@ describe("setupAuthentication", () => {
96
101
  "/remote/verify",
97
102
  expect.any(Function),
98
103
  );
104
+ expect(app.get).toHaveBeenCalledWith(
105
+ "/remote/magic-preview/:token",
106
+ expect.any(Function),
107
+ );
99
108
  });
100
109
 
101
110
  describe("use middleware", () => {
@@ -407,4 +416,50 @@ describe("setupAuthentication", () => {
407
416
  expect(result.status).toBe(401);
408
417
  });
409
418
  });
419
+
420
+ describe("/remote/magic-preview/:token", () => {
421
+ test("returns the token payload for a valid token", async () => {
422
+ const app = makeApp();
423
+ setupAuthentication(app, config);
424
+
425
+ const { generateMagicPreviewToken } = require("../magicPreview");
426
+ const token = generateMagicPreviewToken(
427
+ { slug: "article-slug", resourceType: "article", locale: "es" },
428
+ config,
429
+ );
430
+
431
+ const ctx = makeCtx({
432
+ req: {
433
+ param: jest.fn().mockReturnValue(token),
434
+ },
435
+ });
436
+
437
+ await app.handlers["GET /remote/magic-preview/:token"](ctx);
438
+
439
+ expect(ctx.json).toHaveBeenCalledWith({
440
+ slug: "article-slug",
441
+ resourceType: "article",
442
+ locale: "es",
443
+ });
444
+ });
445
+
446
+ test("returns 404 for invalid token", async () => {
447
+ const app = makeApp();
448
+
449
+ setupAuthentication(app, config);
450
+
451
+ const ctx = makeCtx({
452
+ req: {
453
+ param: jest.fn().mockReturnValue("bad-token"),
454
+ },
455
+ });
456
+
457
+ await app.handlers["GET /remote/magic-preview/:token"](ctx);
458
+
459
+ expect(ctx.json).toHaveBeenCalledWith(
460
+ { error: "Invalid or expired preview token" },
461
+ 404,
462
+ );
463
+ });
464
+ });
410
465
  });