@proteinjs/user-server 1.7.0 → 1.8.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 (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +9 -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/PasswordHasher.d.ts +45 -0
  10. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
  11. package/dist/src/authentication/PasswordHasher.js +152 -0
  12. package/dist/src/authentication/PasswordHasher.js.map +1 -0
  13. package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
  14. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
  15. package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
  16. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
  17. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  18. package/dist/src/authentication/authenticate.js +41 -13
  19. package/dist/src/authentication/authenticate.js.map +1 -1
  20. package/dist/src/authorization/userCache.d.ts.map +1 -1
  21. package/dist/src/authorization/userCache.js +10 -1
  22. package/dist/src/authorization/userCache.js.map +1 -1
  23. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
  24. package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
  25. package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
  26. package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
  27. package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
  28. package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
  29. package/dist/src/emails/AccountDeletionEmails.js +98 -0
  30. package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
  31. package/dist/src/routes/executePasswordReset.js +5 -3
  32. package/dist/src/routes/executePasswordReset.js.map +1 -1
  33. package/dist/src/routes/login.d.ts.map +1 -1
  34. package/dist/src/routes/login.js +26 -2
  35. package/dist/src/routes/login.js.map +1 -1
  36. package/dist/src/services/AccountDeletion.d.ts +52 -0
  37. package/dist/src/services/AccountDeletion.d.ts.map +1 -0
  38. package/dist/src/services/AccountDeletion.js +426 -0
  39. package/dist/src/services/AccountDeletion.js.map +1 -0
  40. package/dist/src/services/MachineCredentials.d.ts +21 -0
  41. package/dist/src/services/MachineCredentials.d.ts.map +1 -0
  42. package/dist/src/services/MachineCredentials.js +164 -0
  43. package/dist/src/services/MachineCredentials.js.map +1 -0
  44. package/dist/src/services/Roles.d.ts.map +1 -1
  45. package/dist/src/services/Roles.js +7 -0
  46. package/dist/src/services/Roles.js.map +1 -1
  47. package/dist/src/services/SetUserStatus.d.ts +18 -0
  48. package/dist/src/services/SetUserStatus.d.ts.map +1 -0
  49. package/dist/src/services/SetUserStatus.js +118 -0
  50. package/dist/src/services/SetUserStatus.js.map +1 -0
  51. package/dist/src/services/Signup.d.ts +2 -2
  52. package/dist/src/services/Signup.d.ts.map +1 -1
  53. package/dist/src/services/Signup.js +22 -17
  54. package/dist/src/services/Signup.js.map +1 -1
  55. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  56. package/dist/src/services/UpdateUserInfo.js +20 -17
  57. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  58. package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
  59. package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
  60. package/dist/test/AccountDeletion.integration.test.js +710 -0
  61. package/dist/test/AccountDeletion.integration.test.js.map +1 -0
  62. package/dist/test/DevLogin.test.js +12 -11
  63. package/dist/test/DevLogin.test.js.map +1 -1
  64. package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
  65. package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
  66. package/dist/test/MachineAccounts.integration.test.js +597 -0
  67. package/dist/test/MachineAccounts.integration.test.js.map +1 -0
  68. package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
  69. package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
  70. package/dist/test/PasswordMigration.integration.test.js +332 -0
  71. package/dist/test/PasswordMigration.integration.test.js.map +1 -0
  72. package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
  73. package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
  74. package/dist/test/SetUserStatus.integration.test.js +266 -0
  75. package/dist/test/SetUserStatus.integration.test.js.map +1 -0
  76. package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
  77. package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
  78. package/dist/test/UserStatusGate.integration.test.js +162 -0
  79. package/dist/test/UserStatusGate.integration.test.js.map +1 -0
  80. package/generated/index.ts +9 -1
  81. package/index.ts +3 -0
  82. package/package.json +8 -6
  83. package/src/authentication/PasswordHasher.ts +87 -0
  84. package/src/authentication/UserStatusTableWatcher.ts +56 -0
  85. package/src/authentication/authenticate.ts +31 -6
  86. package/src/authorization/userCache.ts +9 -1
  87. package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
  88. package/src/emails/AccountDeletionEmails.ts +39 -0
  89. package/src/routes/executePasswordReset.ts +2 -2
  90. package/src/routes/login.ts +23 -0
  91. package/src/services/AccountDeletion.ts +291 -0
  92. package/src/services/MachineCredentials.ts +94 -0
  93. package/src/services/Roles.ts +10 -0
  94. package/src/services/SetUserStatus.ts +56 -0
  95. package/src/services/Signup.ts +4 -4
  96. package/src/services/UpdateUserInfo.ts +4 -5
  97. package/test/AccountDeletion.integration.test.ts +391 -0
  98. package/test/DevLogin.test.ts +5 -4
  99. package/test/MachineAccounts.integration.test.ts +312 -0
  100. package/test/PasswordMigration.integration.test.ts +132 -0
  101. package/test/SetUserStatus.integration.test.ts +120 -0
  102. package/test/UserStatusGate.integration.test.ts +68 -0
@@ -0,0 +1,87 @@
1
+ import { createHash, timingSafeEqual } from 'crypto';
2
+ import * as argon2 from 'argon2';
3
+
4
+ /**
5
+ * `human` credentials are human-chosen (low-entropy) and get the full KDF; `machine` credentials
6
+ * are 256-bit generated secrets (MachineCredentials mint) where key stretching adds no security —
7
+ * the secret's entropy already exceeds any brute-force budget — and a ~100ms KDF would tax the
8
+ * bridge's log-in-fresh-per-poll pattern for nothing, so they stay sha256.
9
+ */
10
+ export type PasswordHashMode = 'human' | 'machine';
11
+
12
+ /**
13
+ * The one owner of credential hashing. Every path that writes or checks the user table's
14
+ * `password` column goes through this class: authenticate (login), Signup.createAccount,
15
+ * executePasswordReset, UpdateUserInfo.updatePassword, AccountDeletion re-auth, and
16
+ * MachineCredentials minting.
17
+ *
18
+ * Exactly two self-describing at-rest formats:
19
+ * - argon2id encoded (`$argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>`) — human passwords.
20
+ * The per-user random salt and the cost parameters live inside the encoded string.
21
+ * - sha256 hex (64 chars, never starts with `$`) — the legacy human format (pre-KDF rows,
22
+ * upgraded in place on next login via verify-then-rehash) AND the permanent machine format
23
+ * (see `PasswordHashMode`).
24
+ */
25
+ export class PasswordHasher {
26
+ /**
27
+ * argon2id cost, pinned so a package upgrade can't silently change what we write: 64 MiB
28
+ * memory, 3 passes, parallelism 4 — RFC 9106's second recommended parameter set (its
29
+ * memory-constrained option). Measured ~23ms/hash on an M-series dev machine — the ~100ms
30
+ * class on typical server vCPUs.
31
+ */
32
+ private static readonly ARGON2_OPTIONS: argon2.HashOptions = {
33
+ type: argon2.argon2id,
34
+ memoryCost: 65536,
35
+ timeCost: 3,
36
+ parallelism: 4,
37
+ };
38
+
39
+ private static readonly ARGON2ID_PREFIX = '$argon2id$';
40
+
41
+ async hash(password: string, mode: PasswordHashMode = 'human'): Promise<string> {
42
+ if (mode === 'machine') {
43
+ return this.sha256Hex(password);
44
+ }
45
+
46
+ return await argon2.hash(password, PasswordHasher.ARGON2_OPTIONS);
47
+ }
48
+
49
+ /**
50
+ * Format-discriminating verify. `storedHash` absent means the row has no credential yet
51
+ * (a machine row before its first mint) — no password matches it.
52
+ */
53
+ async verify(storedHash: string | null | undefined, password: string): Promise<boolean> {
54
+ if (!storedHash) {
55
+ return false;
56
+ }
57
+
58
+ if (storedHash.startsWith(PasswordHasher.ARGON2ID_PREFIX)) {
59
+ return await argon2.verify(storedHash, password);
60
+ }
61
+
62
+ return this.constantTimeEqual(storedHash, this.sha256Hex(password));
63
+ }
64
+
65
+ /**
66
+ * True when a verified credential should be re-written in the current format: legacy sha256
67
+ * under `human` mode. Machine rows never rehash — sha256 IS their current format.
68
+ */
69
+ needsRehash(storedHash: string, mode: PasswordHashMode = 'human'): boolean {
70
+ if (mode === 'machine') {
71
+ return false;
72
+ }
73
+
74
+ return !storedHash.startsWith(PasswordHasher.ARGON2ID_PREFIX);
75
+ }
76
+
77
+ /** Byte-identical to the legacy crypto-js `sha256(value).toString()` (UTF-8 in, lowercase hex out). */
78
+ private sha256Hex(value: string): string {
79
+ return createHash('sha256').update(value, 'utf8').digest('hex');
80
+ }
81
+
82
+ private constantTimeEqual(stored: string, candidate: string): boolean {
83
+ const storedBytes = Buffer.from(stored, 'utf8');
84
+ const candidateBytes = Buffer.from(candidate, 'utf8');
85
+ return storedBytes.length === candidateBytes.length && timingSafeEqual(storedBytes, candidateBytes);
86
+ }
87
+ }
@@ -0,0 +1,56 @@
1
+ import { QueryBuilder, Table, TableWatcher, getDbAsSystem } from '@proteinjs/db';
2
+ import { User, tables } from '@proteinjs/user';
3
+ import { Logger } from '@proteinjs/logger';
4
+
5
+ /**
6
+ * The one owner of "deactivated ⇒ sessions die": any user-table update writing
7
+ * `status: 'deactivated'` deletes the account's session rows (SocketIOSessionWatcher kicks the
8
+ * live sockets on that delete). Because the writers all go through `Db.update` — the staff
9
+ * toggle (SetUserStatus), account deletion, and the boot sync deactivating machine accounts
10
+ * removed from source — this fires for every present and future deactivation path.
11
+ *
12
+ * The delete runs post-commit (`runAfterCommit`): a transactional status flip that rolls back
13
+ * must not have killed sessions, and socket kicks must observe committed truth.
14
+ */
15
+ export class UserStatusTableWatcher implements TableWatcher<User> {
16
+ private logger = new Logger({ name: this.constructor.name });
17
+
18
+ name(): string {
19
+ return this.constructor.name;
20
+ }
21
+
22
+ table(): Table<User> {
23
+ return tables.User;
24
+ }
25
+
26
+ async afterUpdate<T extends User>(recordUpdateCount: number, record: Partial<T>, qb: QueryBuilder<T>): Promise<void> {
27
+ if (record.status !== 'deactivated' || recordUpdateCount === 0) {
28
+ return;
29
+ }
30
+
31
+ if (!record.id) {
32
+ // Every deactivation writer today updates by record id. A future query-based writer must
33
+ // extend this watcher — surface it loudly instead of silently leaving sessions alive.
34
+ this.logger.error({
35
+ message: `Deactivation write without a record id — sessions NOT killed. Extend UserStatusTableWatcher for query-based status writers.`,
36
+ });
37
+ return;
38
+ }
39
+
40
+ const db = getDbAsSystem();
41
+ await db.runAfterCommit(async () => {
42
+ const user = await db.get(tables.User, { id: record.id });
43
+ if (!user) {
44
+ return; // row deleted since the flip — nothing to resolve sessions against
45
+ }
46
+
47
+ const deletedSessions = await db.delete(tables.Session, { userEmail: user.email });
48
+ if (deletedSessions > 0) {
49
+ this.logger.info({
50
+ message: `Deactivation killed sessions`,
51
+ obj: { email: user.email, sessions: deletedSessions },
52
+ });
53
+ }
54
+ });
55
+ }
56
+ }
@@ -1,8 +1,8 @@
1
- import sha256 from 'crypto-js/sha256';
2
1
  import { getDbAsSystem } from '@proteinjs/db';
3
2
  import { tables } from '@proteinjs/user';
4
3
  import { Logger } from '@proteinjs/logger';
5
4
  import { DefaultAdminCredentials } from './DefaultAdminCredentials';
5
+ import { PasswordHasher } from './PasswordHasher';
6
6
 
7
7
  export function createAuthentication(defaultAdminCredentials?: { username: string; password: string }) {
8
8
  if (defaultAdminCredentials) {
@@ -24,13 +24,38 @@ export async function authenticate(email: string, password: string): Promise<tru
24
24
  return true;
25
25
  }
26
26
 
27
- const users = await getDbAsSystem().query(tables.User, {
28
- email: email.toLowerCase(),
29
- password: sha256(password).toString(),
30
- });
31
- if (users.length < 1) {
27
+ // Fetch by EMAIL ONLY and compare in code — never query by password hash. Query-by-hash
28
+ // forced every stored credential into one deterministic queryable value (unsalted sha256);
29
+ // in-code comparison is what lets the stored format be salted and per-user.
30
+ const db = getDbAsSystem();
31
+ const user = await db.get(tables.User, { email: email.toLowerCase() });
32
+ const hasher = new PasswordHasher();
33
+ if (!user || !(await hasher.verify(user.password, password))) {
32
34
  return 'User name or password incorrect';
33
35
  }
34
36
 
37
+ // Verify-then-rehash: the just-proven password re-hashes a legacy sha256 row into the
38
+ // current format in place — the only moment the plaintext is available to migrate with.
39
+ // Machine rows (isLoadedFromSource) never rehash: sha256 IS their format (see PasswordHasher).
40
+ const mode = user.isLoadedFromSource === true ? 'machine' : 'human';
41
+ if (hasher.needsRehash(user.password, mode)) {
42
+ await db.update(tables.User, { id: user.id, password: await hasher.hash(password) });
43
+ }
44
+
45
+ // Deactivated accounts are refused a new session even with correct credentials; the session
46
+ // side of the same gate lives in userCache (deactivated sessions resolve as guest).
47
+ if (user.status === 'deactivated') {
48
+ // Pending-deletion accounts (deactivated by the account-deletion flow, not the staff toggle)
49
+ // may authenticate: logging back in IS the cancel signal — the login route runs the cancel
50
+ // hook before request.login and decides. No purgeAfter check here: the cancel's CAS claim is
51
+ // the arbiter, so a user beating the purge walker to a just-expired window wins honestly.
52
+ if (user.deleteRequestedAt != null) {
53
+ return true;
54
+ }
55
+
56
+ logger.warn({ message: 'Refused login for deactivated account', obj: { email: email.toLowerCase() } });
57
+ return 'This account has been deactivated';
58
+ }
59
+
35
60
  return true;
36
61
  }
@@ -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
+ }
@@ -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,7 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { routes } from '@proteinjs/user';
3
3
  import { authenticate } from '../authentication/authenticate';
4
+ import { AccountDeletion } from '../services/AccountDeletion';
4
5
 
5
6
  export const login: Route = {
6
7
  path: routes.login.path,
@@ -21,6 +22,28 @@ export const login: Route = {
21
22
  return;
22
23
  }
23
24
 
25
+ // Cancel-by-login: a pending-deletion account's successful authentication IS the cancel
26
+ // signal. The restore runs synchronously here, BEFORE request.login, so the first
27
+ // authenticated paint sees the fully restored account (no transient).
28
+ let outcome: Awaited<ReturnType<AccountDeletion['cancelPendingDeletion']>>;
29
+ try {
30
+ outcome = await new AccountDeletion().cancelPendingDeletion(credentials.email);
31
+ } catch (error) {
32
+ // Security boundary: the login response never carries internal error detail — an
33
+ // attacker probing emails must learn nothing from failure shapes (founder ruling
34
+ // 2026-08-18 after a watcher error surfaced verbatim in the login form). The real
35
+ // error stays loud in the server log.
36
+ console.error('cancelPendingDeletion failed during login', error);
37
+ response.send({ error: 'Unable to log in right now. Please try again.' });
38
+ return;
39
+ }
40
+ if (outcome === 'purging') {
41
+ const error = 'This account is being deleted and can no longer be restored.';
42
+ console.error(error);
43
+ response.send({ error });
44
+ return;
45
+ }
46
+
24
47
  await new Promise((resolve, reject) => {
25
48
  request.login(credentials.email, resolve);
26
49
  });