ghost 6.3.1 → 6.4.0

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.
Files changed (28) hide show
  1. package/components/tryghost-i18n-6.4.0.tgz +0 -0
  2. package/core/built/admin/assets/activitypub/activitypub.js +6 -0
  3. package/core/built/admin/assets/{admin-x-activitypub/index-DsmVTjDw.mjs → activitypub/index-2Xkl6sDz.mjs} +2 -2
  4. package/core/built/admin/assets/{admin-x-activitypub/index-CdMLWVnk.mjs → activitypub/index-CKBuhv6F.mjs} +5865 -5820
  5. package/core/built/admin/assets/{chunk.524.5ac0aa6b2e0374d43fa1.js → chunk.524.5fd100e92f0b07a59e66.js} +6 -6
  6. package/core/built/admin/assets/{chunk.582.944f56b6e36ff0afdc80.js → chunk.582.411877f4da4644562f10.js} +8 -8
  7. package/core/built/admin/assets/{ghost-1bfab97cb7f550726e894fae6650a808.js → ghost-e7b2c10bfc27fe1ef28d2697e9e7551b.js} +20 -38
  8. package/core/built/admin/assets/posts/posts.js +5519 -5510
  9. package/core/built/admin/assets/stats/stats.js +20 -20
  10. package/core/built/admin/index.html +3 -3
  11. package/core/frontend/helpers/reading_time.js +4 -4
  12. package/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +8 -3
  13. package/core/server/data/migrations/versions/6.4/2025-10-13-10-18-38-add-tokens-otc-used-count-column.js +8 -0
  14. package/core/server/data/schema/schema.js +3 -2
  15. package/core/server/models/single-use-token.js +1 -0
  16. package/core/server/services/email-service/email-templates/template.hbs +0 -2
  17. package/core/server/services/lib/MailgunClient.js +2 -1
  18. package/core/server/services/lib/magic-link/MagicLink.js +0 -1
  19. package/core/server/services/mail/GhostMailer.js +21 -0
  20. package/core/server/services/members/SingleUseTokenProvider.js +218 -62
  21. package/package.json +6 -6
  22. package/tsconfig.tsbuildinfo +1 -1
  23. package/yarn.lock +265 -169
  24. package/components/tryghost-i18n-6.3.1.tgz +0 -0
  25. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +0 -6
  26. package/core/server/services/lib/magic-link/JWTTokenProvider.js +0 -52
  27. package/core/server/services/lib/magic-link/README.md +0 -60
  28. /package/core/built/admin/assets/{admin-x-activitypub → activitypub}/styles/reader.css +0 -0
@@ -8,12 +8,18 @@ const readingMinutes = require('@tryghost/helpers').utils.readingMinutes;
8
8
  * @returns {void} - modifies attrs
9
9
  */
