@proteinjs/user-server 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +11 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/authentication/DbSessionStore.d.ts +3 -2
  10. package/dist/src/authentication/DbSessionStore.d.ts.map +1 -1
  11. package/dist/src/authentication/DbSessionStore.js +3 -2
  12. package/dist/src/authentication/DbSessionStore.js.map +1 -1
  13. package/dist/src/authentication/PasswordHasher.d.ts +45 -0
  14. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
  15. package/dist/src/authentication/PasswordHasher.js +152 -0
  16. package/dist/src/authentication/PasswordHasher.js.map +1 -0
  17. package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
  18. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
  19. package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
  20. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
  21. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  22. package/dist/src/authentication/authenticate.js +41 -13
  23. package/dist/src/authentication/authenticate.js.map +1 -1
  24. package/dist/src/authentication/establishSession.d.ts +30 -0
  25. package/dist/src/authentication/establishSession.d.ts.map +1 -0
  26. package/dist/src/authentication/establishSession.js +83 -0
  27. package/dist/src/authentication/establishSession.js.map +1 -0
  28. package/dist/src/authorization/userCache.d.ts.map +1 -1
  29. package/dist/src/authorization/userCache.js +10 -1
  30. package/dist/src/authorization/userCache.js.map +1 -1
  31. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
  32. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
  33. package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
  34. package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
  35. package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
  36. package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
  37. package/dist/src/emails/AccountDeletionEmails.js +98 -0
  38. package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
  39. package/dist/src/routes/devLogin.d.ts.map +1 -1
  40. package/dist/src/routes/devLogin.js +6 -12
  41. package/dist/src/routes/devLogin.js.map +1 -1
  42. package/dist/src/routes/executePasswordReset.js +5 -3
  43. package/dist/src/routes/executePasswordReset.js.map +1 -1
  44. package/dist/src/routes/login.d.ts.map +1 -1
  45. package/dist/src/routes/login.js +27 -4
  46. package/dist/src/routes/login.js.map +1 -1
  47. package/dist/src/routes/logout.d.ts +10 -0
  48. package/dist/src/routes/logout.d.ts.map +1 -1
  49. package/dist/src/routes/logout.js +13 -32
  50. package/dist/src/routes/logout.js.map +1 -1
  51. package/dist/src/routes/signup.d.ts +19 -0
  52. package/dist/src/routes/signup.d.ts.map +1 -0
  53. package/dist/src/routes/signup.js +102 -0
  54. package/dist/src/routes/signup.js.map +1 -0
  55. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  56. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  57. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  58. package/dist/src/services/AccountDeletion.d.ts +52 -0
  59. package/dist/src/services/AccountDeletion.d.ts.map +1 -0
  60. package/dist/src/services/AccountDeletion.js +426 -0
  61. package/dist/src/services/AccountDeletion.js.map +1 -0
  62. package/dist/src/services/MachineCredentials.d.ts +21 -0
  63. package/dist/src/services/MachineCredentials.d.ts.map +1 -0
  64. package/dist/src/services/MachineCredentials.js +164 -0
  65. package/dist/src/services/MachineCredentials.js.map +1 -0
  66. package/dist/src/services/Roles.d.ts.map +1 -1
  67. package/dist/src/services/Roles.js +7 -0
  68. package/dist/src/services/Roles.js.map +1 -1
  69. package/dist/src/services/SetUserStatus.d.ts +18 -0
  70. package/dist/src/services/SetUserStatus.d.ts.map +1 -0
  71. package/dist/src/services/SetUserStatus.js +118 -0
  72. package/dist/src/services/SetUserStatus.js.map +1 -0
  73. package/dist/src/services/Signup.d.ts +20 -3
  74. package/dist/src/services/Signup.d.ts.map +1 -1
  75. package/dist/src/services/Signup.js +30 -19
  76. package/dist/src/services/Signup.js.map +1 -1
  77. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  78. package/dist/src/services/UpdateUserInfo.js +20 -17
  79. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  80. package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
  81. package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
  82. package/dist/test/AccountDeletion.integration.test.js +718 -0
  83. package/dist/test/AccountDeletion.integration.test.js.map +1 -0
  84. package/dist/test/DevLogin.test.js +27 -31
  85. package/dist/test/DevLogin.test.js.map +1 -1
  86. package/dist/test/LogoutRoute.test.d.ts +2 -0
  87. package/dist/test/LogoutRoute.test.d.ts.map +1 -0
  88. package/dist/test/LogoutRoute.test.js +81 -0
  89. package/dist/test/LogoutRoute.test.js.map +1 -0
  90. package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
  91. package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
  92. package/dist/test/MachineAccounts.integration.test.js +597 -0
  93. package/dist/test/MachineAccounts.integration.test.js.map +1 -0
  94. package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
  95. package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
  96. package/dist/test/PasswordMigration.integration.test.js +332 -0
  97. package/dist/test/PasswordMigration.integration.test.js.map +1 -0
  98. package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
  99. package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
  100. package/dist/test/SetUserStatus.integration.test.js +266 -0
  101. package/dist/test/SetUserStatus.integration.test.js.map +1 -0
  102. package/dist/test/Signup.test.js +4 -2
  103. package/dist/test/Signup.test.js.map +1 -1
  104. package/dist/test/SignupRoute.test.d.ts +2 -0
  105. package/dist/test/SignupRoute.test.d.ts.map +1 -0
  106. package/dist/test/SignupRoute.test.js +338 -0
  107. package/dist/test/SignupRoute.test.js.map +1 -0
  108. package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
  109. package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
  110. package/dist/test/UserStatusGate.integration.test.js +162 -0
  111. package/dist/test/UserStatusGate.integration.test.js.map +1 -0
  112. package/dist/test/ValidateResetToken.test.d.ts +2 -0
  113. package/dist/test/ValidateResetToken.test.d.ts.map +1 -0
  114. package/dist/test/ValidateResetToken.test.js +170 -0
  115. package/dist/test/ValidateResetToken.test.js.map +1 -0
  116. package/dist/test/passportSessionHarness.d.ts +31 -0
  117. package/dist/test/passportSessionHarness.d.ts.map +1 -0
  118. package/dist/test/passportSessionHarness.js +107 -0
  119. package/dist/test/passportSessionHarness.js.map +1 -0
  120. package/generated/index.ts +11 -1
  121. package/index.ts +3 -0
  122. package/package.json +11 -7
  123. package/src/authentication/DbSessionStore.ts +3 -2
  124. package/src/authentication/PasswordHasher.ts +87 -0
  125. package/src/authentication/UserStatusTableWatcher.ts +56 -0
  126. package/src/authentication/authenticate.ts +31 -6
  127. package/src/authentication/establishSession.ts +33 -0
  128. package/src/authorization/userCache.ts +9 -1
  129. package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
  130. package/src/emails/AccountDeletionEmails.ts +39 -0
  131. package/src/routes/devLogin.ts +4 -6
  132. package/src/routes/executePasswordReset.ts +2 -2
  133. package/src/routes/login.ts +25 -3
  134. package/src/routes/logout.ts +13 -18
  135. package/src/routes/signup.ts +55 -0
  136. package/src/routes/validateResetPasswordToken.ts +5 -1
  137. package/src/services/AccountDeletion.ts +291 -0
  138. package/src/services/MachineCredentials.ts +94 -0
  139. package/src/services/Roles.ts +10 -0
  140. package/src/services/SetUserStatus.ts +56 -0
  141. package/src/services/Signup.ts +25 -6
  142. package/src/services/UpdateUserInfo.ts +4 -5
  143. package/test/AccountDeletion.integration.test.ts +395 -0
  144. package/test/DevLogin.test.ts +16 -18
  145. package/test/LogoutRoute.test.ts +33 -0
  146. package/test/MachineAccounts.integration.test.ts +312 -0
  147. package/test/PasswordMigration.integration.test.ts +132 -0
  148. package/test/SetUserStatus.integration.test.ts +120 -0
  149. package/test/Signup.test.ts +4 -2
  150. package/test/SignupRoute.test.ts +219 -0
  151. package/test/UserStatusGate.integration.test.ts +68 -0
  152. package/test/ValidateResetToken.test.ts +86 -0
  153. package/test/passportSessionHarness.ts +58 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The ONE owner of session establishment. Every door that mints a session — login, dev login,
