@proteinjs/user-server 1.22.0 → 1.22.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +10 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/src/authentication/PasswordHasher.d.ts +9 -0
  9. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
  10. package/dist/src/authentication/PasswordHasher.js +25 -0
  11. package/dist/src/authentication/PasswordHasher.js.map +1 -1
  12. package/dist/src/authentication/PasswordResetToken.d.ts +43 -12
  13. package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
  14. package/dist/src/authentication/PasswordResetToken.js +86 -21
  15. package/dist/src/authentication/PasswordResetToken.js.map +1 -1
  16. package/dist/src/authentication/authenticate.d.ts +15 -0
  17. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  18. package/dist/src/authentication/authenticate.js +55 -20
  19. package/dist/src/authentication/authenticate.js.map +1 -1
  20. package/dist/src/routes/devLogin.d.ts +1 -1
  21. package/dist/src/routes/devLogin.js +1 -1
  22. package/dist/src/routes/executePasswordReset.d.ts +6 -1
  23. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  24. package/dist/src/routes/executePasswordReset.js +16 -7
  25. package/dist/src/routes/executePasswordReset.js.map +1 -1
  26. package/dist/src/routes/initiatePasswordReset.d.ts +10 -2
  27. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  28. package/dist/src/routes/initiatePasswordReset.js +55 -34
  29. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  30. package/dist/src/routes/login.d.ts +8 -0
  31. package/dist/src/routes/login.d.ts.map +1 -1
  32. package/dist/src/routes/login.js +63 -22
  33. package/dist/src/routes/login.js.map +1 -1
  34. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  35. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  36. package/dist/src/throttle/ClientAddress.js +48 -0
  37. package/dist/src/throttle/ClientAddress.js.map +1 -0
  38. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  39. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  40. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  41. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  42. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  43. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  44. package/dist/src/throttle/RequestDigests.js +96 -0
  45. package/dist/src/throttle/RequestDigests.js.map +1 -0
  46. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  47. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  48. package/dist/src/throttle/SignInThrottle.js +91 -0
  49. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  50. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  51. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  52. package/dist/src/throttle/SlidingWindow.js +85 -0
  53. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  54. package/dist/test/ClientAddress.test.d.ts +2 -0
  55. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  56. package/dist/test/ClientAddress.test.js +51 -0
  57. package/dist/test/ClientAddress.test.js.map +1 -0
  58. package/dist/test/DbTraffic.d.ts +29 -0
  59. package/dist/test/DbTraffic.d.ts.map +1 -0
  60. package/dist/test/DbTraffic.js +106 -0
  61. package/dist/test/DbTraffic.js.map +1 -0
  62. package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
  63. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
  64. package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
  65. package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
  66. package/dist/test/ExecutePasswordReset.test.js +513 -124
  67. package/dist/test/ExecutePasswordReset.test.js.map +1 -1
  68. package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
  69. package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
  70. package/dist/test/InitiatePasswordReset.test.js +783 -0
  71. package/dist/test/InitiatePasswordReset.test.js.map +1 -0
  72. package/dist/test/LogCapture.d.ts +22 -0
  73. package/dist/test/LogCapture.d.ts.map +1 -0
  74. package/dist/test/LogCapture.js +127 -0
  75. package/dist/test/LogCapture.js.map +1 -0
  76. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  77. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  78. package/dist/test/LoginThrottle.integration.test.js +616 -0
  79. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  80. package/dist/test/MalformedResetTokens.d.ts +11 -0
  81. package/dist/test/MalformedResetTokens.d.ts.map +1 -0
  82. package/dist/test/MalformedResetTokens.js +35 -0
  83. package/dist/test/MalformedResetTokens.js.map +1 -0
  84. package/dist/test/RequestDigests.test.d.ts +2 -0
  85. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  86. package/dist/test/RequestDigests.test.js +73 -0
  87. package/dist/test/RequestDigests.test.js.map +1 -0
  88. package/dist/test/SlidingWindow.test.d.ts +2 -0
  89. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  90. package/dist/test/SlidingWindow.test.js +72 -0
  91. package/dist/test/SlidingWindow.test.js.map +1 -0
  92. package/dist/test/ValidateResetToken.test.js +156 -33
  93. package/dist/test/ValidateResetToken.test.js.map +1 -1
  94. package/dist/test/emulatorLock.test.d.ts +2 -0
  95. package/dist/test/emulatorLock.test.d.ts.map +1 -0
  96. package/dist/test/emulatorLock.test.js +32 -0
  97. package/dist/test/emulatorLock.test.js.map +1 -0
  98. package/dist/test/emulatorLockName.test.d.ts +2 -0
  99. package/dist/test/emulatorLockName.test.d.ts.map +1 -0
  100. package/dist/test/emulatorLockName.test.js +36 -0
  101. package/dist/test/emulatorLockName.test.js.map +1 -0
  102. package/generated/index.ts +1 -1
  103. package/index.ts +6 -1
  104. package/jest.config.js +2 -2
  105. package/package.json +3 -3
  106. package/src/authentication/PasswordHasher.ts +18 -1
  107. package/src/authentication/PasswordResetToken.ts +74 -20
  108. package/src/authentication/authenticate.ts +36 -9
  109. package/src/routes/devLogin.ts +1 -1
  110. package/src/routes/executePasswordReset.ts +10 -2
  111. package/src/routes/initiatePasswordReset.ts +46 -25
  112. package/src/routes/login.ts +39 -8
  113. package/src/throttle/ClientAddress.ts +47 -0
  114. package/src/throttle/PasswordResetThrottle.ts +62 -0
  115. package/src/throttle/RequestDigests.ts +97 -0
  116. package/src/throttle/SignInThrottle.ts +98 -0
  117. package/src/throttle/SlidingWindow.ts +90 -0
  118. package/test/ClientAddress.test.ts +54 -0
  119. package/test/DbTraffic.ts +43 -0
  120. package/test/DevLoginBootstrapAdmin.test.ts +3 -4
  121. package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
  122. package/test/ExecutePasswordReset.test.ts +308 -70
  123. package/test/InitiatePasswordReset.test.ts +456 -0
  124. package/test/LogCapture.ts +54 -0
  125. package/test/LoginThrottle.integration.test.ts +339 -0
  126. package/test/MalformedResetTokens.ts +27 -0
  127. package/test/RequestDigests.test.ts +81 -0
  128. package/test/SlidingWindow.test.ts +85 -0
  129. package/test/ValidateResetToken.test.ts +108 -24
  130. package/test/emulatorLock.js +5 -6
  131. package/test/emulatorLock.test.ts +32 -0
  132. package/test/emulatorLockName.test.ts +33 -0
  133. package/test/setup.js +2 -0
@@ -1,9 +1,13 @@
1
+ import { createHash } from 'crypto';
1
2
  import moment from 'moment';
2
3
  import { getDbAsSystem } from '@proteinjs/db';
3
4
  import { tables } from '@proteinjs/user';
4
5
  import { validateResetPasswordToken } from '../src/routes/validateResetPasswordToken';
5
6
  import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
6
7
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
8
+ import { DbTraffic } from './DbTraffic';
9
+ import { LogCapture } from './LogCapture';
10
+ import { MalformedResetTokens } from './MalformedResetTokens';
7
11
 
8
12
  const testEnv = new UserServerTestEnvironment();
9
13
 
@@ -14,8 +18,15 @@ const testEnv = new UserServerTestEnvironment();
14
18
  * with the stored credential. The email only ever rides a VALID token's response — the
15
19
  * token was delivered to that very inbox, so it reveals nothing the holder doesn't know —
16
20
  * while invalid/expired verdicts stay email-free (no account-probing oracle). A query value
17
- * that is not a well-formed token (a parsed object or array, a string of another shape) is
18
- * invalid without a lookup.
21
+ * that is not a well-formed token — every type a parsed query string can deliver, and every
22
+ * near-miss cut from a live token — is invalid without a lookup: nothing reaches the database
23
+ * (no lookup built, no statement run), which the verdict alone could not prove, since a wrongly
24
+ * admitted value still matches no row. The row stores the token's SHA-256 digest, never the
25
+ * token: the stored digest presented as a token is invalid — it has a token's shape, so the one
26
+ * lookup it builds is by the digest OF the presented value — and so is a token an earlier
27
+ * release stored in clear. No token, no leading characters of one and no full digest reach the
28
+ * log. Live tokens are seeded through `PasswordResetToken.mint`, so the suite moves with what
29
+ * the owner stores.
19
30
  */
20
31
 
21
32
  type RouteOutcome = { status?: number; body?: any };
@@ -35,15 +46,19 @@ const invokeValidate = async (query: Record<string, unknown>): Promise<RouteOutc
35
46
  return outcome;
