@raketa-cloud/admin 1.9.0 → 1.9.1

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.1",
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
 
@@ -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;
@@ -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", () => {
@@ -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
  });