3
+ * signup auto-login — goes through here; no route calls `request.login` directly.
4
+ *
5
+ * The full contract — regenerate → bind → save, all committed before the caller responds — is
6
+ * passport's own since 0.6 (its CVE-2022-25896 session-fixation fix). `request.login` runs
7
+ * SessionManager.logIn: `session.regenerate` mints a FRESH session id on the privilege change
8
+ * (an attacker-planted pre-auth sid never survives authentication), serializeUser binds the
9
+ * account email onto the fresh session, and `session.save` commits the row before the callback
10
+ * runs. The save half matters with a DB-backed session store: relying on save-at-response-end
11
+ * races the client's follow-up navigation — the next request can read the session row before
12
+ * the write commits and render the login page (observed live on /dev/login; the race class is
13
+ * identical for every session-minting door).
14
+ *
15
+ * Deliberately NO regenerate or save around `request.login`: passport 0.6 offers no way to
16
+ * disable its internal regenerate (`keepSessionInfo` only merges old session data back in after
17
+ * regenerating), so a wrapper-level regenerate would mint two ids per login — one owner of the
18
+ * lifecycle, and it is passport. SignupRoute.test.ts pins the exactly-once ordering against the
19
+ * real passport machinery.
20
+ *
21
+ * COUPLED to the passport 0.6 upgrade in @proteinjs/server (which supplies the runtime passport
22
+ * middleware): under passport 0.4, `request.login` neither regenerates nor saves — shipping this
23
+ * package against a pre-0.6 @proteinjs/server would silently reopen both holes.
24
+ *
25
+ * `request` is typed loosely because passport augments the express request at runtime; the
26
+ * routes in this package share that convention. A login failure (regenerate/serialize/save
27
+ * error) rejects — a door must never answer success for a session that did not commit.
28
+ */
29
+ export async function establishSession(request: any, email: string): Promise<void> {
30
+ await new Promise<void>((resolve, reject) =>
31
+ request.login(email, (error: unknown) => (error ? reject(error) : resolve()))
32
+ );
33
+ }
@@ -27,7 +27,15 @@ export const userCache: SessionDataCache<User> = {
27
27
  user = adminUser;
28
28
  } else {
29
29
  const accountUser = await getDbAsSystem().get(tables.User, { email: userEmail.toLowerCase() });
30
- if (accountUser) {
30
+ if (accountUser && accountUser.status === 'deactivated') {
31
+ // The session half of the deactivation gate (login half in authenticate): the session
32
+ // cache is rebuilt per request, so a live session stops resolving the moment the
33
+ // account is deactivated — every request runs as the unauthenticated guest.
34
+ logger.warn({
35
+ message: `Session references a deactivated account; resolving as unauthenticated`,
36
+ obj: { sessionId, userEmail },
37
+ });
38
+ } else if (accountUser) {
31
39
  delete (accountUser as any)['password'];
32
40
  user = accountUser;
33
41
  } else {
@@ -0,0 +1,79 @@
1
+ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
+ import Mail from 'nodemailer/lib/mailer';
3
+ import { Moment } from 'moment';
4
+
5
+ /**
6
+ * Config-factory seams for the two account-deletion emails (the PasswordResetEmailConfig
7
+ * pattern; the interfaces live here because @proteinjs/email-server is a registry package).
8
+ * Both emails are self-contained notifications, so each getter carries default content (the
9
+ * PasswordUpdatedEmailConfig precedent) — an app implements the factory to override.
10
+ */
11
+
12
+ export interface AccountDeletionRequestedEmailConfig {
13
+ /** @see https://nodemailer.com/message/ for all available options */
14
+ options?: Mail.Options;
15
+ /** @param purgeAfter when the grace window ends and the account is permanently erased */
16
+ getEmailContent: (purgeAfter: Moment) => {
17
+ text: string;
18
+ html?: string;
19
+ };
20
+ }
21
+
22
+ export interface DefaultAccountDeletionRequestedEmailConfigFactory extends Loadable {
23
+ getConfig(): AccountDeletionRequestedEmailConfig;
24
+ }
25
+
26
+ export const getDefaultAccountDeletionRequestedEmailConfigFactory =
27
+ (): DefaultAccountDeletionRequestedEmailConfigFactory => {
28
+ const defaultFactory: DefaultAccountDeletionRequestedEmailConfigFactory = {
29
+ getConfig: () => ({
30
+ options: { subject: 'Your account is scheduled for deletion' },
31
+ getEmailContent: (purgeAfter: Moment) => ({
32
+ text:
33
+ `Your account is scheduled for deletion. Your content is no longer visible to you or to ` +
34
+ `anyone you shared it with. You can change your mind until ${purgeAfter.format('MMMM D, YYYY')} ` +
35
+ `by simply logging back in — that restores everything, including shares. After that date, your ` +
36
+ `account and everything in it are permanently removed from our systems, and we'll email you to ` +
37
+ `confirm when it's done. If you didn't request this, log back in now to cancel.`,
38
+ }),
39
+ }),
40
+ };
41
+
42
+ const retrievedFactory = SourceRepository.get().object<DefaultAccountDeletionRequestedEmailConfigFactory>(
43
+ '@proteinjs/user-server/DefaultAccountDeletionRequestedEmailConfigFactory'
44
+ );
45
+
46
+ return retrievedFactory || defaultFactory;
47
+ };
48
+
49
+ export interface AccountDeletedEmailConfig {
50
+ /** @see https://nodemailer.com/message/ for all available options */
51
+ options?: Mail.Options;
52
+ getEmailContent: () => {
53
+ text: string;
54
+ html?: string;
55
+ };
56
+ }
57
+
58
+ export interface DefaultAccountDeletedEmailConfigFactory extends Loadable {
59
+ getConfig(): AccountDeletedEmailConfig;
60
+ }
61
+
62
+ export const getDefaultAccountDeletedEmailConfigFactory = (): DefaultAccountDeletedEmailConfigFactory => {
63
+ const defaultFactory: DefaultAccountDeletedEmailConfigFactory = {
64
+ getConfig: () => ({
65
+ options: { subject: 'Your account has been deleted' },
66
+ getEmailContent: () => ({
67
+ text:
68
+ `Your account and everything in it have been permanently removed from our systems. ` +
69
+ `This is the confirmation you were promised when you requested deletion. Goodbye, and thank you.`,
70
+ }),
71
+ }),
72
+ };
73
+
74
+ const retrievedFactory = SourceRepository.get().object<DefaultAccountDeletedEmailConfigFactory>(
75
+ '@proteinjs/user-server/DefaultAccountDeletedEmailConfigFactory'
76
+ );
77
+
78
+ return retrievedFactory || defaultFactory;
79
+ };
@@ -0,0 +1,39 @@
1
+ import { Moment } from 'moment';
2
+ import { EmailSender } from '@proteinjs/email-server';
3
+ import {
4
+ getDefaultAccountDeletedEmailConfigFactory,
5
+ getDefaultAccountDeletionRequestedEmailConfigFactory,
6
+ } from './AccountDeletionEmailConfigs';
7
+
8
+ /**
9
+ * Thin sender for the two account-deletion emails: deletion-requested (at deactivation — the
10
+ * only cancel channel an account-takeover victim has) and the store-required completion
11
+ * confirmation (sent by the purge walker after the user row is gone). Content comes from the
12
+ * config factories beside this class; transport is the app's DefaultEmailConfigFactory SMTP
13
+ * config via EmailSender.
14
+ */
15
+ export class AccountDeletionEmails {
16
+ async sendDeletionRequested(to: string, purgeAfter: Moment): Promise<void> {
17
+ const config = getDefaultAccountDeletionRequestedEmailConfigFactory().getConfig();
18
+ const { text, html } = config.getEmailContent(purgeAfter);
19
+ await new EmailSender().sendEmail({
20
+ to,
21
+ subject: 'Your account is scheduled for deletion',
22
+ text,
23
+ html,
24
+ ...config.options,
25
+ });
26
+ }
27
+
28
+ async sendAccountDeleted(to: string): Promise<void> {
29
+ const config = getDefaultAccountDeletedEmailConfigFactory().getConfig();
30
+ const { text, html } = config.getEmailContent();
31
+ await new EmailSender().sendEmail({
32
+ to,
33
+ subject: 'Your account has been deleted',
34
+ text,
35
+ html,
36
+ ...config.options,
37
+ });
38
+ }
39
+ }
@@ -1,5 +1,6 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { Logger } from '@proteinjs/logger';
3
+ import { establishSession } from '../authentication/establishSession';
3
4
  import { Signup } from '../services/Signup';
4
5
 
5
6
  const logger = new Logger({ name: 'devLogin' });
@@ -61,12 +62,9 @@ export const devLogin: Route = {
61
62
  logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
62
63
  }
63
64
 
64
- await new Promise((resolve) => request.login(email, resolve));
65
- // Explicit save before redirecting: with a DB-backed session store, save-on-response-end
66
- // races the redirected GET / — the follow-up request can read the session row before the
67
- // write commits and render the login page (observed: first /dev/login load lands on /login,
68
- // second succeeds). Awaiting the store write closes the race.
69
- await new Promise((resolve) => request.session.save(resolve));
65
+ // establishSession commits the session row before the redirect — the redirected GET / must
66
+ // never read the store ahead of the write (observed: first /dev/login load landed on /login).
67
+ await establishSession(request, email);
70
68
  logger.info({ message: 'Dev auto-login session established', obj: { email } });
71
69
  response.redirect('/');
72
70
  },
@@ -3,7 +3,7 @@ import { getDbAsSystem } from '@proteinjs/db';
3
3
  import { routes, tables } from '@proteinjs/user';
4
4
  import { Logger } from '@proteinjs/logger';
5
5
  import moment from 'moment';
6
- import sha256 from 'crypto-js/sha256';
6
+ import { PasswordHasher } from '../authentication/PasswordHasher';
7
7
 
8
8
  /**
9
9
  * Route handler for executing a password reset.
@@ -42,7 +42,7 @@ export const executePasswordReset: Route = {
42
42
  }
43
43
 
44
44
  // Update user's password
45
- const hashedPassword = sha256(newPassword).toString();
45
+ const hashedPassword = await new PasswordHasher().hash(newPassword);
46
46
  await db.update(tables.User, {
47
47
  id: user.id,
48
48
  password: hashedPassword,
@@ -1,6 +1,8 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
3
  import { authenticate } from '../authentication/authenticate';
4
+ import { establishSession } from '../authentication/establishSession';
5
+ import { AccountDeletion } from '../services/AccountDeletion';
4
6
 
5
7
  export const login: Route = {
6
8
  path: routes.login.path,
@@ -21,9 +23,29 @@ export const login: Route = {
21
23
  return;
22
24
  }
23
25
 
24
- await new Promise((resolve, reject) => {
25
- request.login(credentials.email, resolve);
26
- });
26
+ // Cancel-by-login: a pending-deletion account's successful authentication IS the cancel
27
+ // signal. The restore runs synchronously here, BEFORE request.login, so the first
28
+ // authenticated paint sees the fully restored account (no transient).
29
+ let outcome: Awaited<ReturnType<AccountDeletion['cancelPendingDeletion']>>;
30
+ try {
31
+ outcome = await new AccountDeletion().cancelPendingDeletion(credentials.email);
32
+ } catch (error) {
33
+ // Security boundary: the login response never carries internal error detail — an
34
+ // attacker probing emails must learn nothing from failure shapes (founder ruling
35
+ // 2026-08-18 after a watcher error surfaced verbatim in the login form). The real
36
+ // error stays loud in the server log.
37
+ console.error('cancelPendingDeletion failed during login', error);
38
+ response.send({ error: 'Unable to log in right now. Please try again.' });
39
+ return;
40
+ }
41
+ if (outcome === 'purging') {
42
+ const error = 'This account is being deleted and can no longer be restored.';
43
+ console.error(error);
44
+ response.send({ error });
45
+ return;
46
+ }
47
+
48
+ await establishSession(request, credentials.email);
27
49
  response.send({});
28
50
  },
29
51
  };
@@ -1,28 +1,23 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
- import { destroySession } from '../authentication/destroySession';
4
3
 
4
+ /**
5
+ * Logout rides passport's own `request.logout` (functional for store-backed sessions since
6
+ * passport 0.6, which added the callback form): it clears the user from the session and SAVES —
7
+ * a replayed old session id is logged out even before the id rotates — then REGENERATES the
8
+ * session id (fixation hygiene on the privilege change, mirroring login's rotation). The
9
+ * regenerate destroys the old session row through the store (`DbSessionStore.destroy`), whose
10
+ * row delete also disconnects the session's sockets (SocketIOSessionWatcher). The pre-0.6
11
+ * manual compensation — calling `destroySession` and nulling `session.passport.user` by hand
12
+ * because `request.logout` was broken — is retired with the workaround era that spawned it.
13
+ */
5
14
  export const logout: Route = {
6
15
  path: routes.logout.path,
7
16
  method: routes.logout.method,
8
17
  onRequest: async (request: any, response): Promise<void> => {
9
- // request.logout();
10
- // await request.session.destroy();
11
- // await new Promise((resolve, reject) => {
12
- // request.session?.destroy((error: any) => {
13
- // if (error) {
14
- // reject(error);
15
- // return;
16
- // }
17
-
18
- // resolve();
19
- // });
20
- // });
21
- // delete request._passport?.session?.user;
22
- // console.info(`request.sessionID: ${request.sessionID}`);
23
- // console.info(`request.session?.id: ${request.session?.id}`);
24
- await destroySession(request.sessionID);
25
- request.session.passport.user = null;
18
+ await new Promise<void>((resolve, reject) =>
19
+ request.logout((error: unknown) => (error ? reject(error) : resolve()))
20
+ );
26
21
  response.redirect('/login');
27
22
  },
28
23
  };
@@ -0,0 +1,55 @@
1
+ import { Route } from '@proteinjs/server-api';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { UserSignup, routes } from '@proteinjs/user';
4
+ import { establishSession } from '../authentication/establishSession';
5
+ import { Signup } from '../services/Signup';
6
+
7
+ const logger = new Logger({ name: 'signup' });
8
+
9
+ /**
10
+ * `POST /user/signup` — create the account AND establish its session in the same request
11
+ * (auto-login), so a new user lands in the app instead of being bounced to the login form to
12
+ * re-type the credentials they just submitted. Signup lives at the route layer beside login
13
+ * because session establishment is a request-level concern services never see; the domain flow
14
+ * (invite validation, account creation, notification emails) stays owned by `Signup.createUser`.
15
+ *
16
+ * Body: `UserSignup` (+ optional invite `token`; the invite carries the email on that path).
17
+ * Response mirrors the login route's contract: 200 with `{}` on success, `{ error }` with
18
+ * user-readable copy otherwise.
19
+ *
20
+ * When the email is already registered the response is byte-identical to success but NO session
21
+ * is minted — auto-login must never hand out a session for an account the caller didn't just
22
+ * create; the caller falls through to the login screen exactly as before. Existence is reported
23
+ * to the mailbox owner by email, never to the caller.
24
+ */
25
+ export const signup: Route = {
26
+ path: routes.signup.path,
27
+ method: routes.signup.method,
28
+ onRequest: async (request: any, response): Promise<void> => {
29
+ const body: Partial<UserSignup> & { token?: string } = request.body ?? {};
30
+ if (!body.name || !body.password) {
31
+ response.send({ error: 'Name and password cannot be blank' });
32
+ return;
33
+ }
34
+
35
+ let result;
36
+ try {
37
+ result = await new Signup().createUser(
38
+ { name: body.name, email: body.email, password: body.password },
39
+ body.token
40
+ );
41
+ } catch (error: any) {
42
+ // 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 });
45
+ response.send({ error: error instanceof Error ? error.message : 'Sign up failed.' });
46
+ return;
47
+ }
48
+
49
+ if (result.outcome === 'created') {
50
+ await establishSession(request, result.email);
51
+ }
52
+
53
+ response.send({});
54
+ },
55
+ };
@@ -34,6 +34,10 @@ export const validateResetPasswordToken: Route = {
34
34
  return;
35
35
  }
36
36
 
37
- response.status(200).send({ isValid: true });
37
+ // The account email rides the VALID response only: the reset page renders it as the
38
+ // read-only `autocomplete="username"` field so password managers associate the updated
39
+ // password with the stored credential. The token was delivered to this very inbox, so a
40
+ // valid-token holder learns nothing new; invalid/expired verdicts stay email-free.
41
+ response.status(200).send({ isValid: true, email: user.email });
38
42
  },
39
43
  };
