@proteinjs/user-server 1.22.2 → 1.22.3

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 (83) hide show
  1. package/CHANGELOG.md +15 -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 +5 -0
  38. package/dist/src/services/Signup.d.ts.map +1 -1
  39. package/dist/src/services/Signup.js +28 -6
  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 +717 -0
  56. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  57. package/dist/test/SignupRoute.test.js +80 -1
  58. package/dist/test/SignupRoute.test.js.map +1 -1
  59. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  60. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  61. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  62. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  63. package/generated/index.ts +1 -1
  64. package/package.json +6 -5
  65. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  66. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  67. package/src/authentication/authenticate.ts +1 -1
  68. package/src/authorization/userCache.ts +3 -2
  69. package/src/routes/devLogin.ts +22 -10
  70. package/src/routes/executePasswordReset.ts +6 -5
  71. package/src/routes/initiatePasswordReset.ts +1 -1
  72. package/src/routes/signup.ts +5 -2
  73. package/src/routes/validateResetPasswordToken.ts +5 -1
  74. package/src/services/MachineCredentials.ts +44 -9
  75. package/src/services/Roles.ts +6 -2
  76. package/src/services/Signup.ts +30 -4
  77. package/test/DevLogin.test.ts +35 -1
  78. package/test/LogCapture.test.ts +29 -0
  79. package/test/LogCapture.ts +16 -0
  80. package/test/MachineAccounts.integration.test.ts +97 -22
  81. package/test/NoAddressesInLogs.integration.test.ts +409 -0
  82. package/test/SignupRoute.test.ts +54 -1
  83. package/test/SocketIOSessionWatcher.test.ts +86 -0
@@ -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,11 @@ 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.
303
325
  */
