@proteinjs/user-server 1.22.2 → 1.22.4

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 (88) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
  5. package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
  6. package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
  7. package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
  8. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
  9. package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
  10. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
  11. package/dist/src/authentication/authenticate.js +2 -2
  12. package/dist/src/authentication/authenticate.js.map +1 -1
  13. package/dist/src/authorization/userCache.d.ts.map +1 -1
  14. package/dist/src/authorization/userCache.js +3 -2
  15. package/dist/src/authorization/userCache.js.map +1 -1
  16. package/dist/src/routes/devLogin.d.ts.map +1 -1
  17. package/dist/src/routes/devLogin.js +29 -14
  18. package/dist/src/routes/devLogin.js.map +1 -1
  19. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  20. package/dist/src/routes/executePasswordReset.js +7 -6
  21. package/dist/src/routes/executePasswordReset.js.map +1 -1
  22. package/dist/src/routes/initiatePasswordReset.js +2 -2
  23. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  24. package/dist/src/routes/signup.d.ts.map +1 -1
  25. package/dist/src/routes/signup.js +5 -2
  26. package/dist/src/routes/signup.js.map +1 -1
  27. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  28. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  29. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  30. package/dist/src/services/MachineCredentials.d.ts +10 -1
  31. package/dist/src/services/MachineCredentials.d.ts.map +1 -1
  32. package/dist/src/services/MachineCredentials.js +46 -15
  33. package/dist/src/services/MachineCredentials.js.map +1 -1
  34. package/dist/src/services/Roles.d.ts.map +1 -1
  35. package/dist/src/services/Roles.js +6 -2
  36. package/dist/src/services/Roles.js.map +1 -1
  37. package/dist/src/services/Signup.d.ts +15 -0
  38. package/dist/src/services/Signup.d.ts.map +1 -1
  39. package/dist/src/services/Signup.js +79 -27
  40. package/dist/src/services/Signup.js.map +1 -1
  41. package/dist/test/DevLogin.test.js +68 -0
  42. package/dist/test/DevLogin.test.js.map +1 -1
  43. package/dist/test/LogCapture.d.ts +10 -0
  44. package/dist/test/LogCapture.d.ts.map +1 -1
  45. package/dist/test/LogCapture.js +19 -0
  46. package/dist/test/LogCapture.js.map +1 -1
  47. package/dist/test/LogCapture.test.d.ts +2 -0
  48. package/dist/test/LogCapture.test.d.ts.map +1 -0
  49. package/dist/test/LogCapture.test.js +86 -0
  50. package/dist/test/LogCapture.test.js.map +1 -0
  51. package/dist/test/MachineAccounts.integration.test.js +156 -28
  52. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  53. package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
  54. package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
  55. package/dist/test/NoAddressesInLogs.integration.test.js +719 -0
  56. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  57. package/dist/test/SignupRace.integration.test.d.ts +2 -0
  58. package/dist/test/SignupRace.integration.test.d.ts.map +1 -0
  59. package/dist/test/SignupRace.integration.test.js +320 -0
  60. package/dist/test/SignupRace.integration.test.js.map +1 -0
  61. package/dist/test/SignupRoute.test.js +80 -1
  62. package/dist/test/SignupRoute.test.js.map +1 -1
  63. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  64. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  65. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  66. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  67. package/generated/index.ts +1 -1
  68. package/package.json +6 -5
  69. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  70. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  71. package/src/authentication/authenticate.ts +1 -1
  72. package/src/authorization/userCache.ts +3 -2
  73. package/src/routes/devLogin.ts +22 -10
  74. package/src/routes/executePasswordReset.ts +6 -5
  75. package/src/routes/initiatePasswordReset.ts +1 -1
  76. package/src/routes/signup.ts +5 -2
  77. package/src/routes/validateResetPasswordToken.ts +5 -1
  78. package/src/services/MachineCredentials.ts +44 -9
  79. package/src/services/Roles.ts +6 -2
  80. package/src/services/Signup.ts +60 -15
  81. package/test/DevLogin.test.ts +35 -1
  82. package/test/LogCapture.test.ts +29 -0
  83. package/test/LogCapture.ts +16 -0
  84. package/test/MachineAccounts.integration.test.ts +97 -22
  85. package/test/NoAddressesInLogs.integration.test.ts +410 -0
  86. package/test/SignupRace.integration.test.ts +230 -0
  87. package/test/SignupRoute.test.ts +54 -1
  88. package/test/SocketIOSessionWatcher.test.ts +86 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/user-server",
3
- "version": "1.22.2",
3
+ "version": "1.22.4",
4
4
  "description": "User server components",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,15 +22,16 @@
