@small-tech/auto-encrypt 4.3.0 → 5.0.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.
@@ -1,15 +1,9 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // FinaliseOrderRequest
4
- //
5
- // Attempts to finalise an order by posting the passed CSR (see RFC 2986).
6
- //
7
- // See RFC 8555 § 7.4 (Applying for Certificate Issuance).
8
- //
9
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
10
- // License: AGPLv3 or later.
11
- //
12
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ FinaliseOrderRequest
3
+
4
+ Copyright © 2020 Aral Balkan, Small Technology Foundation.
5
+ License: AGPLv3 or later.
6
+ */
13
7
 
14
8
  import AcmeRequest from '../AcmeRequest.js'
15
9
  import Throws from '../util/Throws.js'
@@ -17,16 +11,24 @@ import Throws from '../util/Throws.js'
17
11
  const throws = new Throws()
18
12
 
19
13
  export default class FinaliseOrderRequest extends AcmeRequest {
14
+ /**
15
+ Attempts to finalise an order by posting the passed CSR (see RFC 2986).
16
+
17
+ See RFC 8555 § 7.4 (Applying for Certificate Issuance).
18
+
19
+ @param { string | void } finaliseUrl
20
+ @param { string | void } csr
21
+ */
20
22
  async execute (finaliseUrl = throws.ifMissing(), csr = throws.ifMissing()) {
21
23
 
22
- const payload = { csr }
24
+ const payload = { csr: /** @type { string } */ (csr) }
23
25
 
24
- const response = await super.execute(
25
- /* command = */ '', // see URL, below.
26
- /* payload = */ payload,
27
- /* useKid = */ true,
28
- /* successCodes = */ [200],
29
- /* url = */ finaliseUrl
26
+ const response = await super.request(
27
+ /* command = */ '', // see URL, below.
28
+ /* payload = */ payload,
29
+ /* useKid = */ true,
30
+ /* successCodes = */ [200],
31
+ /* url = */ /** @type { string } */ (finaliseUrl)
30
32
  )
31
33
 
32
34
  return response
@@ -1,27 +1,26 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // NewAccountRequest
4
- //
5
- // Requests a new account (or existing account if one already exists) and saves
6
- // the returned kid for future use.
7
- //
8
- // See RFC 8555 § 7.3 (Account Management).
9
- //
10
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
11
- // License: AGPLv3 or later.
12
- //
13
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ NewAccountRequest
3
+
4
+ Copyright © 2020 Aral Balkan, Small Technology Foundation.
5
+ License: AGPLv3 or later.
6
+ */
14
7
 
15
8
  import AcmeRequest from '../AcmeRequest.js'
16
9
 
17
10
  export default class NewAccountRequest extends AcmeRequest {
11
+ /**
12
+ Requests a new account (or existing account if one already exists) and saves
13
+ the returned kid for future use.
14
+
15
+ See RFC 8555 § 7.3 (Account Management).
16
+ */
18
17
  async execute () {
19
18
  // Set the only required element.
20
19
  const payload = { termsOfServiceAgreed: true }
21
20
 
22
21
  // Note: a 201 (Created) is returned if the account is new, a 200 (Success) is returned
23
22
  // ===== if an existing account is found. (RFC 8555 § 7.3 & 7.3.1).
24
- const response = await super.execute('newAccount', payload, /* useKid = */ false, /* successCodes = */[200, 201])
23
+ const response = await super.request('newAccount', payload, /* useKid = */ false, /* successCodes = */[200, 201])
25
24
 
26
25
  // This is what we will be using in the kid field in the future
27
26
  // **in place of** the JWK.
@@ -1,27 +1,40 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // NewOrderRequest
4
- //
5
- // Creates a new order request to start the process of obtaining
6
- // Let’s Encrypt TLS certificates.
7
- //
8
- // See RFC 8555 § 7.1.3 (Order Objects), 7.4 (Applying for Certificate Issuance)
9
- //
10
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
11
- // License: AGPLv3 or later.
12
- //
13
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ NewOrderRequest
14
3
 
4
+ Copyright © 2020 Aral Balkan, Small Technology Foundation.
5
+ License: AGPLv3 or later.
6
+ */
7
+
8
+ import net from 'node:net'
15
9
  import AcmeRequest from '../AcmeRequest.js'
16
10
  import Throws from '../util/Throws.js'
17
11
  const throws = new Throws()
18
12
 
19
13
  export default class NewOrderRequest extends AcmeRequest {
20
- async execute (configuration = throws.ifMissing()) {
21
- const identifiers = configuration.domains.map(domain => { return { type: 'dns', value: domain} })
22
- const payload = { identifiers }
14
+ /**
15
+ Creates a new order request to start the process of obtaining
16
+ Let’s Encrypt TLS certificates.
17
+
18
+ See RFC 8555 § 7.1.3 (Order Objects), 7.4 (Applying for Certificate Issuance)
23
19
 
24
- const response = await super.execute('newOrder', payload, /* useKid = */ true, /* successCodes = */ [201])
20
+ @param { import('../Configuration.js').default | void } configuration
21
+ @param { string | void } ariCertId
22
+ */
23
+ async execute (configuration = throws.ifMissing(), ariCertId = throws.ifMissing()) {
24
+ const identifiers = /** @type { import('../Configuration.js').default } */(configuration).domains.map(domain => {
25
+ // Determine whether this is a domain name or an IP address (IPv4 or IPv6)
26
+ return { type: net.isIP(domain) ? 'ip' : 'dns', value: domain} }
27
+ )
28
+ // As of end of December, 2025, Auto Encrypt only supports
29
+ // Let’s Encrypt’s new short-lived profiles.
30
+ // See: https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/
31
+ const payload = { identifiers, profile: 'shortlived' }
32
+ // If this is a renewal, mark the certificate it’s replacing (ARI)
33
+ // See: https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients/#step-6-indicating-which-certificate-is-replaced-by-this-new-order
34
+ if (ariCertId !== null) {
35
+ payload.replaces = ariCertId
36
+ }
37
+ const response = await super.request('newOrder', payload, /* useKid = */ true, /* successCodes = */ [201])
25
38
  return response
26
39
  }
27
40
  }
@@ -1,17 +1,9 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // ReadyForChallengeValidationRequest
4
- //
5
- // The client indicates to the server that it is ready for the challenge
6
- // validation by sending an empty JSON body ("{}") carried in a POST
7
- // request to the challenge URL (not the authorization URL).
8
- //
9
- // – RFC 8555 § 7.5.1 (Responding to Challenges)
10
- //
11
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
12
- // License: AGPLv3 or later.
13
- //
14
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ ReadyForChallengeValidationRequest
3
+
4
+ Copyright © 2020-present Aral Balkan, Small Technology Foundation.
5
+ License: AGPLv3 or later.
6
+ */
15
7
 
16
8
  import AcmeRequest from '../AcmeRequest.js'
17
9
  import Throws from '../util/Throws.js'
@@ -19,15 +11,24 @@ import Throws from '../util/Throws.js'
19
11
  const throws = new Throws()
20
12
 
21
13
  export default class ReadyForChallengeValidationRequest extends AcmeRequest {
14
+ /**
15
+ The client indicates to the server that it is ready for the challenge
16
+ validation by sending an empty JSON body ("{}") carried in a POST
17
+ request to the challenge URL (not the authorization URL).
18
+
19
+ – RFC 8555 § 7.5.1 (Responding to Challenges)
20
+
21
+ @param { string | void } challengeUrl
22
+ */
22
23
  async execute (challengeUrl = throws.ifMissing()) {
23
24
  const emptyPayload = {}
24
25
 
25
- const response = await super.execute(
26
+ const response = await super.request(
26
27
  /* command = */ '', // see URL, below.
27
28
  /* payload = */ emptyPayload,
28
29
  /* useKid = */ true,
29
30
  /* successCodes = */ [200],
30
- /* url = */ challengeUrl
31
+ /* url = */ /** @type { string } */ (challengeUrl)
31
32
  )
32
33
 
33
34
  return response
package/lib/acmeCsr.js CHANGED
@@ -1,87 +1,144 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- //
3
- // ACME CSR
4
- //
5
- // Given a regular Certification Request in PEM format, returns an
6
- // ACME-formatted CSR (single-line PEM without the header or footer and encoded
7
- // in base64Url instead of base64 format).
8
- //
9
- // See RFC 8555 § 7.4 (Applying for Certificate Issuance).
10
- //
11
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
12
- // License: AGPLv3 or later.
13
- //
14
- ////////////////////////////////////////////////////////////////////////////////
15
- import forge from 'node-forge'
16
- const DNS = 2 // The ANS.1 type for DNS name.
17
-
18
- // Returns a valid ACME-formatted (RFC 8555) CSR.
1
+ /**
2
+ ACME CSR
3
+
4
+ Given a regular Certification Request in PEM format, returns an
5
+ ACME-formatted CSR (single-line PEM without the header or footer and encoded
6
+ in base64Url instead of base64 format).
7
+
8
+ See RFC 8555 § 7.4 (Applying for Certificate Issuance).
9
+
10
+ Copyright © 2020-present Aral Balkan, Small Technology Foundation.
11
+ License: AGPLv3 or later.
12
+ */
13
+
14
+ import net from 'node:net'
15
+ import crypto from 'node:crypto'
16
+ import {
17
+ CertificationRequest,
18
+ CertificationRequestInfo,
19
+ SubjectPublicKeyInfo,
20
+ Extensions
21
+ } from './x.509/rfc5280.js'
19
22
 
20
23
  /**
21
- * Return an ACME-formatted (RFC 8555) CSR given a list of domains and a Jose JWK.rsaKey.
22
- *
23
- * @param {String[]} domains
24
- * @param {JWK.rsaKey} key
25
- * @returns {String} An ACME-formatted CSR in PEM format.
26
- */
24
+ Return an ACME-formatted (RFC 8555) CSR given a list of domains and a crypto.KeyObject.
25
+
26
+ @param { string[] } domains
27
+ @param { crypto.KeyObject } key
28
+ @returns { string } An ACME-formatted CSR in PEM format.
29
+ */
27
30
  export default function (domains, key) { return pemToAcmeCsr(csrAsPem(domains, key)) }
28
31
 
29
32
  /**
30
- * Create a CSR given a list of domains and a Jose JWK.rsaKey.
31
- *
32
- * @param {String[]} domains
33
- * @param {JWK.rsaKey} key
34
- * @returns {String} A CSR in PEM format.
35
- */
33
+ Create a CSR given a list of domains and a crypto.KeyObject.
34
+
35
+ @param { string[] } domains
36
+ @param { crypto.KeyObject } key
37
+ @returns { string } A CSR in PEM format.
38
+ */
36
39
  function csrAsPem (domains, key) {
37
- var csr = forge.pki.createCertificationRequest()
40
+ const altNames = domains.map(domain => {
41
+ if (net.isIP(domain)) {
42
+ // IP address.
43
+ let ipBytes
44
+ if (net.isIPv4(domain)) {
45
+ ipBytes = Buffer.from(domain.split('.').map(Number))
46
+ } else {
47
+ // IPv6.
48
+ const parts = domain.split('::')
49
+ const left = parts[0] ? parts[0].split(':') : []
50
+ const right = parts[1] ? parts[1].split(':') : []
51
+ const missing = 8 - (left.length + right.length)
52
+ const expanded = [...left, ...Array(missing).fill('0'), ...right]
53
+ ipBytes = Buffer.from(expanded.flatMap(x => {
54
+ const hex = x.padStart(4, '0')
55
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16)]
56
+ }))
57
+ }
38
58
 
39
- const keys = {
40
- public: forge.pki.publicKeyFromPem(key.toPEM(/* private = */ false)),
41
- private: forge.pki.privateKeyFromPem(key.toPEM(/* private = */ true))
42
- }
59
+ return { type: 'iPAddress', value: ipBytes }
60
+ } else {
61
+ // Domain.
62
+ return { type: 'dNSName', value: domain }
63
+ }
64
+ })
43
65
 
44
- csr.publicKey = keys.public
66
+ const spki = crypto.createPublicKey(key).export({ type: 'spki', format: 'der' })
67
+ const subjectPKInfo = SubjectPublicKeyInfo.decode(spki, 'der')
45
68
 
46
- const altNames = domains.map(domain => {
47
- return {type: DNS, value: domain}
48
- })
69
+ const certificationRequestInfo = {
70
+ version: 0,
71
+ // According to RFC 8555, we *either* need to specify the subject or
72
+ // the subjectAltName so skip the subject. (We use an empty subject.)
73
+ subject: { type: 'rdnSequence', value: [] },
74
+ subjectPKInfo,
75
+ attributes: [
76
+ {
77
+ type: [1, 2, 840, 113549, 1, 9, 14], // extensionRequest
78
+ values: [
79
+ Extensions.encode([
80
+ {
81
+ extnID: 'subjectAlternativeName',
82
+ critical: false,
83
+ extnValue: altNames
84
+ }
85
+ ], 'der')
86
+ ]
87
+ }
88
+ ]
89
+ }
49
90
 
50
- // According to RFC 8555, we *either* need to specify the subject or the subjectAltName so skip the subject.
51
- csr.setAttributes([{
52
- name: 'extensionRequest',
53
- extensions: [{
54
- name: 'subjectAltName',
55
- altNames
56
- }]
57
- }])
91
+ const tbsDer = CertificationRequestInfo.encode(certificationRequestInfo, 'der')
92
+ const signature = crypto.sign('sha256', tbsDer, key)
58
93
 
59
- csr.sign(keys.private, forge.md.sha256.create())
94
+ const csr = {
95
+ certificationRequestInfo,
96
+ signatureAlgorithm: {
97
+ algorithm: [1, 2, 840, 113549, 1, 1, 11], // sha256WithRSAEncryption
98
+ parameters: Buffer.alloc(0)
99
+ },
100
+ signature: {
101
+ data: signature,
102
+ unused: 0
103
+ }
104
+ }
60
105
 
61
- const pem = forge.pki.certificationRequestToPem(csr)
106
+ const der = CertificationRequest.encode(csr, 'der')
107
+ const pem = `-----BEGIN CERTIFICATE REQUEST-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE REQUEST-----`
62
108
  return pem
63
109
  }
64
110
 
111
+ /**
112
+ @param { string } csr
113
+ */
65
114
  function pemToAcmeCsr (csr) {
66
115
  csr = pemToHeaderlessSingleLinePem(csr)
67
116
  csr = base64ToBase64Url(csr)
68
117
  return csr
69
118
  }
70
119
 
71
- // Strip the PEM headers and covert to a non-newline delimited Base64Url-encoded
72
- // string as required by RFC 8555 (would be nice if this was explicitly-mentioned in the spec).
120
+ /**
121
+ Strip the PEM headers and covert to a non-newline delimited Base64Url-encoded
122
+ string as required by RFC 8555 (would be nice if this was explicitly-mentioned in the spec).
123
+
124
+ @param { string } str
125
+ */
73
126
  function pemToHeaderlessSingleLinePem (str) {
74
127
  return str
75
- .replace('-----BEGIN CERTIFICATE REQUEST-----', '')
76
- .replace('-----END CERTIFICATE REQUEST-----', '')
77
- .replace(/\n/g, '')
128
+ .replace('-----BEGIN CERTIFICATE REQUEST-----', '')
129
+ .replace('-----END CERTIFICATE REQUEST-----', '')
130
+ .replace(/\n/g, '')
78
131
  }
79
132
 
80
- // Convert a base64-encoded string into a base64Url-encoded string.
133
+ /**
134
+ Convert base64-encoded string into a base64Url-encoded string.
135
+
136
+ @param { string } str
137
+ */
81
138
  function base64ToBase64Url (str) {
82
139
  return str
83
- .replace(/\+/g, '-')
84
- .replace(/\//g, '_')
85
- .replace(/=/g, '')
86
- .replace(/\r/g, '')
140
+ .replace(/\+/g, '-')
141
+ .replace(/\//g, '_')
142
+ .replace(/=/g, '')
143
+ .replace(/\r/g, '')
87
144
  }
@@ -1,23 +1,24 @@
1
- ////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // AccountIdentity
4
- //
5
- // Generates, stores, loads, and saves the account identity. The default
6
- // account identity file path is:
7
- //
8
- // ~/.small-tech.org/auto-encrypt/account.pem
9
- //
10
- // Copyright © 2020 Aral Balkan, Small Technology Foundation.
11
- // License: AGPLv3 or later.
12
- //
13
- ////////////////////////////////////////////////////////////////////////////////
1
+ /**
2
+ AccountIdentity
3
+
4
+ Copyright © 2020 Aral Balkan, Small Technology Foundation.
5
+ License: AGPLv3 or later.
6
+ */
14
7
 
15
8
  import Identity from '../Identity.js'
16
9
  import Throws from '../util/Throws.js'
17
10
  const throws = new Throws()
18
11
 
19
12
  export default class AccountIdentity extends Identity {
13
+ /**
14
+ Generates, stores, loads, and saves the account identity. The default
15
+ account identity file path is:
16
+
17
+ ~/.small-tech.org/auto-encrypt/account.pem
18
+
19
+ @param {import('../Configuration.js').default | void} configuration
20
+ */
20
21
  constructor (configuration = throws.ifMissing()) {
21
- super(configuration, 'accountIdentityPath')
22
+ super(/** @type { import('../Configuration.js').default } */ (configuration), 'accountIdentityPath')
22
23
  }
23
24
  }
@@ -3,24 +3,24 @@ import Throws from '../util/Throws.js'
3
3
  const throws = new Throws()
4
4
 
5
5
  /**
6
- * Generates, stores, loads, and saves the certificate identity. The default
7
- * certificate identity file path is:
8
- *
9
- * ~/.small-tech.org/auto-encrypt/certificate-identity.pem
10
- *
11
- * @class CertificateIdentity
12
- * @extends {Identity}
13
- * @copyright Aral Balkan, Small Technology Foundation
14
- * @license AGPLv3 or later
15
- */
6
+ Generates, stores, loads, and saves the certificate identity. The default
7
+ certificate identity file path is:
8
+
9
+ ~/.small-tech.org/auto-encrypt/certificate-identity.pem
10
+
11
+ @class CertificateIdentity
12
+ @extends {Identity}
13
+ @copyright Aral Balkan, Small Technology Foundation
14
+ @license AGPLv3 or later
15
+ */
16
16
  export default class CertificateIdentity extends Identity {
17
17
  /**
18
- * Creates an instance of CertificateIdentity.
19
- *
20
- * @param {Configuration} configuration (Required) Configuration instance.
21
- * @memberof CertificateIdentity
22
- */
18
+ Creates an instance of CertificateIdentity.
19
+
20
+ @param { import('../Configuration.js').default | void } configuration (Required) Configuration instance.
21
+ @memberof CertificateIdentity
22
+ */
23
23
  constructor (configuration = throws.ifMissing()) {
24
- super(configuration, 'certificateIdentityPath')
24
+ super(/** @type { import('../Configuration.js').default } */ (configuration), 'certificateIdentityPath')
25
25
  }
26
26
  }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Monkey patches the TLS module to accept run-time root and intermediary Certificate Authority (CA) certificates.
3
- *
4
- * Based on the method provided by David Barral at https://link.medium.com/6xHYLeUVq5.
5
- *
6
- * @module
7
- * @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
8
- * @license AGPLv3 or later.
9
- */
2
+ Monkey patches the TLS module to accept run-time root and intermediary Certificate Authority (CA) certificates.
3
+
4
+ Based on the method provided by David Barral at https://link.medium.com/6xHYLeUVq5.
5
+
6
+ @module
7
+ @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
8
+ @license AGPLv3 or later.
9
+ */
10
10
  import fs from 'fs'
11
11
  import tls from 'tls'
12
12
  import path from 'path'
@@ -15,10 +15,10 @@ import { fileURLToPath } from 'url'
15
15
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
16
16
 
17
17
  /**
18
- * Monkey patches the TLS module to accept the Let’s Encrypt staging certificate.
19
- *
20
- * @alias module:lib/MonkeyPatchTls
21
- */
18
+ Monkey patches the TLS module to accept the Let’s Encrypt staging certificate.
19
+
20
+ @alias module:lib/MonkeyPatchTls
21
+ */
22
22
  export default function monkeyPatchTLS () {
23
23
  const originalCreateSecureContext = tls.createSecureContext
24
24
 
@@ -1,8 +1,6 @@
1
- //////////////////////////////////////////////////////////////////////
2
- //
3
- // Unit test helpers.
4
- //
5
- //////////////////////////////////////////////////////////////////////
1
+ /**
2
+ Unit test helpers.
3
+ */
6
4
 
7
5
  import fs from 'fs'
8
6
  import os from 'os'
@@ -27,7 +25,7 @@ export class MockServer {
27
25
  static async getInstanceAsync (responseHandler = throws.ifMissing()) {
28
26
  this.#isBeingInstantiatedViaAsyncFactoryMethod = true
29
27
  const instance = new MockServer(responseHandler)
30
- await instance.create(responseHandler)
28
+ await instance.create()
31
29
  this.#isBeingInstantiatedViaAsyncFactoryMethod = false
32
30
  return instance
33
31
  }
@@ -96,16 +94,16 @@ export class TestContext {
96
94
  //
97
95
 
98
96
  export function timeIt(func) {
99
- const startTime = new Date()
97
+ const startTime = new Date().getTime()
100
98
  const returnValue = func()
101
- const endTime = new Date()
99
+ const endTime = new Date().getTime()
102
100
  return { returnValue, duration: endTime - startTime }
103
101
  }
104
102
 
105
103
  export async function timeItAsync(func) {
106
- const startTime = new Date()
104
+ const startTime = new Date().getTime()
107
105
  const returnValue = await func()
108
- const endTime = new Date()
106
+ const endTime = new Date().getTime()
109
107
  return { returnValue, duration: endTime - startTime }
110
108
  }
111
109
 
@@ -164,16 +162,3 @@ export function createTestSettingsPath () {
164
162
  fs.rmSync(testSettingsPath, { recursive: true, force: true })
165
163
  return testSettingsPath
166
164
  }
167
-
168
- export function initialiseStagingConfigurationWithOneDomainAtTestSettingsPath () {
169
- Configuration.reset()
170
- Configuration.initialise({
171
- domains: ['dev.ar.al'],
172
- server: new LetsEncryptServer(LetsEncryptServer.type.STAGING),
173
- settingsPath: createTestSettingsPath()
174
- })
175
- }
176
-
177
- export function setupStagingConfigurationWithOneDomainAtTestSettingsPath () {
178
- initialiseStagingConfigurationWithOneDomainAtTestSettingsPath()
179
- }
@@ -10,10 +10,10 @@ export default class Pluralise {
10
10
  }
11
11
 
12
12
  static isAre (array) {
13
- return array.length === 1 ? 'is' : 'are'
13
+ return array === undefined || array.length === 1 ? 'is' : 'are'
14
14
  }
15
15
 
16
16
  static word (word, array) {
17
- return array.length === 1 ? word : `${word}${this.requiresEs(word) ? 'es' : 's'}`
17
+ return array === undefined || array.length === 1 ? word : `${word}${this.requiresEs(word) ? 'es' : 's'}`
18
18
  }
19
19
  }