304
326
  async createAccount(account: {
305
327
  name: string;
@@ -310,6 +332,10 @@ export class Signup implements SignupService {
310
332
  }): Promise<'created' | 'exists'> {
311
333
  const db = getDbAsSystem();
312
334
  const email = account.email.toLowerCase();
335
+ if (getMachineAccounts().some((machineAccount) => machineAccount.email === email)) {
336
+ throw new Error(`This address can't be registered.`);
337
+ }
338
+
313
339
  const existingUser = await db.get(tables.User, { email });
314
340
  if (existingUser) {
315
341
  return 'exists';
@@ -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
@@ -6,6 +6,7 @@ import { SourceRepository } from '@proteinjs/reflection';
6
6
  import { ServiceAuth } from '@proteinjs/service/dist/src/ServiceAuth';
7
7
  import { MachineAccount, RoleCatalogEntry, User, UserRepo, tables } from '@proteinjs/user';
8
8
  import { authenticate } from '../src/authentication/authenticate';
9
+ import { SocketIOSessionWatcher } from '../src/authentication/SocketIOSessionWatcher';
9
10
  import { UserStatusTableWatcher } from '../src/authentication/UserStatusTableWatcher';
10
11
  import { MachineCredentials } from '../src/services/MachineCredentials';
11
12
  import { Roles } from '../src/services/Roles';
@@ -48,10 +49,12 @@ const resetTableWatcherMap = () => {
48
49
 
49
50
  /**
50
51
  * Machine accounts as source records, at the user layer: declare-only on the REAL mixed user
51
- * table, adopt-in-place by email (id + password survive — the old credential still logs in),
52
- * removed-from-source auto-deactivation with session kill through the categorical
53
- * UserStatusTableWatcher, the machine/human ledger split (Roles refuses machine targets),
54
- * declaration validation, and credential minting (hash-only at rest, plaintext shown once).
52
+ * table, a declared address held by a row the sync does not own (a person's signup, a hand-made
53
+ * row) REFUSED — never taken over, its credential and sessions never inherited — and said so on
54
+ * the admin list and the mint; removed-from-source auto-deactivation with session kill through
55
+ * the categorical UserStatusTableWatcher, completing at boot before any socket server exists;
56
+ * the machine/human ledger split (Roles refuses machine targets), declaration validation, and
57
+ * credential minting (hash-only at rest, plaintext shown once).
55
58
  */
56
59
  describe('Machine accounts as source records', () => {
57
60
  const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
@@ -90,8 +93,10 @@ describe('Machine accounts as source records', () => {
90
93
  '@proteinjs/user-auth/AuthenticatedUserRepo'
91
94
  ] = [new UserRepo()];
92
95
  objectCache()['@proteinjs/user/RoleCatalogEntry'] = [new OpsRole()];
93
- // Exactly the deactivation watcher observes this run, rebuilt from the seeded cache.
94
- objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher()];
96
+ // The two session watchers a real server registers, rebuilt from the seeded cache — and no
97
+ // socket server exists in this process: the shape of a boot's sync (and of a migration Job,
98
+ // which never starts one), where deactivations delete sessions before the server is up.
99
+ objectCache()['@proteinjs/db/TableWatcher'] = [new UserStatusTableWatcher(), new SocketIOSessionWatcher()];
95
100
  resetTableWatcherMap();
96
101
  }, 120000);
97
102
 
@@ -139,32 +144,102 @@ describe('Machine accounts as source records', () => {
139
144
  expect(humanAfter).toMatchObject({ name: 'A human', roles: ['ops'] });
140
145
  });
141
146
 
142
- it('adopt-in-place by email: the hand-made row keeps its id and password; declared fields revert', async () => {
143
- // The deployed-env shape: a hand-made machine row with an env-random id, a provisioned
144
- // credential, and runtime-granted roles that have drifted from the declaration.
147
+ it(`a person's row holding a declared address refuses the declaration: no roles, no machine flag, their password and sessions stand; the list and the mint say so`, async () => {
148
+ // A person registered the address before the declaration shipped (inviteless signup): their
149
+ // own row, their own password, a live session — never a machine.
150
+ const person = await testEnv.createUser({ name: 'A person', email: 'machine-ops@test.local' });
151
+ await insertSession('person-session', 'machine-ops@test.local');
152
+ class Sibling extends TestOpsMachineAccount {
153
+ id = 'machine-sibling';
154
+ email = 'machine-sibling@test.local';
155
+ accountName = 'Sibling machine';
156
+ secretName = 'sibling-secret';
157
+ }
158
+
159
+ // The boot completes and the rest of the declarations land.
160
+ await boot([new TestOpsMachineAccount(), new Sibling()]);
161
+ expect(await getDbAsSystem().get(tables.User, { email: 'machine-sibling@test.local' })).toMatchObject({
162
+ roles: ['ops'],
163
+ isLoadedFromSource: true,
164
+ machine: true,
165
+ });
166
+
167
+ // The person's row is untouched: none of the declared roles, not a machine, not source-owned.
168
+ const row = await machineRow();
169
+ expect(row.id).toBe(person.id);
170
+ expect(row).toMatchObject({ name: 'A person', roles: [], emailVerified: true });
171
+ expect(row.machine).toBeFalsy();
172
+ expect(row.isLoadedFromSource).toBeFalsy();
173
+ expect(row.password).toBe(person.password);
174
+ expect(row.updated.valueOf()).toBe(person.updated.valueOf());
175
+ expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
176
+
177
+ // The admin list names the refusal; the mint refuses in the same words and changes nothing.
178
+ const view = (await new MachineCredentials().listMachineAccounts()).find(
179
+ (account) => account.email === 'machine-ops@test.local'
180
+ );
181
+ expect(view).toMatchObject({
182
+ status: 'declaration refused',
183
+ refusal: 'a person holds this address',
184
+ hasCredential: false,
185
+ });
186
+ await expect(new MachineCredentials().mintCredential('machine-ops@test.local')).rejects.toThrow(
187
+ /declaration for 'machine-ops@test\.local' was refused at boot: a person holds this address/
188
+ );
189
+ expect((await machineRow()).password).toBe(person.password);
190
+ expect(await sessionEmails()).toEqual(['machine-ops@test.local']);
191
+
192
+ // Idempotent: the next boot refuses again and still grants nothing.
193
+ await boot([new TestOpsMachineAccount(), new Sibling()]);
194
+ const again = await machineRow();
195
+ expect(again).toMatchObject({ roles: [] });
196
+ expect(again.isLoadedFromSource).toBeFalsy();
197
+ });
198
+
199
+ it('a hand-made machine row the sync never owned is refused too — no row is taken over by a declaration any more', async () => {
200
+ // The pre-declaration provisioning shape: a row made by hand and marked machine, with a
201
+ // provisioned credential and runtime-granted roles.
145
202
  const handMade = await testEnv.createUser({
146
203
  name: 'Hand-made bridge',
147
204
  email: 'machine-ops@test.local',
148
205
  roles: ['ops', 'stale-role'],
149
206
  });
207
+ await getDbAsSystem().update(tables.User, { id: handMade.id, machine: true });
150
208
 
151
209
  await boot([new TestOpsMachineAccount()]);
152
210
 
153
- const adopted = await machineRow();
154
- expect(adopted.id).toBe(handMade.id);
155
- expect(adopted).toMatchObject({
156
- name: 'Test ops machine',
157
- roles: ['ops'],
158
- status: 'active',
159
- isLoadedFromSource: true,
160
- // Adoption stamps the explicit machine column onto the hand-made row (the deployed-env
161
- // provisioning shape — e.g. a hand-provisioned deploy account becomes honest on boot).
211
+ const row = await machineRow();
212
+ expect(row).toMatchObject({
213
+ id: handMade.id,
214
+ name: 'Hand-made bridge',
215
+ roles: ['ops', 'stale-role'],
162
216
  machine: true,
163
217
  });
164
- // The credential survived adoption: the account still authenticates with its old password
165
- // (createUser stores sha256('test')... it stores the raw string; assert equality instead).
166
- expect(adopted.password).toBe(handMade.password);
167
- expect((await getDbAsSystem().query(tables.User, { email: 'machine-ops@test.local' })).length).toBe(1);
218
+ expect(row.isLoadedFromSource).toBeFalsy();
219
+ expect(row.password).toBe(handMade.password);
220
+ const [view] = await new MachineCredentials().listMachineAccounts();
221
+ expect(view).toMatchObject({
222
+ status: 'declaration refused',
223
+ refusal: 'a hand-made machine row holds this address',
224
+ hasCredential: false,
225
+ });
226
+ });
227
+
228
+ it('withdrawing a declaration while its account holds a live session: the boot completes before any socket server exists — the row deactivated, the session gone', async () => {
229
+ class Keeper extends TestOpsMachineAccount {
230
+ id = 'machine-keeper';
231
+ email = 'machine-keeper@test.local';
232
+ accountName = 'Keeper machine';
233
+ secretName = 'keeper-secret';
234
+ }
235
+ await boot([new TestOpsMachineAccount(), new Keeper()]);
236
+ await insertSession('machine-live-session', 'machine-ops@test.local');
237
+
238
+ // The declaration is withdrawn; the package still boots with its other declaration.
239
+ await boot([new Keeper()]);
240
+
241
+ expect((await machineRow()).status).toBe('deactivated');
242
+ expect(await sessionEmails()).toEqual([]);
168
243
  });
169
244
 
170
245
  it('removed from source: deactivated (never deleted), sessions killed, login refused; re-declaring reactivates', async () => {