apostrophe 3.14.2 → 3.16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.16.0 (2022-03-18)
4
+
5
+ ### Adds
6
+
7
+ * Offers a simple way to set a Cache-Control max-age for Apostrophe page and GET REST API responses for pieces and pages.
8
+ * API keys and bearer tokens "win" over session cookies when both are present. Since API keys and bearer tokens are explicitly added to the request at hand, it never makes sense to ignore them in favor of a cookie, which is implicit. This also simplifies automated testing.
9
+ * `data-apos-test=""` selectors for certain elements frequently selected in QA tests, such as `data-apos-test="adminBar"`.
10
+ * To speed up functional tests, an `insecurePasswords` option has been added to the login module. This option is deliberately named to discourage use for any purpose other than functional tests in which repeated password hashing would unduly limit performance. Normally password hashing is intentionally difficult to slow down brute force attacks, especially if a database is compromised.
11
+
12
+ ### Fixes
13
+
14
+ * `POST`ing a new child page with `_targetId: '_home'` now works properly in combination with `_position: 'lastChild'`.
15
+
16
+ ## 3.15.0 (2022-03-02)
17
+
18
+ ### Adds
19
+
20
+ * Adds throttle system based on username (even when not existing), on initial login route. Also added for each late login requirement, e.g. for 2FA attempts.
21
+
3
22
  ## 3.14.2 (2022-02-27)
4
23
 
5
24
  * Hotfix: fixed a bug introduced by 3.14.1 in which non-parked pages could throw an error during the migration to fix replication issues.
@@ -1,12 +1,12 @@
1
1
  <template>
2
- <div class="apos-admin-bar-wrapper" :class="themeClass">
2
+ <div data-apos-test="adminBar" class="apos-admin-bar-wrapper" :class="themeClass">
3
3
  <div class="apos-admin-bar-spacer" ref="spacer" />
4
4
  <nav class="apos-admin-bar" ref="adminBar">
5
5
  <div class="apos-admin-bar__row">
6
6
  <AposLogoPadless class="apos-admin-bar__logo" />
7
7
  <TheAposAdminBarMenu :items="items" />
8
8
  <TheAposAdminBarLocale v-if="hasLocales()" />
9
- <TheAposAdminBarUser class="apos-admin-bar__user" />
9
+ <TheAposAdminBarUser data-apos-test="authenticatedUserMenuTrigger" class="apos-admin-bar__user" />
10
10
  </div>
11
11
  <TheAposContextBar @mounted="setSpacer" />
12
12
  </nav>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * If the page delivers a logged-out content but we know from session storage that a user is logged-in,
3
+ * we force-refresh the page to bypass the cache, in order to get the logged-in content (with admin UI).
4
+ */
5
+ export default function() {
6
+ const isLoggedOutPageContent = !(apos.login && apos.login.user);
7
+ const isLoggedInCookie = apos.util.getCookie(`${self.apos.shortName}.loggedIn`) === 'true';
8
+
9
+ if (!isLoggedOutPageContent || !isLoggedInCookie) {
10
+ sessionStorage.setItem('aposRefreshedPages', '{}');
11
+
12
+ return;
13
+ }
14
+
15
+ const refreshedPages = JSON.parse(sessionStorage.aposRefreshedPages || '{}');
16
+
17
+ // Avoid potential refresh loops
18
+ if (!refreshedPages[location.href]) {
19
+ refreshedPages[location.href] = true;
20
+ sessionStorage.setItem('aposRefreshedPages', JSON.stringify(refreshedPages));
21
+
22
+ console.info('Received logged-out content from cache while logged-in, refreshing the page');
23
+
24
+ location.reload();
25
+ }
26
+ };
@@ -178,6 +178,8 @@
178
178
  "manageDocType": "Manage {{ type }}",
179
179
  "manageDraftSubmissions": "Manage Draft Submissions",
180
180
  "managePages": "Manage Pages",
181
+ "loginMaxAttemptsReached": "Too many attempts. You may try again in a minute.",
182
+ "loginMaxAttemptsReached_plural": "Too many attempts. You may try again in {{ count }} minutes.",
181
183
  "maxLabel": "Max:",
182
184
  "maxUi": "Max: {{ number }}",