36
47
  };
37
48
 
38
- const mintToken = () => new PasswordResetToken().mint();
49
+ const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
39
50
 
40
- const armResetToken = async (email: string, token: string, expiration: moment.Moment) => {
51
+ /** A well-formed token that was never issued to anyone. */
52
+ const unissuedToken = () => sha256Hex(`never issued ${Math.random()}`);
53
+
54
+ /** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
55
+ const armResetToken = async (email: string, expiration?: moment.Moment): Promise<string> => {
41
56
  const user = await testEnv.createUser({ name: 'Reset User', email });
42
- await getDbAsSystem().update(tables.User, {
43
- id: user.id,
44
- passwordResetToken: token,
45
- passwordResetTokenExpiration: expiration,
46
- });
57
+ const token = await new PasswordResetToken().mint(user);
58
+ if (expiration !== undefined) {
59
+ await getDbAsSystem().update(tables.User, { id: user.id, passwordResetTokenExpiration: expiration });
60
+ }
61
+ return token;
47
62
  };
48
63
 
49
64
  describe('validateResetPasswordToken route', () => {
@@ -56,8 +71,7 @@ describe('validateResetPasswordToken route', () => {
56
71
  });
57
72
 
58
73
  it('a valid token resolves isValid WITH the account email (the reset form identifier)', async () => {
59
- const token = mintToken();
60
- await armResetToken('reset-valid@test.local', token, moment().add(1, 'hour'));
74
+ const token = await armResetToken('reset-valid@test.local');
61
75
 
62
76
  const outcome = await invokeValidate({ token });
63
77
 
@@ -66,8 +80,7 @@ describe('validateResetPasswordToken route', () => {
66
80
  });
67
81
 
68
82
  it('an expired token resolves invalid and leaks no email', async () => {
69
- const token = mintToken();
70
- await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
83
+ const token = await armResetToken('reset-expired@test.local', moment().subtract(1, 'minute'));
71
84
 
72
85
  const outcome = await invokeValidate({ token });
73
86
 
@@ -77,28 +90,99 @@ describe('validateResetPasswordToken route', () => {
77
90
  });
78
91
 
79
92
  it('an unknown token resolves invalid and leaks no email', async () => {
80
- const outcome = await invokeValidate({ token: mintToken() });
93
+ const outcome = await invokeValidate({ token: unissuedToken() });
81
94
 
82
95
  expect(outcome.status).toBe(200);
83
96
  expect(outcome.body.isValid).toBe(false);
84
97
  expect(outcome.body.email).toBeUndefined();
85
98
  });
86
99
 
87
- it.each([
88
- ['a string of another shape', 'tok-never-issued'],
89
- ['a parsed array', ['a', 'b']],
90
- ['a parsed object', { passwordResetToken: null }],
91
- ])('%s resolves invalid without a lookup and leaks no email', async (_label, token) => {
92
- const outcome = await invokeValidate({ token });
100
+ it('the stored digest presented as a token resolves invalid — the row holds nothing that can be presented', async () => {
101
+ const token = await armResetToken('reset-digest@test.local');
102
+ const row = await getDbAsSystem().get(tables.User, { email: 'reset-digest@test.local' });
103
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
104
+
105
+ const { result: outcome, traffic } = await DbTraffic.during(
106
+ testEnv.spannerDriver,
107
+ async () => await invokeValidate({ token: row.passwordResetToken })
108
+ );
93
109
 
94
110
  expect(outcome.status).toBe(200);
95
111
  expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
112
+ expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
113
+ expect(traffic.statements).toBe(1);
114
+ expect(traffic.writes).toBe(0);
96
115
  });
97
116
 
98
- it('a missing token is a 400', async () => {
99
- const outcome = await invokeValidate({});
117
+ it('a token an earlier release stored in clear resolves invalid — no migration, the person asks again', async () => {
118
+ const clearToken = unissuedToken();
119
+ const user = await testEnv.createUser({ name: 'Reset User', email: 'reset-stored-in-clear@test.local' });
120
+ await getDbAsSystem().update(tables.User, {
121
+ id: user.id,
122
+ passwordResetToken: clearToken,
123
+ passwordResetTokenExpiration: moment().add(1, 'hour'),
124
+ });
100
125
 
101
- expect(outcome.status).toBe(400);
102
- expect(outcome.body.isValid).toBe(false);
126
+ const outcome = await invokeValidate({ token: clearToken });
127
+
128
+ expect(outcome.status).toBe(200);
129
+ expect(outcome.body).toEqual({ isValid: false, message: 'Invalid token' });
130
+ });
131
+
132
+ describe('a query without a well-formed token is invalid without a lookup', () => {
133
+ let liveToken: string;
134
+
135
+ beforeAll(async () => {
136
+ liveToken = await armResetToken('reset-shapes@test.local');
137
+ });
138
+
139
+ it.each(MalformedResetTokens.CASES)(
140
+ '%s: invalid, no email, and nothing reaches the database',
141
+ async (_label, tokenField) => {
142
+ const query = tokenField(liveToken);
143
+
144
+ const { result: outcome, traffic } = await DbTraffic.during(
145
+ testEnv.spannerDriver,
146
+ async () => await invokeValidate(query)
147
+ );
148
+
149
+ // Nothing usable in the query is a 400; anything else that is not a token is an invalid token.
150
+ expect(outcome).toEqual(
151
+ query.token
152
+ ? { status: 200, body: { isValid: false, message: 'Invalid token' } }
153
+ : { status: 400, body: { isValid: false, message: 'No token provided' } }
154
+ );
155
+ expect(traffic).toEqual(DbTraffic.NONE);
156
+ }
157
+ );
158
+
159
+ it('after every refusal the live token is still valid', async () => {
160
+ const outcome = await invokeValidate({ token: liveToken });
161
+
162
+ expect(outcome.body).toEqual({ isValid: true, email: 'reset-shapes@test.local' });
163
+ });
164
+ });
165
+
166
+ it('no token, no leading characters of one and no full digest reach the log — every verdict', async () => {
167
+ const live = await armResetToken('reset-log-live@test.local');
168
+ const expired = await armResetToken('reset-log-expired@test.local', moment().subtract(1, 'minute'));
169
+ const unissued = unissuedToken();
170
+
171
+ const log = await LogCapture.during(async () => {
172
+ await invokeValidate({ token: unissued });
173
+ await invokeValidate({ token: live.toUpperCase() });
174
+ await invokeValidate({ token: sha256Hex(live) });
175
+ await invokeValidate({ token: expired });
176
+ await invokeValidate({ token: live });
177
+ });
178
+
179
+ // The capture saw the route's lines: the refusals above logged.
180
+ expect(log.text).toContain('Invalid reset token used');
181
+ expect(log.text).toContain('Expired reset token used');
182
+ expect(log.lines.length).toBeGreaterThanOrEqual(4);
183
+ // The digest presented as a token is covered by `live`: its full digest is what was presented.
184
+ for (const token of [unissued, live, expired]) {
185
+ log.expectFreeOf(token);
186
+ }
103
187
  });
104
188
  });
@@ -7,11 +7,10 @@
7
7
  * FAILED_PRECONDITION). This lock closes that gap for this package: a lockfile in os.tmpdir()
8
8
  * keyed by the emulator host string, held for the whole jest run via globalSetup/globalTeardown.
9
9
  *
10
- * The lockfile PATH FORMULA must stay byte-identical to the fleet's canonical implementation
11
- * (`EmulatorLock.lockFilePathFor` in @n3xah/thought-common/test) so suites from every repo
12
- * pointed at the same emulator actually contend on the same file. The logic is duplicated here
13
- * because this repo sits BELOW @n3xah in the dependency layering (thought-common itself depends
14
- * on @proteinjs/user) — future extraction target: @proteinjs/db-driver-spanner/test, beside
10
+ * The lockfile PATH FORMULA must stay byte-identical in every copy of this lock (other
11
+ * repositories' suites, a consumer application's suites) so runs pointed at the same emulator
12
+ * actually contend on the same file. The logic is duplicated here because no shared package
13
+ * carries it yet — future extraction target: @proteinjs/db-driver-spanner/test, beside
15
14
  * SpannerEmulatorProvisioner.
16
15
  */
17
16
  const crypto = require('crypto');
@@ -30,7 +29,7 @@ function lockFilePathFor(host) {
30
29
  .replace(/^-+|-+$/g, '')
31
30
  .slice(0, 64);
32
31
  const hash = crypto.createHash('sha1').update(host).digest('hex').slice(0, 8);
33
- return path.join(os.tmpdir(), `n3xa-spanner-emulator-${slug}-${hash}.lock`);
32
+ return path.join(os.tmpdir(), `spanner-emulator-${slug}-${hash}.lock`);
34
33
  }
35
34
 
36
35
  function isPidAlive(pid) {
@@ -0,0 +1,32 @@
1
+ import crypto from 'crypto';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
6
+ const { lockFilePathFor } = require('./emulatorLock') as { lockFilePathFor: (host: string) => string };
7
+
8
+ /**
9
+ * The lockfile path is a CONTRACT between every copy of the emulator lock: two jest runs only
10
+ * serialize when they compute the same file for the same emulator host. The formula is pinned
11
+ * here byte for byte — a neutral `spanner-emulator-` name (no application's name belongs in a
12
+ * shared lock), the sanitized host slug, and the first 8 hex characters of the host's sha1.
13
+ */
14
+ describe('emulatorLock.lockFilePathFor', () => {
15
+ const expectedFor = (host: string, slug: string) =>
16
+ path.join(
17
+ os.tmpdir(),
18
+ `spanner-emulator-${slug}-${crypto.createHash('sha1').update(host).digest('hex').slice(0, 8)}.lock`
19
+ );
20
+
21
+ it('names the lock by the emulator alone: spanner-emulator-<slug>-<sha1[0..8]>.lock in os.tmpdir()', () => {
22
+ expect(lockFilePathFor('localhost:9010')).toBe(expectedFor('localhost:9010', 'localhost-9010'));
23
+ expect(lockFilePathFor('127.0.0.1:9710')).toBe(expectedFor('127.0.0.1:9710', '127-0-0-1-9710'));
24
+ });
25
+
26
+ it('gives two emulators two files, and one emulator the same file every time', () => {
27
+ expect(lockFilePathFor('localhost:9010')).toBe(lockFilePathFor('localhost:9010'));
28
+ expect(lockFilePathFor('localhost:9010')).not.toBe(lockFilePathFor('localhost:9011'));
29
+ // The hash keeps hosts apart even when their slugs collide.
30
+ expect(lockFilePathFor('localhost:9010')).not.toBe(lockFilePathFor('localhost-9010'));
31
+ });
32
+ });
@@ -0,0 +1,33 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
5
+ const { lockFilePathFor } = require('./emulatorLock') as { lockFilePathFor: (host: string) => string };
6
+
7
+ /**
8
+ * The emulator lock's FILE NAME is a contract between every copy of the lock: two jest runs
9
+ * serialize on a shared emulator only when they compute the same file for the same host. No
10
+ * shared package carries the lock yet, so the contract is pinned by value — this table is the
11
+ * same, row for row, in every repository that keeps a copy. A change to a row here is a change
12
+ * to all of them, made in the same window; a copy that drifts stops contending, silently.
13
+ */
14
+ // [emulator host, lock file name, what the row pins]
15
+ const LOCK_FILE_NAMES: [string, string, string][] = [
16
+ ['localhost:9010', 'spanner-emulator-localhost-9010-0091e79a.lock', 'the default shared emulator'],
17
+ [
18
+ 'localhost-9010',
19
+ 'spanner-emulator-localhost-9010-493b66d1.lock',
20
+ 'the same slug as the row above: the hash alone keeps two hosts apart',
21
+ ],
22
+ [
23
+ '[emulator.a-host-name-long-enough-to-run-past-the-sixty-four-cap.example.test]:9010',
24
+ 'spanner-emulator-emulator-a-host-name-long-enough-to-run-past-the-sixty-four-cap--fe958a1a.lock',
25
+ 'edge dashes trimmed first, the slug capped at 64 second (the cap lands on a dash, and it stays)',
26
+ ],
27
+ ];
28
+
29
+ describe('the emulator lock file name (the contract every copy of the lock shares)', () => {
30
+ it.each(LOCK_FILE_NAMES)('%s locks on %s (%s)', (host, fileName) => {
31
+ expect(lockFilePathFor(host)).toBe(path.join(os.tmpdir(), fileName));
32
+ });
33
+ });
package/test/setup.js CHANGED
@@ -1,2 +1,4 @@
1
1
  process.env.SPANNER_EMULATOR_HOST = process.env.SPANNER_EMULATOR_HOST || 'localhost:9010';
2
2
  process.env.DB_LOG_LEVEL = 'error';
3
+ // The key the throttled doors' digests (RequestDigests) derive from; a process without one refuses to digest.
4
+ process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'user-server-test-secret';