@@ -0,0 +1,291 @@
1
+ import moment, { Moment } from 'moment';
2
+ import { Db, QueryBuilderFactory, Reference, getDbAsSystem } from '@proteinjs/db';
3
+ import { Logger } from '@proteinjs/logger';
4
+ import { Service } from '@proteinjs/service';
5
+ import {
6
+ AccessGrant,
7
+ AccountDeletion as AccountDeletionRecord,
8
+ AccountDeletionService,
9
+ ManifestGrant,
10
+ User,
11
+ UserRepo,
12
+ tables,
13
+ } from '@proteinjs/user';
14
+ import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
15
+ import { PasswordHasher } from '../authentication/PasswordHasher';
16
+ import { AccountDeletionEmails } from '../emails/AccountDeletionEmails';
17
+ import { SetUserStatus } from './SetUserStatus';
18
+
19
+ /** IN-list chunk size for grant enumeration/revocation (the Db.ts IN-chunk precedent). */
20
+ const GRANT_CHUNK_SIZE = 100;
21
+
22
+ /**
23
+ * Deactivation ("gone right away") + cancel-by-login — the synchronous half of archive-then-purge.
24
+ * The purge walker (flow-server) owns erasure after the grace window; this service owns entering
25
+ * and leaving the deactivated state, driven by the `account_deletion` manifest row.
26
+ *
27
+ * Deliberately NOT deactivation's job: the user's own content_reference/content_seen/pins/
28
+ * notifications — all scoped to the locked-out user, invisible behind the auth gate, purged by
29
+ * the walker. Leaving them preserves watermarks and pins exactly for cancel-restore.
30
+ */
31
+ export class AccountDeletion implements AccountDeletionService {
32
+ /** Any authenticated user — the call acts only on the session user (re-auth inside). */
33
+ public serviceMetadata: Service['serviceMetadata'] = {
34
+ auth: {
35
+ allUsers: true,
36
+ },
37
+ };
38
+ private logger = new Logger({ name: this.constructor.name });
39
+
40
+ /**
41
+ * §3.3 — seven steps, each idempotent; a re-call after a partial failure resumes from the
42
+ * stored manifest. The caller's sessions die last, so the response is the last authenticated
43
+ * exchange.
44
+ */
45
+ async requestDeletion(password: string): Promise<{ purgeAfter: Moment }> {
46
+ // 1. Re-auth — a miss throws with nothing written.
47
+ const user = await this.reauthenticateSessionUser(password);
48
+ const db = getDbAsSystem();
49
+
50
+ // 2. Resume check: an existing row means a prior call crashed mid-flight — resume with the
51
+ // STORED manifest, never re-enumerate (after a partial revocation the live grants
52
+ // under-count; rebuilding would clobber the manifest with the shrunken set).
53
+ let deletion = await db.get(tables.AccountDeletion, { userId: user.id });
54
+
55
+ // 3. Enumerate + persist — the manifest is durable BEFORE any mutation.
56
+ if (!deletion) {
57
+ deletion = await this.createDeletionManifest(db, user);
58
+ }
59
+
60
+ // 4. Revoke. Watchers fire per Db.delete: the grant watcher removes grantee-side cards for
61
+ // my content AND my cards for others' content — the synchronous "gone right away" contract.
62
+ // Already-deleted ids no-op (resume-safe).
63
+ await this.deleteGrantsById(
64
+ db,
65
+ deletion.manifestGrants.map((grant) => grant.id)
66
+ );
67
+
68
+ // 5. Flip the user row through the ONE standing write path (audited), then stamp the
69
+ // deletion columns. deleteRequestedAt = the manifest row's own creation time, so a resumed
70
+ // call re-writes identical values.
71
+ await new SetUserStatus().setUserStatus(user.id, 'deactivated');
72
+ await db.update(tables.User, {
73
+ id: user.id,
74
+ deleteRequestedAt: deletion.created,
75
+ purgeAfter: deletion.purgeAfter,
76
+ });
77
+
78
+ // Deletion-requested email (§9.4-1) — the only cancel channel an account-takeover victim
79
+ // has. The deletion state is already durable, so a mail-transport failure logs loudly
80
+ // instead of misreporting the committed deactivation as failed.
81
+ try {
82
+ await new AccountDeletionEmails().sendDeletionRequested(user.email, deletion.purgeAfter);
83
+ } catch (error: any) {
84
+ this.logger.error({
85
+ message: 'Failed to send the deletion-requested email',
86
+ obj: { userId: user.id },
87
+ error,
88
+ });
89
+ }
90
+
91
+ // 6. Kill sessions last — SocketIOSessionWatcher disconnects every live socket, including
92
+ // the caller's own.
93
+ await db.delete(tables.Session, { userEmail: user.email });
94
+
95
+ // 7. The UI's confirmation copy renders from this.
96
+ return { purgeAfter: deletion.purgeAfter };
97
+ }
98
+
99
+ /**
100
+ * §3.4 — logging back in IS the cancel (called by the login route after `authenticate`
101
+ * passes, BEFORE `request.login`). Not RPC-reachable: it is deliberately absent from the
102
+ * AccountDeletionService interface, so the service router never exposes it.
103
+ */
104
+ async cancelPendingDeletion(email: string): Promise<'not-pending' | 'restored' | 'purging'> {
105
+ const db = getDbAsSystem();
106
+ const deletion = await db.get(tables.AccountDeletion, { userEmail: email.toLowerCase() });
107
+ if (!deletion) {
108
+ return 'not-pending';
109
+ }
110
+
111
+ // CAS claim (RoutineTicker shape): one winner across this login, concurrent logins, and the
112
+ // purge walker. 'restoring' re-entry covers a crashed prior cancel. No purgeAfter check —
113
+ // this CAS is the arbiter, so a user beating the walker to a just-expired window wins
114
+ // honestly.
115
+ const claimSeq = (deletion.leaseSeq ?? 0) + 1;
116
+ const claimQb = new QueryBuilderFactory()
117
+ .createQueryBuilder(tables.AccountDeletion)
118
+ .condition({ field: 'userId', operator: '=', value: deletion.userId })
119
+ .condition({ field: 'phase', operator: 'IN', value: ['grace', 'restoring'] })
120
+ .condition({ field: 'leaseSeq', operator: '=', value: deletion.leaseSeq ?? 0 });
121
+ const claimed = await db.update(tables.AccountDeletion, { phase: 'restoring', leaseSeq: claimSeq }, claimQb);
122
+ if (claimed !== 1) {
123
+ const current = await db.get(tables.AccountDeletion, { userId: deletion.userId });
124
+ if (!current) {
125
+ return 'not-pending'; // a concurrent cancel finished the restore — normal login proceeds
126
+ }
127
+ if (current.phase === 'purging' || current.phase === 'purged') {
128
+ return 'purging'; // the walker won — no longer restorable
129
+ }
130
+ throw new Error(`A concurrent restore is in flight for account ${deletion.userId} — retry login.`);
131
+ }
132
+
133
+ // Re-insert manifest grants idempotently (fresh ids). The grant watcher's afterInsert
134
+ // rebuilds content_reference cards through the normal path — grantees' cards for my content
135
+ // AND my cards for inbound shares — synchronously, before the login response.
136
+ await this.restoreManifestGrants(db, deletion.manifestGrants);
137
+
138
+ // User row back to standing through the audited path; deletion stamps nulled.
139
+ await new SetUserStatus().setUserStatus(deletion.userId, 'active');
140
+ await db.update(tables.User, { id: deletion.userId, deleteRequestedAt: null, purgeAfter: null });
141
+
142
+ await db.delete(tables.AccountDeletion, { id: deletion.id });
143
+ return 'restored';
144
+ }
145
+
146
+ /** Verify the caller's password against their own account row; throws with nothing written. */
147
+ private async reauthenticateSessionUser(password: string): Promise<User> {
148
+ const sessionUser = new UserRepo().getUser();
149
+ const adminCredentials = DefaultAdminCredentials.getCredentials();
150
+ if (adminCredentials && sessionUser.email === adminCredentials.username) {
151
+ throw new Error('The default admin session has no account row to delete.');
152
+ }
153
+ if (!sessionUser.id || !sessionUser.email) {
154
+ throw new Error('Account deletion requires a signed-in account.');
155
+ }
156
+
157
+ // Fetch by email only and verify in code (both stored formats). No rehash here — this
158
+ // method's contract is "throws with nothing written"; legacy rows migrate at login.
159
+ const user = await getDbAsSystem().get(tables.User, { email: sessionUser.email.toLowerCase() });
160
+ if (!user || !(await new PasswordHasher().verify(user.password, password))) {
161
+ throw new Error('Password incorrect');
162
+ }
163
+
164
+ return user;
165
+ }
166
+
167
+ /**
168
+ * §3.3 step 3: enumerate grants in both directions and persist the manifest row — phase
169
+ * 'grace', full grant set, owned-resource snapshot — before any mutation.
170
+ */
171
+ private async createDeletionManifest(db: Db, user: User): Promise<AccountDeletionRecord> {
172
+ const qbf = new QueryBuilderFactory();
173
+
174
+ // Owner grants are the authoritative ownership signal (backed by
175
+ // idx_ag_principal_table_level_resource). They are NOT revoked at deactivation — the purge
176
+ // walker deletes them terminally after the walk that enumerates from this snapshot.
177
+ const ownerQb = qbf
178
+ .createQueryBuilder(tables.AccessGrant)
179
+ .condition({ field: 'principal', operator: '=', value: user.id })
180
+ .condition({ field: 'accessLevel', operator: '=', value: 'owner' });
181
+ const ownerGrants = await db.query(tables.AccessGrant, ownerQb);
182
+ const ownedResourceIds = Array.from(
183
+ new Set(ownerGrants.map((grant) => this.referenceId(grant.resource, grant.id)))
184
+ );
185
+
186
+ // Inbound: my non-owner grants on other people's content.
187
+ const inboundQb = qbf
188
+ .createQueryBuilder(tables.AccessGrant)
189
+ .condition({ field: 'principal', operator: '=', value: user.id })
190
+ .condition({ field: 'accessLevel', operator: 'IN', value: ['read', 'write', 'admin'] });
191
+ const inbound = await db.query(tables.AccessGrant, inboundQb);
192
+
193
+ // Outbound: grants on my content held by anyone else (no resource-side index — chunked
194
+ // IN-list enumeration, acceptable at MVP scale).
195
+ const outbound: AccessGrant[] = [];
196
+ for (const idsChunk of this.chunk(ownedResourceIds, GRANT_CHUNK_SIZE)) {
197
+ const outboundQb = qbf
198
+ .createQueryBuilder(tables.AccessGrant)
199
+ .condition({ field: 'resource', operator: 'IN', value: idsChunk });
200
+ const grants = await db.query(tables.AccessGrant, outboundQb);
201
+ outbound.push(...grants.filter((grant) => this.referenceId(grant.principal, grant.id) !== user.id));
202
+ }
203
+
204
+ const graceDays = this.gracePeriodDays();
205
+ return await db.insert(tables.AccountDeletion, {
206
+ userId: user.id,
207
+ userEmail: user.email,
208
+ phase: 'grace',
209
+ purgeAfter: moment().add(Math.round(graceDays * 24 * 60 * 60 * 1000), 'milliseconds'),
210
+ manifestGrants: [...inbound, ...outbound].map((grant) => this.toManifestGrant(grant)),
211
+ ownedResourceIds,
212
+ leaseSeq: 0,
213
+ });
214
+ }
215
+
216
+ /** Revoke grants by manifest id, in chunks of 100. Already-deleted ids no-op. */
217
+ private async deleteGrantsById(db: Db, grantIds: string[]): Promise<void> {
218
+ for (const idsChunk of this.chunk(grantIds, GRANT_CHUNK_SIZE)) {
219
+ const qb = new QueryBuilderFactory()
220
+ .createQueryBuilder(tables.AccessGrant)
221
+ .condition({ field: 'id', operator: 'IN', value: idsChunk });
222
+ await db.delete(tables.AccessGrant, qb);
223
+ }
224
+ }
225
+
226
+ /** §3.4 step 3: per entry, get by (principal, resource, accessLevel) → insert if missing. */
227
+ private async restoreManifestGrants(db: Db, manifestGrants: ManifestGrant[]): Promise<void> {
228
+ for (const grant of manifestGrants) {
229
+ const existingQb = new QueryBuilderFactory()
230
+ .createQueryBuilder(tables.AccessGrant)
231
+ .condition({ field: 'principal', operator: '=', value: grant.principal })
232
+ .condition({ field: 'resource', operator: '=', value: grant.resource })
233
+ .condition({ field: 'accessLevel', operator: '=', value: grant.accessLevel });
234
+ const existing = await db.query(tables.AccessGrant, existingQb);
235
+ if (existing.length > 0) {
236
+ continue;
237
+ }
238
+
239
+ await db.insert(tables.AccessGrant, {
240
+ principal: new Reference(tables.User.name, grant.principal),
241
+ // Serialization fails loudly on a missing table name — a table-less grant is corrupt.
242
+ resource: new Reference(grant.resourceTable ?? '', grant.resource),
243
+ resourceTable: grant.resourceTable,
244
+ accessLevel: grant.accessLevel,
245
+ });
246
+ }
247
+ }
248
+
249
+ private toManifestGrant(grant: AccessGrant): ManifestGrant {
250
+ return {
251
+ id: grant.id,
252
+ principal: this.referenceId(grant.principal, grant.id),
253
+ resource: this.referenceId(grant.resource, grant.id),
254
+ resourceTable: grant.resourceTable,
255
+ accessLevel: grant.accessLevel,
256
+ };
257
+ }
258
+
259
+ /** A grant reference's id, loudly — the manifest must never be built from corrupt grants. */
260
+ private referenceId(reference: Reference<any>, grantId: string): string {
261
+ if (!reference?._id) {
262
+ throw new Error(`access_grant ${grantId} has a reference with no id — refusing to build a deletion manifest.`);
263
+ }
264
+ return reference._id;
265
+ }
266
+
267
+ /**
268
+ * Grace window in days (ACCOUNT_DELETION_GRACE_DAYS, default 30). Fractional values are
269
+ * honored so dev verification can shorten the window to minutes.
270
+ */
271
+ private gracePeriodDays(): number {
272
+ const raw = process.env.ACCOUNT_DELETION_GRACE_DAYS;
273
+ if (raw === undefined || raw === '') {
274
+ return 30;
275
+ }
276
+
277
+ const days = Number(raw);
278
+ if (!Number.isFinite(days) || days < 0) {
279
+ throw new Error(`ACCOUNT_DELETION_GRACE_DAYS must be a non-negative number of days, got '${raw}'`);
280
+ }
281
+ return days;
282
+ }
283
+
284
+ private chunk<T>(items: T[], size: number): T[][] {
285
+ const chunks: T[][] = [];
286
+ for (let i = 0; i < items.length; i += size) {
287
+ chunks.push(items.slice(i, i + size));
288
+ }
289
+ return chunks;
290
+ }
291
+ }