22
22
  "test": "jest --runInBand --passWithNoTests"
23
23
  },
24
24
  "dependencies": {
25
- "@proteinjs/db": "^1.47.0",
25
+ "@proteinjs/db": "^1.47.3",
26
26
  "@proteinjs/db-file": "^1.5.3",
27
27
  "@proteinjs/email-server": "^1.4.0",
28
28
  "@proteinjs/logger": "^1.0.21",
29
29
  "@proteinjs/reflection": "^1.2.0",
30
- "@proteinjs/server": "^3.5.1",
30
+ "@proteinjs/server": "^3.8.5",
31
31
  "@proteinjs/server-api": "^3.0.11",
32
- "@proteinjs/user": "^1.20.1",
32
+ "@proteinjs/user": "^1.20.2",
33
33
  "@proteinjs/util": "^1.6.1",
34
+ "@proteinjs/util-node": "^1.11.1",
34
35
  "argon2": "^0.45.1",
35
36
  "crypto-js": "4.0.0",
36
37
  "express-session": "1.17.1",
@@ -59,5 +60,5 @@
59
60
  },
60
61
  "main": "./dist/generated/index.js",
61
62
  "types": "./dist/generated/index.d.ts",
62
- "gitHead": "ae97c22e42cbb471bddf3f0ddc08f93fad4be375"
63
+ "gitHead": "a2e6c0a0688d7681fb398354a07c76a9bb08644e"
63
64
  }
@@ -5,6 +5,11 @@ import { SocketIOServerRepo } from '@proteinjs/server';
5
5
  /**
6
6
  * Handles Socket.IO session cleanup (such as disconnecting sockets)
7
7
  * when sessions are deleted.
8
+ *
9
+ * A deletion is not always made by a running server: a boot's source-record sync — and a
10
+ * migration Job, which never starts one — deactivates a withdrawn machine account and deletes its
11
+ * sessions before any socket server exists. Then no socket can be open, and there is nothing to
12
+ * close.
8
13
  */
9
14
  export class SocketIOSessionWatcher implements TableWatcher<Session> {
10
15
  name(): string {
@@ -21,7 +26,14 @@ export class SocketIOSessionWatcher implements TableWatcher<Session> {
21
26
  initialQb: QueryBuilder<T>,
22
27
  deleteQb: QueryBuilder<T>
23
28
  ): Promise<void> {
29
+ const socketIOServer = SocketIOServerRepo.getSocketIOServerIfExists();
24
30
  const deletedSessionIds = deletedRecords.map((deletedRecord) => deletedRecord.sessionId);
25
- SocketIOServerRepo.getSocketIOServer().in(deletedSessionIds).disconnectSockets(true);
31
+ // No rooms is never sent: socket.io applies an operation with an empty room list to EVERY
32
+ // socket on the namespace.
33
+ if (!socketIOServer || deletedSessionIds.length === 0) {
34
+ return;
35
+ }
36
+
37
+ socketIOServer.in(deletedSessionIds).disconnectSockets(true);
26
38
  }
27
39
  }
@@ -1,6 +1,7 @@
1
1
  import { QueryBuilder, Table, TableWatcher, getDbAsSystem } from '@proteinjs/db';
2
2
  import { User, tables } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
+ import { RequestDigests } from '@proteinjs/util-node';
4
5
 
5
6
  /**
6
7
  * The one owner of "deactivated ⇒ sessions die": any user-table update writing
@@ -48,7 +49,7 @@ export class UserStatusTableWatcher implements TableWatcher<User> {
48
49
  if (deletedSessions > 0) {
49
50
  this.logger.info({
50
51
  message: `Deactivation killed sessions`,
51
- obj: { email: user.email, sessions: deletedSessions },
52
+ obj: { account: new RequestDigests().account(user.email), sessions: deletedSessions },
52
53
  });
53
54
  }
54
55
  });
@@ -1,7 +1,7 @@
1
1
  import { getDbAsSystem } from '@proteinjs/db';
2
2
  import { User, tables } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
- import { RequestDigests } from '../throttle/RequestDigests';
4
+ import { RequestDigests } from '@proteinjs/util-node';
5
5
  import { DefaultAdminCredentials } from './DefaultAdminCredentials';
6
6
  import { PasswordHasher } from './PasswordHasher';
7
7
 
@@ -2,6 +2,7 @@ import moment from 'moment';
2
2
  import { SessionDataCache } from '@proteinjs/server-api';
3
3
  import { getDbAsSystem } from '@proteinjs/db';
4
4
  import { Logger } from '@proteinjs/logger';
5
+ import { RequestDigests } from '@proteinjs/util-node';
5
6
  import { User, tables, guestUser, USER_SESSION_CACHE_KEY } from '@proteinjs/user';
6
7
  import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
7
8
 
@@ -42,7 +43,7 @@ export const userCache: SessionDataCache<User> = {
42
43
  // account is deactivated — every request runs as the unauthenticated guest.
43
44
  logger.warn({
44
45
  message: `Session references a deactivated account; resolving as unauthenticated`,
45
- obj: { sessionId, userEmail },
46
+ obj: { sessionId, account: new RequestDigests().account(userEmail) },
46
47
  });
47
48
  } else if (accountUser) {
48
49
  delete (accountUser as any)['password'];
@@ -54,7 +55,7 @@ export const userCache: SessionDataCache<User> = {
54
55
  // build as an unhandled rejection and downs the process.
55
56
  logger.warn({
56
57
  message: `Session references an account that does not exist; resolving as unauthenticated`,
57
- obj: { sessionId, userEmail },
58
+ obj: { sessionId, account: new RequestDigests().account(userEmail) },
58
59
  });
59
60
  }
60
61
  }
@@ -1,5 +1,6 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { Logger } from '@proteinjs/logger';
3
+ import { RequestDigests } from '@proteinjs/util-node';
3
4
  import { emailRegex } from '@proteinjs/util';
4
5
  import { establishSession } from '../authentication/establishSession';
5
6
  import { Roles } from '../services/Roles';
@@ -88,22 +89,33 @@ export const devLogin: Route = {
88
89
  email = requested;
89
90
  }
90
91
  email = email.toLowerCase();
92
+ const digests = new RequestDigests();
91
93
 
92
- const creation = await new Signup().createAccount({
93
- name: email.slice(0, email.indexOf('@')),
94
- email,
95
- password: 'test',
96
- emailVerified: false, // same shape an inviteless signup produces
97
- invitedBy: null,
98
- });
94
+ let creation: 'created' | 'exists';
95
+ try {
96
+ creation = await new Signup().createAccount({
97
+ name: email.slice(0, email.indexOf('@')),
98
+ email,
99
+ password: 'test',
100
+ emailVerified: false, // same shape an inviteless signup produces
101
+ invitedBy: null,
102
+ });
103
+ } catch (error) {
104
+ // createAccount refuses in plain words (an address a machine-account declaration owns); the
105
+ // dev door answers the way its other refusals do — the words, no session, no row.
106
+ response
107
+ .status(400)
108
+ .send(`/dev/login: ${error instanceof Error ? error.message : 'the account could not be created'}`);
109
+ return;
110
+ }
99
111
  if (creation === 'created') {
100
- logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
112
+ logger.info({ message: 'Dev auto-login created missing test account', obj: { account: digests.account(email) } });
101
113
  }
102
114
 
103
115
  const bootstrapEmail = (process.env.DEV_BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase();
104
116
  if (bootstrapEmail && email === bootstrapEmail) {
105
117
  const outcome = await new Roles().bootstrapAdmin(email);
106
- logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { email } });
118
+ logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { account: digests.account(email) } });
107
119
  }
108
120
 
109
121
  const bootstrapRoles = DevBootstrapRoles.rolesFor(email);
@@ -115,7 +127,7 @@ export const devLogin: Route = {
115
127
  // establishSession commits the session row before the redirect — the redirected GET / must
116
128
  // never read the store ahead of the write (observed: first /dev/login load landed on /login).
117
129
  await establishSession(request, email);
118
- logger.info({ message: 'Dev auto-login session established', obj: { email } });
130
+ logger.info({ message: 'Dev auto-login session established', obj: { account: digests.account(email) } });
119
131
  response.redirect('/');
120
132
  },
121
133
  };
@@ -1,9 +1,9 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
+ import { RequestDigests } from '@proteinjs/util-node';
4
5
  import { PasswordHasher } from '../authentication/PasswordHasher';
5
6
  import { PasswordResetToken } from '../authentication/PasswordResetToken';
6
- import { RequestDigests } from '../throttle/RequestDigests';
7
7
  import { signInThrottle } from '../throttle/SignInThrottle';
8
8
 
9
9
  /**
@@ -27,6 +27,7 @@ export const executePasswordReset: Route = {
27
27
  method: routes.executePasswordReset.method,
28
28
  onRequest: async (request, response): Promise<void> => {
29
29
  const logger = new Logger({ name: 'executePasswordReset' });
30
+ const digests = new RequestDigests();
30
31
  const { token, newPassword } = request.body ?? {};
31
32
  if (typeof newPassword !== 'string' || newPassword.length === 0) {
32
33
  response.status(400).send({ error: 'New password cannot be blank' });
@@ -45,7 +46,7 @@ export const executePasswordReset: Route = {
45
46
  }
46
47
 
47
48
  if (resolution.status === 'expired') {
48
- logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
49
+ logger.info({ message: `Expired reset token used`, obj: { account: digests.account(resolution.user.email) } });
49
50
  response.status(400).send({ error: 'Reset token has expired' });
50
51
  return;
51
52
  }
@@ -53,13 +54,13 @@ export const executePasswordReset: Route = {
53
54
  const { user } = resolution;
54
55
  const hashedPassword = await new PasswordHasher().hash(newPassword);
55
56
  if (!(await resetToken.redeem(user, resolution.token, hashedPassword))) {
56
- logger.info({ message: `Reset token already redeemed`, obj: { email: user.email } });
57
+ logger.info({ message: `Reset token already redeemed`, obj: { account: digests.account(user.email) } });
57
58
  response.status(400).send({ error: 'Invalid or expired reset token' });
58
59
  return;
59
60
  }
60
61
 
61
- signInThrottle.recordSuccess(new RequestDigests().account(user.email));
62
- logger.info({ message: `Password successfully reset`, obj: { email: user.email } });
62
+ signInThrottle.recordSuccess(digests.account(user.email));
63
+ logger.info({ message: `Password successfully reset`, obj: { account: digests.account(user.email) } });
63
64
  response.send({ message: 'Password has been successfully reset' });
64
65
  },
65
66
  };
@@ -2,6 +2,7 @@ import { Route } from '@proteinjs/server-api';
2
2
  import { getDbAsSystem } from '@proteinjs/db';
3
3
  import { routes, tables, uiRoutes } from '@proteinjs/user';
4
4
  import { Logger } from '@proteinjs/logger';
5
+ import { RequestDigests } from '@proteinjs/util-node';
5
6
  import moment from 'moment';
6
7
  import {
7
8
  EmailSender,
@@ -9,7 +10,6 @@ import {
9
10
  } from '@proteinjs/email-server';
10
11
  import { PasswordResetToken } from '../authentication/PasswordResetToken';
11
12
  import { ClientAddress } from '../throttle/ClientAddress';
12
- import { RequestDigests } from '../throttle/RequestDigests';
13
13
  import { passwordResetThrottle } from '../throttle/PasswordResetThrottle';
14
14
 
15
15
  /**
@@ -1,5 +1,6 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { Logger } from '@proteinjs/logger';
3
+ import { RequestDigests } from '@proteinjs/util-node';
3
4
  import { UserSignup, routes } from '@proteinjs/user';
4
5
  import { establishSession } from '../authentication/establishSession';
5
6
  import { Signup } from '../services/Signup';
@@ -40,8 +41,10 @@ export const signup: Route = {
40
41
  );
41
42
  } catch (error: any) {
42
43
  // createUser throws plain-words errors deliberately (invite expired / invite required /
43
- // email missing); the message is the user-facing contract, same as the RPC layer's.
44
- logger.error({ message: 'Signup failed', error });
44
+ // email missing); the message is the user-facing contract, same as the RPC layer's. The log
45
+ // gets the error through the digest door: a concurrent double sign-up's unique-index refusal
46
+ // names the address in the database's own words.
47
+ logger.error({ message: 'Signup failed', error: new RequestDigests().redactError(error) });
45
48
  response.send({ error: error instanceof Error ? error.message : 'Sign up failed.' });
46
49
  return;
47
50
  }
@@ -1,6 +1,7 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
+ import { RequestDigests } from '@proteinjs/util-node';
4
5
  import { PasswordResetToken } from '../authentication/PasswordResetToken';
5
6
 
6
7
  export const validateResetPasswordToken: Route = {
@@ -26,7 +27,10 @@ export const validateResetPasswordToken: Route = {
26
27
  }
27
28
 
28
29
  if (resolution.status === 'expired') {
29
- logger.info({ message: `Expired reset token used`, obj: { email: resolution.user.email } });
30
+ logger.info({
31
+ message: `Expired reset token used`,
32
+ obj: { account: new RequestDigests().account(resolution.user.email) },
33
+ });
30
34
  response.status(200).send({ isValid: false, message: 'Token has expired' });
31
35
  return;
32
36
  }
@@ -1,14 +1,17 @@
1
1
  import { randomBytes } from 'crypto';
2
2
  import { getDbAsSystem } from '@proteinjs/db';
3
3
  import {
4
+ MachineAccountRefusal,
4
5
  MachineAccountView,
5
6
  MachineCredentialsService,
6
7
  MintedMachineCredential,
7
8
  USER_PERMISSIONS,
9
+ User,
8
10
  getMachineAccounts,
9
11
  tables,
10
12
  } from '@proteinjs/user';
11
13
  import { Logger } from '@proteinjs/logger';
14
+ import { RequestDigests } from '@proteinjs/util-node';
12
15
  import { Service } from '@proteinjs/service';
13
16
  import { PasswordHasher } from '../authentication/PasswordHasher';
14
17
 
@@ -23,7 +26,9 @@ import { PasswordHasher } from '../authentication/PasswordHasher';
23
26
  * account's existing sessions (the old
24
27
  * credential dies with them), and returns the plaintext ONCE for pasting into the declaration's
25
28
  * Secret Manager secret. The same call rotates. Machine rows only (`is_loaded_from_source`);
26
- * human credentials go through the password-reset flow.
29
+ * human credentials go through the password-reset flow. A declared address held by a row the
30
+ * boot sync does not own (a person's signup, a hand-made row) has no machine account at all —
31
+ * the sync refused the declaration — and both the list and the mint say so.
27
32
  */
28
33
  export class MachineCredentials implements MachineCredentialsService {
29
34
  public serviceMetadata: Service['serviceMetadata'] = {
@@ -36,15 +41,22 @@ export class MachineCredentials implements MachineCredentialsService {
36
41
  const db = getDbAsSystem();
37
42
  const views: MachineAccountView[] = [];
38
43
  for (const declaration of getMachineAccounts()) {
39
- const row = await db.get(tables.User, { email: declaration.email });
40
- const booted = !!row && row.isLoadedFromSource === true;
44
+ const row: User | undefined = await db.get(tables.User, { email: declaration.email });
45
+ const refusal = row && this.refusal(row);
41
46
  views.push({
42
47
  email: declaration.email,
43
48
  accountName: declaration.accountName,
44
49
  roles: [...declaration.roles],
45
50
  secretName: declaration.secretName,
46
- status: booted ? (row.status === 'deactivated' ? 'deactivated' : 'active') : 'pending first boot',
47
- hasCredential: booted && !!row.password,
51
+ status: !row
52
+ ? 'pending first boot'
53
+ : refusal
54
+ ? 'declaration refused'
55
+ : row.status === 'deactivated'
56
+ ? 'deactivated'
57
+ : 'active',
58
+ ...(refusal ? { refusal } : {}),
59
+ hasCredential: !!row && !refusal && !!row.password,
48
60
  });
49
61
  }
50
62
 
@@ -63,11 +75,20 @@ export class MachineCredentials implements MachineCredentialsService {
63
75
  }
64
76
 
65
77
  const db = getDbAsSystem();
66
- const user = await db.get(tables.User, { email: normalizedEmail });
67
- if (!user || user.isLoadedFromSource !== true) {
78
+ const user: User | undefined = await db.get(tables.User, { email: normalizedEmail });
79
+ if (!user) {
68
80
  throw new Error(
69
81
  `The machine account row for '${normalizedEmail}' has not been loaded from source yet — ` +
70
- `the boot sync creates (or adopts) it. Boot the server with the declaration, then mint.`
82
+ `the boot sync creates it. Boot the server with the declaration, then mint.`
83
+ );
84
+ }
85
+
86
+ const refusal = this.refusal(user);
87
+ if (refusal) {
88
+ throw new Error(
89
+ `The declaration for '${normalizedEmail}' was refused at boot: ${refusal} — the boot sync ` +
90
+ `never takes over a row it does not own, so there is no machine account to mint for. The ` +
91
+ `first boot after that row is gone creates it.`
71
92
  );
72
93
  }
73
94
 
@@ -79,7 +100,7 @@ export class MachineCredentials implements MachineCredentialsService {
79
100
  await db.delete(tables.Session, { userEmail: normalizedEmail });
80
101
  logger.info({
81
102
  message: `Minted machine credential`,
82
- obj: { email: normalizedEmail, secretName: declaration.secretName },
103
+ obj: { account: new RequestDigests().account(normalizedEmail), secretName: declaration.secretName },
83
104
  });
84
105
 
85
106
  return {
@@ -91,4 +112,18 @@ export class MachineCredentials implements MachineCredentialsService {
91
112
  `secret, then restart the service that reads it — the previous credential is already invalid.`,
92
113
  };
93
114
  }
115
+
116
+ /**
117
+ * Why the boot sync refused a declaration whose address this row holds — undefined for the
118
+ * account's own row. The sync owns a row only when it created it (`isLoadedFromSource`); any
119
+ * other row under a declared address is never taken over (@proteinjs/db's natural-key refusal),
120
+ * and the one machine marker says whose row it is.
121
+ */
122
+ private refusal(row: User): MachineAccountRefusal | undefined {
123
+ if (row.isLoadedFromSource === true) {
124
+ return undefined;
125
+ }
126
+
127
+ return row.machine === true ? 'a hand-made machine row holds this address' : 'a person holds this address';
128
+ }
94
129
  }
@@ -1,6 +1,7 @@
1
1
  import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
2
2
  import { RolesService, RolesCatalog, UserAuth, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
3
3
  import { Logger } from '@proteinjs/logger';
4
+ import { RequestDigests } from '@proteinjs/util-node';
4
5
  import { Service } from '@proteinjs/service';
5
6
 
6
7
  /**
@@ -67,7 +68,10 @@ export class Roles implements RolesService {
67
68
  await db.update(tables.User, { id: user.id, roles: [...roles, 'admin'] });
68
69
  await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role: 'admin', action: 'grant' });
69
70
  });
70
- logger.info({ message: 'Break-glass admin granted by the dev first-admin door', obj: { target: user.id, email } });
71
+ logger.info({
72
+ message: 'Break-glass admin granted by the dev first-admin door',
73
+ obj: { target: user.id, account: new RequestDigests().account(email) },
74
+ });
71
75
  return 'granted';
72
76
  }
73
77
 
@@ -122,7 +126,7 @@ export class Roles implements RolesService {
122
126
  });
123
127
  logger.info({
124
128
  message: 'Roles granted by the dev role-bootstrap door',
125
- obj: { target: user.id, email, granted: outcome.granted },
129
+ obj: { target: user.id, account: new RequestDigests().account(email), granted: outcome.granted },
126
130
  });
127
131
  return outcome;
128
132
  }
@@ -11,10 +11,12 @@ import {
11
11
  User,
12
12
  UserSignup,
13
13
  USER_PERMISSIONS,
14
+ getMachineAccounts,
14
15
  } from '@proteinjs/user';
15
16
  import moment from 'moment';
16
17
  import { lib } from 'crypto-js';
17
18
  import { Logger } from '@proteinjs/logger';
19
+ import { RequestDigests } from '@proteinjs/util-node';
18
20
  import {
19
21
  EmailSender,
20
22
  getDefaultInviteEmailConfigFactory,
@@ -124,7 +126,10 @@ export class Signup implements SignupService {
124
126
  invitedBy: invite ? invite.invitedBy : null,
125
127
  });
126
128
  if (creation === 'exists') {
127
- logger.error({ message: `User with this email already exists`, obj: { email } });
129
+ logger.error({
130
+ message: `User with this email already exists`,
131
+ obj: { account: new RequestDigests().account(email) },
132
+ });
128
133
  if (config.getExistingUserEmailContent) {
129
134
  const { text, html } = config.getExistingUserEmailContent();
130
135
  await emailSender.sendEmail({
@@ -146,7 +151,7 @@ export class Signup implements SignupService {
146
151
  html,
147
152
  ...config.options,
148
153
  });
149
- logger.info({ message: `Created user`, obj: { email } });
154
+ logger.info({ message: `Created user`, obj: { account: new RequestDigests().account(email) } });
150
155
  return { outcome: 'created', email };
151
156
  }
152
157
 
@@ -183,7 +188,14 @@ export class Signup implements SignupService {
183
188
  await this.emailInvite(caseInsensitiveEmail, token, config);
184
189
  return { sent: true };
185
190
  } catch (error: any) {
186
- logger.error({ message: 'Error sending invite', obj: { email: caseInsensitiveEmail }, error });
191
+ // The try block covers the Invite row's write and the mail: a refusal's own words may name
192
+ // the address, so the error reaches the log through the digest door too.
193
+ const digests = new RequestDigests();
194
+ logger.error({
195
+ message: 'Error sending invite',
196
+ obj: { invitee: digests.address(caseInsensitiveEmail) },
197
+ error: digests.redactError(error),
198
+ });
187
199
  return {
188
200
  sent: false,
189
201
  error: 'Error occurred.',
@@ -218,7 +230,12 @@ export class Signup implements SignupService {
218
230
  await this.emailInvite(caseInsensitiveEmail, token, config);
219
231
  return { sent: true };
220
232
  } catch (error: any) {
221
- logger.error({ message: 'Error re-sending invite', obj: { email: caseInsensitiveEmail }, error });
233
+ const digests = new RequestDigests();
234
+ logger.error({
235
+ message: 'Error re-sending invite',
236
+ obj: { invitee: digests.address(caseInsensitiveEmail) },
237
+ error: digests.redactError(error),
238
+ });
222
239
  return {
223
240
  sent: false,
224
241
  error: 'Error occurred.',
@@ -300,6 +317,19 @@ export class Signup implements SignupService {
300
317
  * (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
301
318
  * the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
302
319
  * methods), so extra class methods stay server-internal.
320
+ *
321
+ * An address a `MachineAccount` declaration owns is never registered, whatever its row state:
322
+ * a person's row under it would make the boot sync refuse the declaration (it never takes over
323
+ * a row it does not own), so the address is refused here in plain words — the declared machine
324
+ * accounts of this build are the list, nothing else.
325
+ *
326
+ * 'exists' means the address has an account this call did not create — found by the check, or
327
+ * written by a concurrent create between the check and this insert. Two creates for one address
328
+ * both pass the check; the database's unique index on the address refuses the second insert, and
329
+ * that refusal is the same fact the check reads, so the second answers 'exists' exactly as if it
330
+ * had arrived a moment later: its caller gets the existing-address response, no session, and the
331
+ * owner's mail — never the database's sentence, which names the address. An insert that fails
332
+ * while the address still has no account is a real failure and throws.
303
333
  */
304
334
  async createAccount(account: {
305
335
  name: string;
@@ -308,24 +338,39 @@ export class Signup implements SignupService {
308
338
  emailVerified: boolean;
309
339
  invitedBy: User['invitedBy'];
310
340
  }): Promise<'created' | 'exists'> {
311
- const db = getDbAsSystem();
312
341
  const email = account.email.toLowerCase();
313
- const existingUser = await db.get(tables.User, { email });
314
- if (existingUser) {
342
+ if (getMachineAccounts().some((machineAccount) => machineAccount.email === email)) {
343
+ throw new Error(`This address can't be registered.`);
344
+ }
345
+
346
+ if (await this.hasAccount(email)) {
315
347
  return 'exists';
316
348
  }
317
349
 
318
- await db.insert(tables.User, {
319
- name: account.name,
320
- email,
321
- password: await new PasswordHasher().hash(account.password),
322
- emailVerified: account.emailVerified,
323
- roles: [],
324
- invitedBy: account.invitedBy,
325
- });
350
+ const password = await new PasswordHasher().hash(account.password);
351
+ try {
352
+ await getDbAsSystem().insert(tables.User, {
353
+ name: account.name,
354
+ email,
355
+ password,
356
+ emailVerified: account.emailVerified,
357
+ roles: [],
358
+ invitedBy: account.invitedBy,
359
+ });
360
+ } catch (error: unknown) {
361
+ if (await this.hasAccount(email)) {
362
+ return 'exists';
363
+ }
364
+ throw error;
365
+ }
326
366
  return 'created';
327
367
  }
328
368
 
369
+ /** Whether an account holds `email` (already lowercased) — the one existence read `createAccount` makes. */
370
+ private async hasAccount(email: string): Promise<boolean> {
371
+ return Boolean(await getDbAsSystem().get(tables.User, { email }));
372
+ }
373
+
329
374
  /** Resolves an invite token, distinguishing "expired" from "never existed / already revoked". */
330
375
  private async lookupInvite(token: string): Promise<InviteLookup> {
331
376
  const db = getDbAsSystem();
@@ -1,5 +1,6 @@
1
1
  import { getDbAsSystem } from '@proteinjs/db';
2
- import { tables } from '@proteinjs/user';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { MachineAccount, tables } from '@proteinjs/user';
3
4
  import { PasswordHasher } from '../src/authentication/PasswordHasher';
4
5
  import { invokeDevLogin } from './devLoginHarness';
5
6
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
@@ -107,6 +108,39 @@ describe('devLogin route', () => {
107
108
  expect(await getUserRow('intruder@evil.example')).toBeUndefined();
108
109
  });
109
110
 
111
+ it('refuses a ?email a machine-account declaration owns with 400 in plain words — no session, no row', async () => {
112
+ // The declared machine accounts of the build are the list signup refuses from; the dev door
113
+ // creates missing accounts through the same function and must answer the way its other
114
+ // refusals do — in words, not as an unhandled throw.
115
+ class DeclaredMachineAccount extends MachineAccount {
116
+ id = 'declared-machine';
117
+ email = 'declared.machine@test.local';
118
+ accountName = 'Declared machine';
119
+ roles = [];
120
+ secretName = 'declared-machine-secret';
121
+ }
122
+ const repo = SourceRepository.get() as unknown as { namedObjectCache: Record<string, unknown[]> };
123
+ repo.namedObjectCache['@proteinjs/db/SourceRecordLoader'] = [
124
+ {
125
+ qualifiedName: '@proteinjs/user-server-test/DeclaredMachineAccount',
126
+ packageName: '@test/declarer',
127
+ object: new DeclaredMachineAccount(),
128
+ },
129
+ ];
130
+ try {
131
+ const outcome = await invokeDevLogin({ email: 'declared.machine@test.local' });
132
+
133
+ expect(outcome.status).toBe(400);
134
+ expect(String(outcome.body)).toContain(`This address can't be registered.`);
135
+ expect(outcome.loggedInAs).toBeUndefined();
136
+ expect(outcome.sessionSaved).toBe(false);
137
+ expect(outcome.redirect).toBeUndefined();
138
+ expect(await getUserRow('declared.machine@test.local')).toBeUndefined();
139
+ } finally {
140
+ delete repo.namedObjectCache['@proteinjs/db/SourceRecordLoader'];
141
+ }
142
+ });
143
+
110
144
  it('rejects a malformed same-domain ?email with 400 — no session, no stray account', async () => {
111
145
  // The observed shape: an unencoded `+` in the query decodes to a space, so
112
146
  // `?email=brent+shareproof-a@...` arrived as `brent shareproof-a@...` and minted a stray
@@ -0,0 +1,29 @@
1
+ import { LogCapture } from './LogCapture';
2
+
3
+ /**
4
+ * The log capture's address reader — what `expect(log.addresses).toEqual([])` stands on. It reads
5
+ * an address in every spelling a log line can carry: bare, inside a name-and-address form, and
6
+ * URL-encoded (`%40` for the `@`, `%2B` for a `+`) as a logged request path carries it. A digest
7
+ * or a bare domain is not an address.
8
+ */
9
+ describe('LogCapture.addresses', () => {
10
+ it('reads an address bare, in a name-and-address form, and URL-encoded in a logged path', async () => {
11
+ const log = await LogCapture.during(async () => {
12
+ console.log('Email sent successfully to ada@example.com');
13
+ console.info('Invite from "Grace" <grace.hopper@example.org>');
14
+ console.info('Started /dev/login?email=ada%2Blane%40example.com');
15
+ console.warn('Sign-in refused', { account: '0123456789abcdef', domain: 'example.com' });
16
+ });
17
+
18
+ expect(log.addresses).toEqual(['ada@example.com', 'grace.hopper@example.org', 'ada%2Blane%40example.com']);
19
+ });
20
+
21
+ it('reads nothing from a log that names people by digest and domain only', async () => {
22
+ const log = await LogCapture.during(async () => {
23
+ console.info('Email sent — 1 recipient (example.com)', { recipients: ['0123456789abcdef'] });
24
+ console.info('Finished /user/login 200');
25
+ });
26
+
27
+ expect(log.addresses).toEqual([]);
28
+ });
29
+ });
@@ -9,6 +9,12 @@ import { createHash } from 'crypto';
9
9
  export class LogCapture {
10
10
  /** How many leading characters of a token count as the token showing up in a log. */
11
11
  private static readonly TOKEN_PREFIX_LENGTH = 12;
12
+ /**
13
+ * An e-mail address, as the house's log check reads one: bare, or URL-encoded (`%40` for the `@`)
14
+ * as a logged request path carries it — a check that read only the bare spelling missed the one
15
+ * place an address is known to reach a log, the request log's URL.
16
+ */
17
+ private static readonly ADDRESS_SHAPE = /[A-Za-z0-9._%+-]+(?:@|%40)[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
12
18
 
13
19
  private constructor(readonly lines: string[]) {}
14
20
 
@@ -38,6 +44,16 @@ export class LogCapture {
38
44
  return this.lines.join('\n');
39
45
  }
40
46
 
47
+ /** Every e-mail address the log holds, in order of appearance — a log names people by their digests, never by address. */
48
+ get addresses(): string[] {
49
+ return this.text.match(LogCapture.ADDRESS_SHAPE) ?? [];
50
+ }
51
+
52
+ /** The capture narrowed to the writes that contain `text` — one door's lines, read with the same readers. */
53
+ linesContaining(text: string): LogCapture {
54
+ return new LogCapture(this.lines.filter((line) => line.includes(text)));
55
+ }
56
+
41
57
  /**
42
58
  * Fails unless the log is free of `token` in every form that would let a log reader act on it
43
59
  * or tie a line to a row: the token, the SHA-256 digest a row stores for it, and the token's