183
185
  "mediaCreatedDate": "Uploaded: {{ createdDate }}",
@@ -44,6 +44,9 @@ const Promise = require('bluebird');
44
44
  const cuid = require('cuid');
45
45
  const expressSession = require('express-session');
46
46
 
47
+ const loginAttemptsNamespace = '@apostrophecms/loginAttempt';
48
+ const loggedInCookieName = 'loggedIn';
49
+
47
50
  module.exports = {
48
51
  cascades: [ 'requirements' ],
49
52
  options: {
@@ -53,7 +56,12 @@ module.exports = {
53
56
  csrfExceptions: [
54
57
  'login'
55
58
  ],
56
- bearerTokens: true
59
+ bearerTokens: true,
60
+ throttle: {
61
+ allowedAttempts: 3,
62
+ perMinutes: 1,
63
+ lockoutMinutes: 1
64
+ }
57
65
  },
58
66
  async init(self) {
59
67
  self.passport = new Passport();
@@ -144,6 +152,9 @@ module.exports = {
144
152
  expireCookie.expires = new Date(0);
145
153
  const name = self.apos.modules['@apostrophecms/express'].sessionOptions.name;
146
154
  req.res.header('set-cookie', expireCookie.serialize(name, 'deleted'));
155
+
156
+ // TODO: get cookie name from config
157
+ req.res.cookie(`${self.apos.shortName}.${loggedInCookieName}`, 'false');
147
158
  }
148
159
  },
149
160
  // invokes the `props(req, user)` function for the requirement specified by
@@ -166,8 +177,16 @@ module.exports = {
166
177
  },
167
178
  async requirementVerify(req) {
168
179
  const name = self.apos.launder.string(req.body.name);
180
+ const loginNamespace = `${loginAttemptsNamespace}/${name}`;
169
181
 
170
- const { user } = await self.findIncompleteTokenAndUser(req, req.body.incompleteToken);
182
+ const { user } = await self.findIncompleteTokenAndUser(
183
+ req,
184
+ req.body.incompleteToken
185
+ );
186
+
187
+ if (!user) {
188
+ throw self.apos.error('invalid');
189
+ }
171
190
 
172
191
  const requirement = self.requirements[name];
173
192
 
@@ -179,7 +198,15 @@ module.exports = {
179
198
  throw self.apos.error('invalid', 'You must provide a verify method in your requirement');
180
199
  }
181
200
 
201
+ const { cachedAttempts, reached } = await self
202
+ .checkLoginAttempts(user.username, loginNamespace);
203
+
204
+ if (reached) {
205
+ throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached'));
206
+ }
207
+
182
208
  try {
209
+
183
210
  await requirement.verify(req, req.body.value, user);
184
211
 
185
212
  const token = await self.bearerTokens.findOne({
@@ -198,8 +225,16 @@ module.exports = {
198
225
  $pull: { requirementsToVerify: name }
199
226
  });
200
227
 
228
+ await self.clearLoginAttempts(user.username, loginNamespace);
229
+
201
230
  return {};
202
231
  } catch (err) {
232
+ await self.addLoginAttempt(
233
+ user.username,
234
+ cachedAttempts,
235
+ loginNamespace
236
+ );
237
+
203
238
  err.data = err.data || {};
204
239
  err.data.requirement = name;
205
240
  throw err;
@@ -553,48 +588,64 @@ module.exports = {
553
588
  // Implementation detail of the login route. Log in the user, or if there are
554
589
  // `requirements` that require password verification occur first, return an incomplete token.
555
590
  async initialLogin(req) {
556
- // Initial login step
557
591
  const username = self.apos.launder.string(req.body.username);
558
592
  const password = self.apos.launder.string(req.body.password);
593
+
559
594
  if (!(username && password)) {
560
595
  throw self.apos.error('invalid', req.t('apostrophe:loginPageBothRequired'));
561
596
  }
562
- const { earlyRequirements, lateRequirements } = self.filterRequirements();
563
- for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
564
- try {
565
- await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
566
- } catch (e) {
567
- e.data = e.data || {};
568
- e.data.requirement = name;
569
- throw e;
570
- }
597
+
598
+ const { cachedAttempts, reached } = await self.checkLoginAttempts(username);
599
+
600
+ if (reached) {
601
+ throw self.apos.error('invalid', req.t('apostrophe:loginMaxAttemptsReached', {
602
+ count: self.options.throttle.lockoutMinutes
603
+ }));
571
604
  }
572
- const user = await self.apos.login.verifyLogin(username, password);
573
- if (!user) {
605
+
606
+ try {
607
+ // Initial login step
608
+ const { earlyRequirements, lateRequirements } = self.filterRequirements();
609
+ for (const [ name, requirement ] of Object.entries(earlyRequirements)) {
610
+ try {
611
+ await requirement.verify(req, req.body.requirements && req.body.requirements[name]);
612
+ } catch (e) {
613
+ e.data = e.data || {};
614
+ e.data.requirement = name;
615
+ throw e;
616
+ }
617
+ }
618
+ const user = await self.apos.login.verifyLogin(username, password);
619
+ if (!user) {
574
620
  // For security reasons we may not tell the user which case applies
575
- throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
576
- }
621
+ throw self.apos.error('invalid', req.t('apostrophe:loginPageBadCredentials'));
622
+ }
577
623
 
578
- const requirementsToVerify = Object.keys(lateRequirements);
624
+ const requirementsToVerify = Object.keys(lateRequirements);
579
625
 
580
- if (requirementsToVerify.length) {
581
- const token = cuid();
626
+ if (requirementsToVerify.length) {
627
+ const token = cuid();
628
+
629
+ await self.bearerTokens.insert({
630
+ _id: token,
631
+ userId: user._id,
632
+ requirementsToVerify,
633
+ // Default lifetime of 1 hour is generous to permit situations like
634
+ // installing a TOTP app for the first time
635
+ expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
636
+ });
637
+
638
+ await self.clearLoginAttempts(user.username);
639
+
640
+ return {
641
+ incompleteToken: token
642
+ };
643
+ }
582
644
 
583
- await self.bearerTokens.insert({
584
- _id: token,
585
- userId: user._id,
586
- requirementsToVerify,
587
- // Default lifetime of 1 hour is generous to permit situations like
588
- // installing a TOTP app for the first time
589
- expires: new Date(new Date().getTime() + (self.options.incompleteLifetime || 60 * 60 * 1000))
590
- });
591
- return {
592
- incompleteToken: token
593
- };
594
- } else {
595
645
  const session = self.apos.launder.boolean(req.body.session);
596
646
  if (session) {
597
647
  await self.passportLogin(req, user);
648
+ await self.clearLoginAttempts(user.username);
598
649
  } else {
599
650
  const token = cuid();
600
651
  await self.bearerTokens.insert({
@@ -602,17 +653,30 @@ module.exports = {
602
653
  userId: user._id,
603
654
  expires: new Date(new Date().getTime() + (self.options.bearerTokens.lifetime || (86400 * 7 * 2)) * 1000)
604
655
  });
656
+
657
+ await self.clearLoginAttempts(user.username);
658
+
605
659
  return {
606
660
  token
607
661
  };
608
662
  }
663
+ } catch (err) {
664
+ await self.addLoginAttempt(username, cachedAttempts);
665
+
666
+ throw err;
609
667
  }
610
668
  },
611
669
 
612
670
  filterRequirements() {
613
671
  return {
614
- earlyRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'beforeSubmit')),
615
- lateRequirements: Object.fromEntries(Object.entries(self.requirements).filter(([ name, requirement ]) => requirement.phase === 'afterPasswordVerified'))
672
+ earlyRequirements: Object.fromEntries(
673
+ Object.entries(self.requirements)
674
+ .filter(([ _, requirement ]) => requirement.phase === 'beforeSubmit')
675
+ ),
676
+ lateRequirements: Object.fromEntries(
677
+ Object.entries(self.requirements)
678
+ .filter(([ _, requirement ]) => requirement.phase === 'afterPasswordVerified')
679
+ )
616
680
  };
617
681
  },
618
682
 
@@ -624,6 +688,63 @@ module.exports = {
624
688
  })(user);
625
689
  };
626
690
  await passportLogin(user);
691
+ },
692
+
693
+ async addLoginAttempt (
694
+ username,
695
+ attempts,
696
+ namespace = loginAttemptsNamespace
697
+ ) {
698
+ if (typeof attempts !== 'number') {
699
+ await self.apos.cache.set(namespace,
700
+ username,
701
+ 1,
702
+ self.options.throttle.perMinutes * 60
703
+ );
704
+ } else {
705
+ await self.apos.cache.cacheCollection.updateOne(
706
+ {
707
+ namespace,
708
+ key: username
709
+ },
710
+ {
711
+ $inc: {
712
+ value: 1
713
+ }
714
+ }
715
+ );
716
+ }
717
+ },
718
+
719
+ async checkLoginAttempts (username, namespace = loginAttemptsNamespace) {
720
+ const cachedAttempts = await self.apos.cache.get(namespace, username);
721
+ const { allowedAttempts } = self.options.throttle;
722
+
723
+ if (!cachedAttempts || cachedAttempts < allowedAttempts) {
724
+ return { cachedAttempts };
725
+ }
726
+
727
+ // When this is the first time we reach the limit
728
+ // we set the lifetime only once with lockoutMinutes
729
+ if (cachedAttempts === allowedAttempts) {
730
+ await self.apos.cache.set(namespace,
731
+ username,
732
+ cachedAttempts + 1,
733
+ self.options.throttle.lockoutMinutes * 60
734
+ );
735
+ }
736
+
737
+ return {
738
+ cachedAttempts,
739
+ reached: true
740
+ };
741
+ },
742
+
743
+ async clearLoginAttempts (username, namespace = loginAttemptsNamespace) {
744
+ await self.apos.cache.cacheCollection.deleteOne({
745
+ namespace,
746
+ key: username
747
+ });
627
748
  }
628
749
  };
629
750
  },
@@ -672,7 +793,12 @@ module.exports = {
672
793
  },
673
794
  passportSession: {
674
795
  before: '@apostrophecms/i18n',
675
- middleware: self.passport.session()
796
+ middleware: (() => {
797
+ // Wrap the passport middleware so that if the apikey or bearer token
798
+ // middleware already supplied req.user, that wins (explicit wins over implicit)
799
+ const passportSession = self.passport.session();
800
+ return (req, res, next) => req.user ? next() : passportSession(req, res, next);
801
+ })()
676
802
  },
677
803
  honorLoginInvalidBefore: {
678
804
  before: '@apostrophecms/i18n',
@@ -695,6 +821,17 @@ module.exports = {
695
821
  return next();
696
822
  }
697
823
  }
824
+ },
825
+ addLoggedInCookie: {
826
+ before: '@apostrophecms/i18n',
827
+ middleware(req, res, next) {
828
+ // TODO: get cookie name from config
829
+ const cookieName = `${self.apos.shortName}.${loggedInCookieName}`;
830
+ if (req.user && req.cookies[cookieName] !== 'true') {
831
+ res.cookie(cookieName, 'true');
832
+ }
833
+ return next();
834
+ }
698
835
  }