10
10
  module.exports.forPost = (options, model, attrs) => {
11
+ // requested via `columns`
11
12
  const columnsIncludesCustomExcerpt = options.columns?.includes('custom_excerpt');
12
13
  const columnsIncludesExcerpt = options.columns?.includes('excerpt');
13
14
  const columnsIncludesPlaintext = options.columns?.includes('plaintext');
14
15
  const columnsIncludesReadingTime = options.columns?.includes('reading_time');
16
+
17
+ // requested via `formats`
15
18
  const formatsIncludesPlaintext = options.formats?.includes('plaintext');
16
19
 
20
+ // no columns requested
21
+ const noColumnsRequested = !Object.prototype.hasOwnProperty.call(options, 'columns');
22
+
17
23
  // 1. Gets excerpt from post's plaintext. If custom_excerpt exists, it overrides the excerpt but the key remains excerpt.
18
24
  if (columnsIncludesExcerpt) {
19
25
  if (!attrs.custom_excerpt) {
@@ -32,7 +38,6 @@ module.exports.forPost = (options, model, attrs) => {
32
38
  }
33
39
  }
34
40
 
35
- // 2. Displays plaintext if requested via `columns` or `formats`
36
41
  if (columnsIncludesPlaintext || formatsIncludesPlaintext) {
37
42
  let plaintext = model.get('plaintext');
38
43
  if (plaintext) {
@@ -43,7 +48,7 @@ module.exports.forPost = (options, model, attrs) => {
43
48
  }
44
49
 
45
50
  // 3. Displays excerpt if no columns was requested - specifically needed for the Admin Posts API
46
- if (!Object.prototype.hasOwnProperty.call(options, 'columns')) {
51
+ if (noColumnsRequested) {
47
52
  let customExcerpt = model.get('custom_excerpt');
48
53
 
49
54
  if (customExcerpt !== null) {
@@ -59,7 +64,7 @@ module.exports.forPost = (options, model, attrs) => {
59
64
  }
60
65
 
61
66
  // 4. Add `reading_time` if no columns were requested, or if `reading_time` was requested via `columns`
62
- if (!Object.prototype.hasOwnProperty.call(options, 'columns') || columnsIncludesReadingTime) {
67
+ if (noColumnsRequested || columnsIncludesReadingTime) {
63
68
  if (attrs.html) {
64
69
  let additionalImages = 0;
65
70
 
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('tokens', 'otc_used_count', {
4
+ type: 'integer',
5
+ nullable: false,
6
+ unsigned: true,
7
+ defaultTo: 0
8
+ });
@@ -1,7 +1,7 @@
1
1
  /* String Column Sizes Information
2
2
  * (From: https://github.com/TryGhost/Ghost/pull/7932)
3
3
  * New/Updated column maxlengths should meet these guidlines
4
- *
4
+ *
5
5
  * Small strings = length 50
6
6
  * Medium strings = length 191
7
7
  * Large strings = length 2000 (use soft limits via validation for 191-2000)
@@ -925,7 +925,8 @@ module.exports = {
925
925
  created_at: {type: 'dateTime', nullable: false},
926
926
  updated_at: {type: 'dateTime', nullable: true},
927
927
  first_used_at: {type: 'dateTime', nullable: true},
928
- used_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
928
+ used_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0},
929
+ otc_used_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
929
930
  },
930
931
  snippets: {
931
932
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -7,6 +7,7 @@ const SingleUseToken = ghostBookshelf.Model.extend({
7
7
  defaults() {
8
8
  return {
9
9
  used_count: 0,
10
+ otc_used_count: 0,
10
11
  uuid: crypto.randomUUID(),
11
12
  token: crypto
12
13
  .randomBytes(192 / 8)
@@ -9,12 +9,10 @@
9
9
  </head>
10
10
  <body>
11
11
  <span class="preheader">{{preheader}}</span>
12
- {{#hasFeature 'emailCustomization'}}
13
12
  <!-- SPACING TO AVOID BODY TEXT BEING DUPLICATED IN PREVIEW TEXT -->
14
13
  <div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
15
14
  {{{preheaderSpacing}}}
16
15
  </div>
17
- {{/hasFeature}}
18
16
 
19
17
  <!-- HEADER WITH FULL-WIDTH BACKGROUND -->
20
18
  {{#if hasHeaderContent}}
@@ -68,7 +68,8 @@ module.exports = class MailgunClient {
68
68
  html: messageContent.html,
69
69
  text: messageContent.plaintext,
70
70
  'recipient-variables': JSON.stringify(recipientData),
71
- 'h:Sender': message.from
71
+ 'h:Sender': message.from,
72
+ 'h:Auto-Submitted': 'auto-generated'
72
73
  };
73
74
 
74
75
  // Do we have a custom List-Unsubscribe header set?
@@ -243,4 +243,3 @@ function defaultGetSubject(type, otc) {
243
243
  }
244
244
 
245
245
  module.exports = MagicLink;
246
- module.exports.JWTTokenProvider = require('./JWTTokenProvider');
@@ -17,6 +17,7 @@ const messages = {
17
17
  messageSent: 'Message sent. Double check inbox and spam folder!'
18
18
  };
19
19
  const EmailAddressParser = require('../email-address/EmailAddressParser');
20
+ const DEFAULT_TAGS = ['ghost-email', 'transactional-email'];
20
21
 
21
22
  function getDomain() {
22
23
  const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
@@ -129,6 +130,15 @@ module.exports = class GhostMailer {
129
130
  }
130
131
 
131
132
  const messageToSend = createMessage(message);
133
+ if (this.state.usingMailgun) {
134
+ const tags = this.getTags();
135
+ if (tags.length > 0) {
136
+ messageToSend['o:tag'] = tags;
137
+ }
138
+ if (settingsCache.get('emailTrackOpens')) {
139
+ messageToSend['o:tracking-opens'] = true;
140
+ }
141
+ }
132
142
 
133
143
  const response = await this.sendMail(messageToSend);
134
144
 
@@ -184,4 +194,15 @@ module.exports = class GhostMailer {
184
194
 
185
195
  return tpl(messages.messageSent);
186
196
  }
197
+
198
+ getTags() {
199
+ const tagList = [...DEFAULT_TAGS];
200
+
201
+ const siteId = config.get('hostSettings:siteId');
202
+ if (siteId) {
203
+ tagList.push(`blog-${siteId}`);
204
+ }
205
+
206
+ return tagList;
207
+ }
187
208
  };
@@ -9,6 +9,7 @@ const messages = {
9
9
  INVALID_OTC_VERIFICATION_HASH: 'Invalid OTC verification hash',
10
10
  INVALID_TOKEN: 'Invalid token provided',
11
11
  TOKEN_EXPIRED: 'Token expired',
12
+ OTC_EXPIRED: 'One-time code expired',
12
13
  DERIVE_OTC_MISSING_INPUT: 'tokenId and tokenValue are required'
13
14
  };
14
15
 
@@ -56,7 +57,7 @@ class SingleUseTokenProvider {
56
57
  * @param {Object} [options.transacting] - Database transaction object
57
58
  * @param {string} [options.otcVerification] - OTC verification hash for additional validation
58
59
  *
59
- * @returns {Promise<Object<string, any>>}
60
+ * @returns {Promise<Object<string, unknown>>}
60
61
  */
61
62
  async validate(token, options = {}) {
62
63
  if (!options.transacting) {
@@ -68,66 +69,13 @@ class SingleUseTokenProvider {
68
69
  });
69
70
  }
70
71
 
71
- if (options.otcVerification) {
72
- const isValidOTCVerification = await this._validateOTCVerificationHash(options.otcVerification, token);
73
- if (!isValidOTCVerification) {
74
- throw new ValidationError({
75
- message: tpl(messages.INVALID_OTC_VERIFICATION_HASH),
76
- code: 'INVALID_OTC_VERIFICATION_HASH'
77
- });
78
- }
79
- }
80
-
81
- const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
82
-
83
- if (!model) {
84
- throw new ValidationError({
85
- message: tpl(messages.INVALID_TOKEN),
86
- code: 'INVALID_TOKEN'
87
- });
88
- }
89
-
90
- if (model.get('used_count') >= this.maxUsageCount) {
91
- throw new ValidationError({
92
- message: tpl(messages.TOKEN_EXPIRED),
93
- code: 'TOKEN_EXPIRED'
94
- });
95
- }
96
-
97
- const createdAtEpoch = model.get('created_at').getTime();
98
- const firstUsedAtEpoch = model.get('first_used_at')?.getTime() ?? createdAtEpoch;
99
-
100
- // Is this token already used?
101
- if (model.get('used_count') > 0) {
102
- const timeSinceFirstUsage = Date.now() - firstUsedAtEpoch;
103
-
104
- if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
105
- throw new ValidationError({
106
- message: tpl(messages.TOKEN_EXPIRED),
107
- code: 'TOKEN_EXPIRED'
108
- });
109
- }
110
- }
111
- const tokenLifetimeMilliseconds = Date.now() - createdAtEpoch;
112
-
113
- if (tokenLifetimeMilliseconds > this.validityPeriod) {
114
- throw new ValidationError({
115
- message: tpl(messages.TOKEN_EXPIRED),
116
- code: 'TOKEN_EXPIRED'
117
- });
118
- }
72
+ const model = await this._findAndLockTokenModel(token, options.transacting);
119
73
 
120
- if (!model.get('first_used_at')) {
121
- await model.save({
122
- first_used_at: new Date(),
123
- updated_at: new Date(),
124
- used_count: model.get('used_count') + 1
125
- }, {autoRefresh: false, patch: true, transacting: options.transacting});
74
+ if (options.otcVerification) {
75
+ await this._validateOTCVerificationHash(options.otcVerification, model.get('token'));
76
+ await this._validateOTCUsageLimit(model, options.transacting);
126
77
  } else {
127
- await model.save({
128
- used_count: model.get('used_count') + 1,
129
- updated_at: new Date()
130
- }, {autoRefresh: false, patch: true, transacting: options.transacting});
78
+ await this._validateUsageLimit(model, options.transacting);
131
79
  }
132
80
 
133
81
  try {
@@ -137,6 +85,40 @@ class SingleUseTokenProvider {
137
85
  }
138
86
  }
139
87
 
88
+ /**
89
+ * @private
90
+ * @method _validateOTCUsageLimit
91
+ * Validates a token model is within it's usage limits after additional OTC verification..
92
+ * OTC bypasses the non-OTC usage count and time-since-first-usage validation but is true single-use.
93
+ *
94
+ * @param {Object} model - Token model instance
95
+ * @param {Object} transaction - Database transaction object
96
+ *
97
+ * @returns {Promise<void>}
98
+ */
99
+ async _validateOTCUsageLimit(model, transaction) {
100
+ this._validateOTCUsageCount(model);
101
+ this._validateTotalTokenLifetime(model);
102
+ await this._incrementOTCUsageCount(model, transaction);
103
+ }
104
+
105
+ /**
106
+ * @private
107
+ * @method _validateUsageLimit
108
+ * Validates a token model is within it's usage limits
109
+ *
110
+ * @param {Object} model - Token model instance
111
+ * @param {Object} transaction - Database transaction object
112
+ *
113
+ * @returns {Promise<void>}
114
+ */
115
+ async _validateUsageLimit(model, transaction) {
116
+ this._validateUsageCount(model);
117
+ this._validateTimeSinceFirstUsage(model);
118
+ this._validateTotalTokenLifetime(model);
119
+ await this._incrementUsageCount(model, transaction);
120
+ }
121
+
140
122
  /**
141
123
  * @private
142
124
  * @method deriveCounter
@@ -268,17 +250,59 @@ class SingleUseTokenProvider {
268
250
  return hmac.digest('hex');
269
251
  }
270
252
 
253
+ /**
254
+ * @private
255
+ * @method _findAndLockTokenModel
256
+ * Finds token in database and locks it for update
257
+ *
258
+ * @param {string} token
259
+ * @param {Object} transacting
260
+ * @returns {Promise<Object>}
261
+ */
262
+ async _findAndLockTokenModel(token, transacting) {
263
+ const model = await this.model.findOne({token}, {transacting, forUpdate: true});
264
+
265
+ if (!model) {
266
+ throw new ValidationError({
267
+ message: tpl(messages.INVALID_TOKEN),
268
+ code: 'INVALID_TOKEN'
269
+ });
270
+ }
271
+
272
+ return model;
273
+ }
274
+
271
275
  /**
272
276
  * @private
273
277
  * @method _validateOTCVerificationHash
274
- * Validates OTC verification hash by recreating and comparing the hash.
275
- * Private because it's only used internally by the public validate method.
278
+ * Validates OTC verification hash, throwing on invalid hash.
276
279
  *
277
280
  * @param {string} otcVerificationHash - The hash to validate (timestamp:hash format)
278
281
  * @param {string} token - The token value
279
- * @returns {Promise<boolean>} - True if hash is valid, false otherwise
282
+ * @returns {Promise<void>}
280
283
  */
281
284
  async _validateOTCVerificationHash(otcVerificationHash, token) {
285
+ const isValid = await this._isValidOTCVerificationHash(otcVerificationHash, token);
286
+
287
+ if (!isValid) {
288
+ throw new ValidationError({
289
+ message: tpl(messages.INVALID_OTC_VERIFICATION_HASH),
290
+ code: 'INVALID_OTC_VERIFICATION_HASH'
291
+ });
292
+ }
293
+ }
294
+
295
+ /**
296
+ * @private
297
+ * @method _isValidOTCVerificationHash
298
+ * Validates OTC verification hash by recreating and comparing the hash.
299
+ * Returns true if the hash is valid, false otherwise.
300
+ *
301
+ * @param {string} otcVerificationHash - The hash to validate (timestamp:hash format)
302
+ * @param {string} token - The token value
303
+ * @returns {Promise<boolean>}
304
+ */
305
+ async _isValidOTCVerificationHash(otcVerificationHash, token) {
282
306
  try {
283
307
  if (!this.secret || !otcVerificationHash || !token) {
284
308
  return false;
@@ -319,6 +343,138 @@ class SingleUseTokenProvider {
319
343
  return false;
320
344
  }
321
345
  }
346
+
347
+ /**
348
+ * @private
349
+ * @method _validateUsageCount
350
+ * Validates that token has not exceeded usage limits, throws on over-used token
351
+ *
352
+ * @param {Object} model - The token model
353
+ * @returns {void}
354
+ */
355
+ _validateUsageCount(model) {
356
+ // Magic links are invalid if OTC has been used
357
+ const otcUsedCount = model.get('otc_used_count') || 0;
358
+ if (otcUsedCount > 0) {
359
+ throw new ValidationError({
360
+ message: tpl(messages.TOKEN_EXPIRED),
361
+ code: 'TOKEN_EXPIRED'
362
+ });
363
+ }
364
+
365
+ if (model.get('used_count') >= this.maxUsageCount) {
366
+ throw new ValidationError({
367
+ message: tpl(messages.TOKEN_EXPIRED),
368
+ code: 'TOKEN_EXPIRED'
369
+ });
370
+ }
371
+ }
372
+
373
+ /**
374
+ * @private
375
+ * @method _validateOTCUsageCount
376
+ * Validates that OTC has not exceeded usage limits, throws on over-used token
377
+ *
378
+ * @param {Object} model - The token model
379
+ * @returns {void}
380
+ */
381
+ _validateOTCUsageCount(model) {
382
+ const otcUsedCount = model.get('otc_used_count') || 0;
383
+ if (otcUsedCount >= 1) {
384
+ throw new ValidationError({
385
+ message: tpl(messages.OTC_EXPIRED),
386
+ code: 'OTC_EXPIRED'
387
+ });
388
+ }
389
+ }
390
+
391
+ /**
392
+ * @private
393
+ * @method _validateTimeSinceFirstUsage
394
+ * Validates token has not exceeded its time since first usage, throws on expired token
395
+ *
396
+ * @param {Object} model - The token model
397
+ * @returns {void}
398
+ */
399
+ _validateTimeSinceFirstUsage(model) {
400
+ const createdAtEpoch = model.get('created_at').getTime();
401
+ const firstUsedAtEpoch = model.get('first_used_at')?.getTime() ?? createdAtEpoch;
402
+ const timeSinceFirstUsage = Date.now() - firstUsedAtEpoch;
403
+
404
+ if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
405
+ throw new ValidationError({
406
+ message: tpl(messages.TOKEN_EXPIRED),
407
+ code: 'TOKEN_EXPIRED'
408
+ });
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @private
414
+ * @method _validateTotalTokenLifetime
415
+ * Validates token has not exceeded its total lifetime, throws on expired token
416
+ *
417
+ * @param {Object} model - The token model
418
+ * @returns {void}
419
+ */
420
+ _validateTotalTokenLifetime(model) {
421
+ const createdAtEpoch = model.get('created_at').getTime();
422
+ const tokenLifetimeMilliseconds = Date.now() - createdAtEpoch;
423
+ if (tokenLifetimeMilliseconds > this.validityPeriod) {
424
+ throw new ValidationError({
425
+ message: tpl(messages.TOKEN_EXPIRED),
426
+ code: 'TOKEN_EXPIRED'
427
+ });
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @private
433
+ * @method _incrementUsageCount
434
+ * Increments the usage count for a token
435
+ *
436
+ * @param {Object} model - The token model
437
+ * @param {Object} transacting - Database transaction object
438
+ * @returns {Promise<void>}
439
+ */
440
+ async _incrementUsageCount(model, transacting) {
441
+ const updateData = {used_count: model.get('used_count') + 1};
442
+ await this._saveUsageData(updateData, model, transacting);
443
+ }
444
+
445
+ /**
446
+ * @private
447
+ * @method _incrementOTCUsageCount
448
+ * Increments the OTC usage count for a token
449
+ *
450
+ * @param {Object} model - The token model
451
+ * @param transacting - Database transaction object
452
+ * @returns {Promise<void>}
453
+ */
454
+ async _incrementOTCUsageCount(model, transacting) {
455
+ const updateData = {otc_used_count: model.get('otc_used_count') + 1};
456
+ await this._saveUsageData(updateData, model, transacting);
457
+ }
458
+
459
+ /**
460
+ * @private
461
+ * @method _saveUsageData
462
+ * Saves the usage data for a token
463
+ *
464
+ * @param {Object} updateData - The data to save
465
+ * @param {Object} model - The token model
466
+ * @param {Object} transacting - Database transaction object
467
+ * @returns {Promise<void>}
468
+ */
469
+ async _saveUsageData(updateData, model, transacting) {
470
+ updateData.updated_at = new Date();
471
+
472
+ if (!model.get('first_used_at')) {
473
+ updateData.first_used_at = new Date();
474
+ }
475
+
476
+ await model.save(updateData, {autoRefresh: false, patch: true, transacting});
477
+ }
322
478
  }
323
479
 
324
480
  module.exports = SingleUseTokenProvider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "description": "The professional publishing platform",
5
5
  "author": "Ghost Foundation",
6
6
  "homepage": "https://ghost.org",
@@ -86,7 +86,7 @@
86
86
  "@tryghost/helpers": "1.1.97",
87
87
  "@tryghost/html-to-plaintext": "1.0.4",
88
88
  "@tryghost/http-cache-utils": "0.1.20",
89
- "@tryghost/i18n": "file:components/tryghost-i18n-6.3.1.tgz",
89
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.4.0.tgz",
90
90
  "@tryghost/image-transform": "1.4.6",
91
91
  "@tryghost/job-manager": "1.0.3",
92
92
  "@tryghost/kg-card-factory": "5.1.2",
@@ -144,7 +144,7 @@
144
144
  "csso": "5.0.5",
145
145
  "csv-writer": "1.6.0",
146
146
  "date-fns": "2.30.0",
147
- "dompurify": "3.2.7",
147
+ "dompurify": "3.3.0",
148
148
  "downsize": "0.0.8",
149
149
  "entities": "4.5.0",
150
150
  "express": "4.21.2",
@@ -166,7 +166,7 @@
166
166
  "heic-convert": "2.1.0",
167
167
  "html-to-text": "5.1.1",
168
168
  "html5parser": "2.0.2",
169
- "human-number": "2.0.6",
169
+ "human-number": "2.0.7",
170
170
  "iconv-lite": "0.6.3",
171
171
  "image-size": "1.2.1",
172
172
  "intl": "1.2.5",
@@ -232,7 +232,7 @@
232
232
  "@types/bookshelf": "1.2.9",
233
233
  "@types/common-tags": "1.8.4",
234
234
  "@types/jsonwebtoken": "9.0.10",
235
- "@types/node": "22.18.9",
235
+ "@types/node": "22.18.10",
236
236
  "@types/node-jose": "1.1.13",
237
237
  "@types/nodemailer": "6.4.20",
238
238
  "@types/sinon": "17.0.4",
@@ -273,7 +273,7 @@
273
273
  "jackspeak": "2.3.6",
274
274
  "moment": "2.24.0",
275
275
  "moment-timezone": "0.5.45",
276
- "@tryghost/i18n": "file:components/tryghost-i18n-6.3.1.tgz"
276
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.4.0.tgz"
277
277
  },
278
278
  "nx": {
279
279
  "targets": {