699
836
  };
700
837
  }
@@ -2,6 +2,7 @@
2
2
  <transition name="fade-stage">
3
3
  <div
4
4
  class="apos-login apos-theme-dark"
5
+ data-apos-test="loginForm"
5
6
  v-show="loaded"
6
7
  :class="themeClass"
7
8
  >
@@ -35,6 +36,7 @@
35
36
  <!-- TODO -->
36
37
  <!-- <a href="#" class="apos-login__link">Forgot Password</a> -->
37
38
  <AposButton
39
+ data-apos-test="loginSubmit"
38
40
  :busy="busy"
39
41
  :disabled="disabled"
40
42
  type="primary"
@@ -128,7 +130,8 @@ export default {
128
130
  return this.mounted && this.beforeCreateFinished;
129
131
  },
130
132
  disabled() {
131
- return this.doc.hasErrors || !!this.beforeSubmitRequirements.find(requirement => !requirement.done);
133
+ return this.doc.hasErrors ||
134
+ !!this.beforeSubmitRequirements.find(requirement => !requirement.done);
132
135
  },
133
136
  beforeSubmitRequirements() {
134
137
  return this.requirements.filter(requirement => requirement.phase === 'beforeSubmit');
@@ -274,12 +277,14 @@ export default {
274
277
  location.assign(`${apos.prefix}/`);
275
278
  },
276
279
  async requirementBlock(requirementBlock) {
277
- const requirement = this.requirements.find(requirement => requirement.name === requirementBlock.name);
280
+ const requirement = this.requirements
281
+ .find(requirement => requirement.name === requirementBlock.name);
278
282
  requirement.done = false;
279
283
  requirement.value = undefined;
280
284
  },
281
285
  async requirementDone(requirementDone, value) {
282
- const requirement = this.requirements.find(requirement => requirement.name === requirementDone.name);
286
+ const requirement = this.requirements
287
+ .find(requirement => requirement.name === requirementDone.name);
283
288
 
284
289
  if (requirement.phase === 'beforeSubmit') {
285
290
  requirement.done = true;
@@ -177,6 +177,11 @@ module.exports = {
177
177
  return async function(req, res) {
178
178
  try {
179
179
  const result = await fn(req);
180
+
181
+ if (req.method === 'GET' && req.user) {
182
+ res.header('Cache-Control', 'no-store');
183
+ }
184
+
180
185
  res.status(200);
181
186
  res.send(result);
182
187
  } catch (err) {
@@ -446,6 +451,27 @@ module.exports = {
446
451
  );
447
452
  },
448
453
 
454
+ setMaxAge(req, maxAge) {
455
+ if (typeof maxAge !== 'number') {
456
+ self.apos.util.warnDev(`"maxAge" property must be defined as a number in the "${self.__meta.name}" module's cache options"`);
457
+ return;
458
+ }
459
+
460
+ // A cookie in session doesn't mean we can't cache, nor an empty flash or passport object.
461
+ // Other session properties must be assumed to be specific to the user, with a possible
462
+ // impact on the response, and thus mean this request must not be cached.
463
+ // Same rule as in [express-cache-on-demand](https://github.com/apostrophecms/express-cache-on-demand/blob/master/index.js#L102)
464
+ const isSessionClearForCaching = Object.entries(req.session).every(([ key, val ]) =>
465
+ key === 'cookie' || (
466
+ (key === 'flash' || key === 'passport') && _.isEmpty(val)
467
+ )
468
+ );
469
+ const isSafeToCache = !req.user && isSessionClearForCaching;
470
+ const cacheControlValue = isSafeToCache ? `max-age=${maxAge}` : 'no-store';
471
+
472
+ req.res.header('Cache-Control', cacheControlValue);
473
+ },
474
+
449
475
  // Call from init once if this module implements the `getBrowserData` method.
450
476
  // The data returned by `getBrowserData(req)` will then be available on
451
477
  // `apos.modules['your-module-name']` in the browser.
@@ -119,6 +119,10 @@ module.exports = {
119
119
  project: self.getAllProjection()
120
120
  }).toObject();
121
121
 
122
+ if (self.options.cache && self.options.cache.api) {
123
+ self.setMaxAge(req, self.options.cache.api.maxAge);
124
+ }
125
+
122
126
  if (!page) {
123
127
  throw self.apos.error('notfound');
124
128
  }
@@ -137,6 +141,11 @@ module.exports = {
137
141
  }
138
142
  } else {
139
143
  const result = await self.getRestQuery(req).and({ level: 0 }).toObject();
144
+
145
+ if (self.options.cache && self.options.cache.api) {
146
+ self.setMaxAge(req, self.options.cache.api.maxAge);
147
+ }
148
+
140
149
  if (!result) {
141
150
  throw self.apos.error('notfound');
142
151
  }
@@ -168,6 +177,11 @@ module.exports = {
168
177
  self.publicApiCheck(req);
169
178
  const criteria = self.getIdCriteria(_id);
170
179
  const result = await self.getRestQuery(req).and(criteria).toObject();
180
+
181
+ if (self.options.cache && self.options.cache.api) {
182
+ self.setMaxAge(req, self.options.cache.api.maxAge);
183
+ }
184
+
171
185
  if (!result) {
172
186
  throw self.apos.error('notfound');
173
187
  }
@@ -220,7 +234,7 @@ module.exports = {
220
234
  }
221
235
 
222
236
  return self.withLock(req, async () => {
223
- const targetPage = await self.findForEditing(req, targetId ? { _id: targetId } : { level: 0 }).ancestors(true).permission('edit').toObject();
237
+ const targetPage = await self.findForEditing(req, targetId ? self.getIdCriteria(targetId) : { level: 0 }).ancestors(true).permission('edit').toObject();
224
238
  if (!targetPage) {
225
239
  throw self.apos.error('notfound');
226
240
  }
@@ -1415,6 +1429,10 @@ database.`);
1415
1429
  await self.emit('serveQuery', query);
1416
1430
  req.data.bestPage = await query.toObject();
1417
1431
  self.evaluatePageMatch(req);
1432
+
1433
+ if (self.options.cache && self.options.cache.page) {
1434
+ self.setMaxAge(req, self.options.cache.page.maxAge);
1435
+ }
1418
1436
  },
1419
1437
  // Normalize req.slug to account for unneeded trailing whitespace,
1420
1438
  // trailing slashes other than the root, and double slash based open
@@ -200,6 +200,11 @@ module.exports = {
200
200
  if (query.get('countsResults')) {
201
201
  result.counts = query.get('countsResults');
202
202
  }
203
+
204
+ if (self.options.cache && self.options.cache.api) {
205
+ self.setMaxAge(req, self.options.cache.api.maxAge);
206
+ }
207
+
203
208
  return result;
204
209
  }
205
210
  ],
@@ -209,6 +214,11 @@ module.exports = {
209
214
  _id = self.inferIdLocaleAndMode(req, _id);
210
215
  self.publicApiCheck(req);
211
216
  const doc = await self.getRestQuery(req).and({ _id }).toObject();
217
+
218
+ if (self.options.cache && self.options.cache.api) {
219
+ self.setMaxAge(req, self.options.cache.api.maxAge);
220
+ }
221
+
212
222
  if (!doc) {
213
223
  throw self.apos.error('notfound');
214
224
  }
@@ -193,6 +193,12 @@ module.exports = {
193
193
  res: {
194
194
  redirect(url) {
195
195
  req.res.redirectedTo = url;
196
+ },
197
+ header(key, value) {
198
+ req.res.headers = {
199
+ ...(req.res.headers || {}),
200
+ [key]: value
201
+ };
196
202
  }
197
203
  },
198
204
  t(key, options = {}) {
@@ -643,6 +643,7 @@ module.exports = {
643
643
  modules: {},
644
644
  prefix: req.prefix,
645
645
  sitePrefix: self.apos.prefix,
646
+ shortName: self.apos.shortName,
646
647
  locale: req.locale,
647
648
  csrfCookieName: self.apos.csrfCookieName,
648
649
  tabId: self.apos.util.generateId(),
@@ -16,6 +16,7 @@
16
16
  <!-- TODO refactor buttons to take a single config obj -->
17
17
  <AposButton
18
18
  class="apos-context-menu__btn"
19
+ data-apos-test="contextMenuTrigger"
19
20
  @click.stop="buttonClicked($event)"
20
21
  v-bind="button"
21
22
  :state="buttonState"
@@ -19,6 +19,7 @@
19
19
  <AposContextMenuItem
20
20
  v-for="item in menu"
21
21
  :key="item.action"
22
+ :data-apos-test-context-menu-item="item.action"
22
23
  :menu-item="item"
23
24
  @clicked="menuItemClicked"
24
25
  :open="isOpen"
@@ -432,7 +432,12 @@ module.exports = {
432
432
 
433
433
  // Initialize the [credential](https://npmjs.org/package/credential) module.
434
434
  initializeCredential() {
435
- self.pw = credential();
435
+ self.pw = credential({
436
+ // For efficient unit tests only. Reducing the work factor
437
+ // for actual credentials increases the speed of brute force attacks
438
+ // if the database is ever compromised
439
+ work: self.options.insecurePasswords ? 0.01 : 1
440
+ });
436
441
  },
437
442
 
438
443
  // Implement the `@apostrophecms/user:add` command line task.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.14.2",
3
+ "version": "3.16.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {