@proteinjs/user-server 1.6.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 (114) hide show
  1. package/CHANGELOG.md +35 -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 +27 -3
  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/devLogin.d.ts +15 -5
  32. package/dist/src/routes/devLogin.d.ts.map +1 -1
  33. package/dist/src/routes/devLogin.js +52 -15
  34. package/dist/src/routes/devLogin.js.map +1 -1
  35. package/dist/src/routes/executePasswordReset.js +5 -3
  36. package/dist/src/routes/executePasswordReset.js.map +1 -1
  37. package/dist/src/routes/login.d.ts.map +1 -1
  38. package/dist/src/routes/login.js +26 -2
  39. package/dist/src/routes/login.js.map +1 -1
  40. package/dist/src/services/AccountDeletion.d.ts +52 -0
  41. package/dist/src/services/AccountDeletion.d.ts.map +1 -0
  42. package/dist/src/services/AccountDeletion.js +426 -0
  43. package/dist/src/services/AccountDeletion.js.map +1 -0
  44. package/dist/src/services/MachineCredentials.d.ts +21 -0
  45. package/dist/src/services/MachineCredentials.d.ts.map +1 -0
  46. package/dist/src/services/MachineCredentials.js +164 -0
  47. package/dist/src/services/MachineCredentials.js.map +1 -0
  48. package/dist/src/services/Roles.d.ts.map +1 -1
  49. package/dist/src/services/Roles.js +7 -0
  50. package/dist/src/services/Roles.js.map +1 -1
  51. package/dist/src/services/SetUserStatus.d.ts +18 -0
  52. package/dist/src/services/SetUserStatus.d.ts.map +1 -0
  53. package/dist/src/services/SetUserStatus.js +118 -0
  54. package/dist/src/services/SetUserStatus.js.map +1 -0
  55. package/dist/src/services/Signup.d.ts +16 -1
  56. package/dist/src/services/Signup.d.ts.map +1 -1
  57. package/dist/src/services/Signup.js +55 -16
  58. package/dist/src/services/Signup.js.map +1 -1
  59. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  60. package/dist/src/services/UpdateUserInfo.js +20 -17
  61. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  62. package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
  63. package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
  64. package/dist/test/AccountDeletion.integration.test.js +710 -0
  65. package/dist/test/AccountDeletion.integration.test.js.map +1 -0
  66. package/dist/test/DevLogin.test.d.ts +2 -0
  67. package/dist/test/DevLogin.test.d.ts.map +1 -0
  68. package/dist/test/DevLogin.test.js +262 -0
  69. package/dist/test/DevLogin.test.js.map +1 -0
  70. package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
  71. package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
  72. package/dist/test/MachineAccounts.integration.test.js +597 -0
  73. package/dist/test/MachineAccounts.integration.test.js.map +1 -0
  74. package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
  75. package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
  76. package/dist/test/PasswordMigration.integration.test.js +332 -0
  77. package/dist/test/PasswordMigration.integration.test.js.map +1 -0
  78. package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
  79. package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
  80. package/dist/test/SetUserStatus.integration.test.js +266 -0
  81. package/dist/test/SetUserStatus.integration.test.js.map +1 -0
  82. package/dist/test/UserCacheMissingAccount.test.d.ts +2 -0
  83. package/dist/test/UserCacheMissingAccount.test.d.ts.map +1 -0
  84. package/dist/test/UserCacheMissingAccount.test.js +104 -0
  85. package/dist/test/UserCacheMissingAccount.test.js.map +1 -0
  86. package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
  87. package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
  88. package/dist/test/UserStatusGate.integration.test.js +162 -0
  89. package/dist/test/UserStatusGate.integration.test.js.map +1 -0
  90. package/generated/index.ts +9 -1
  91. package/index.ts +3 -0
  92. package/package.json +8 -6
  93. package/src/authentication/PasswordHasher.ts +87 -0
  94. package/src/authentication/UserStatusTableWatcher.ts +56 -0
  95. package/src/authentication/authenticate.ts +31 -6
  96. package/src/authorization/userCache.ts +25 -2
  97. package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
  98. package/src/emails/AccountDeletionEmails.ts +39 -0
  99. package/src/routes/devLogin.ts +45 -7
  100. package/src/routes/executePasswordReset.ts +2 -2
  101. package/src/routes/login.ts +23 -0
  102. package/src/services/AccountDeletion.ts +291 -0
  103. package/src/services/MachineCredentials.ts +94 -0
  104. package/src/services/Roles.ts +10 -0
  105. package/src/services/SetUserStatus.ts +56 -0
  106. package/src/services/Signup.ts +43 -12
  107. package/src/services/UpdateUserInfo.ts +4 -5
  108. package/test/AccountDeletion.integration.test.ts +391 -0
  109. package/test/DevLogin.test.ts +160 -0
  110. package/test/MachineAccounts.integration.test.ts +312 -0
  111. package/test/PasswordMigration.integration.test.ts +132 -0
  112. package/test/SetUserStatus.integration.test.ts +120 -0
  113. package/test/UserCacheMissingAccount.test.ts +42 -0
  114. package/test/UserStatusGate.integration.test.ts +68 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/user-server",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "User server components",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,14 +22,15 @@
22
22
  "test": "jest --runInBand --passWithNoTests"
23
23
  },
24
24
  "dependencies": {
25
- "@proteinjs/db": "^1.28.1",
26
- "@proteinjs/db-file": "^1.4.5",
25
+ "@proteinjs/db": "^1.34.1",
26
+ "@proteinjs/db-file": "^1.5.1",
27
27
  "@proteinjs/email-server": "^1.3.0",
28
28
  "@proteinjs/logger": "^1.0.21",
29
29
  "@proteinjs/reflection": "^1.1.12",
30
30
  "@proteinjs/server": "^3.2.1",
31
31
  "@proteinjs/server-api": "^3.0.11",
32
- "@proteinjs/user": "^1.8.0",
32
+ "@proteinjs/user": "^1.9.0",
33
+ "argon2": "^0.45.1",
33
34
  "crypto-js": "4.0.0",
34
35
  "express-session": "1.17.1",
35
36
  "heic-decode": "^2.1.0",
@@ -37,12 +38,13 @@
37
38
  "sharp": "0.33.4"
38
39
  },
39
40
  "devDependencies": {
40
- "@proteinjs/db-driver-spanner": "^1.16.0",
41
+ "@proteinjs/db-driver-spanner": "^1.22.1",
41
42
  "@proteinjs/reflection-build": "^1.4.10",
42
43
  "@types/crypto-js": "4.0.1",
43
44
  "@types/express-session": "1.17.0",
44
45
  "@types/jest": "29.5.5",
45
46
  "@types/node": "14.0.13",
47
+ "@types/nodemailer": "6.4.15",
46
48
  "@typescript-eslint/eslint-plugin": "7.8.0",
47
49
  "@typescript-eslint/parser": "7.8.0",
48
50
  "eslint": "8.57.0",
@@ -54,5 +56,5 @@
54
56
  },
55
57
  "main": "./dist/generated/index.js",
56
58
  "types": "./dist/generated/index.d.ts",
57
- "gitHead": "ca5406c5d08120454950abcb42a54bf77531b3e0"
59
+ "gitHead": "9f0638dcd6baef3d93c04ef399dcd8909ea7cf45"
58
60
  }
@@ -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
  }
@@ -1,9 +1,12 @@
1
1
  import moment from 'moment';
2
2
  import { SessionDataCache } from '@proteinjs/server-api';
3
3
  import { getDbAsSystem } from '@proteinjs/db';
4
+ import { Logger } from '@proteinjs/logger';
4
5
  import { User, tables, guestUser, USER_SESSION_CACHE_KEY } from '@proteinjs/user';
5
6
  import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
6
7
 
8
+ const logger = new Logger({ name: 'userCache' });
9
+
7
10
  export const userCache: SessionDataCache<User> = {
8
11
  key: USER_SESSION_CACHE_KEY,
9
12
  create: async (sessionId: string, userEmail: string): Promise<User> => {
@@ -23,8 +26,28 @@ export const userCache: SessionDataCache<User> = {
23
26
  };
24
27
  user = adminUser;
25
28
  } else {
26
- user = await getDbAsSystem().get(tables.User, { email: userEmail.toLowerCase() });
27
- delete (user as any)['password'];
29
+ const accountUser = await getDbAsSystem().get(tables.User, { email: userEmail.toLowerCase() });
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) {
39
+ delete (accountUser as any)['password'];
40
+ user = accountUser;
41
+ } else {
42
+ // A session can outlive its account (row deleted, or a dev auto-login for a never-created
43
+ // email). Resolve it to the unauthenticated guest session — the client sees no
44
+ // authenticated user and re-logs. Throwing here escapes the per-request session-cache
45
+ // build as an unhandled rejection and downs the process.
46
+ logger.warn({
47
+ message: `Session references an account that does not exist; resolving as unauthenticated`,
48
+ obj: { sessionId, userEmail },
49
+ });
50
+ }
28
51
  }
29
52
  }
30
53
 
@@ -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,28 +1,66 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { Logger } from '@proteinjs/logger';
3
+ import { Signup } from '../services/Signup';
3
4
 
4
5
  const logger = new Logger({ name: 'devLogin' });
5
6
 
7
+ /** Lowercased domain of an email address (the whole string when there is no `@`, which can never
8
+ * equal a real env domain — so malformed params fall out at the domain rail). */
9
+ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
10
+
6
11
  /**
7
- * DEV-ONLY session bootstrap: `GET /dev/login` establishes a session for the account named by
8
- * `DEV_AUTO_LOGIN_EMAIL` — no credentials involved — so automated dev-loop testing (agent-driven
9
- * browsers) can self-serve a session instead of stalling on the login form every time a cookie
10
- * is lost.
12
+ * DEV-ONLY session bootstrap: `GET /dev/login` establishes a session — no credentials involved —
13
+ * so automated dev-loop testing (agent-driven browsers) can self-serve a session instead of
14
+ * stalling on the login form. `?email=<addr>` selects the session's account so parallel
15
+ * verification can fan out over distinct users; absent, the `DEV_AUTO_LOGIN_EMAIL` account is
16
+ * used as before.
11
17
  *
12
18
  * Double-gated, acts only when BOTH hold; otherwise the path answers 404 as if unregistered:
13
19
  * 1. `process.env.DEVELOPMENT` — the dev-server switch, never set in prod images.
14
- * 2. `DEV_AUTO_LOGIN_EMAIL` — explicit per-launch opt-in naming exactly one account.
20
+ * 2. `DEV_AUTO_LOGIN_EMAIL` — explicit per-launch opt-in naming the default account.
21
+ *
22
+ * Domain rail: `?email` must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server must not
23
+ * mint sessions (much less accounts) for arbitrary domains; anything else answers 400.
24
+ *
25
+ * A missing account is created through the normal signup creation path (`Signup.createAccount`)
26
+ * as a normal test user — password `test`, matching the seeded test-account convention, so
27
+ * interactive login works for the same identity. Composes with userCache's missing-account→guest
28
+ * seam: that covers sessions whose account was deleted AFTER minting; this ensures dev-minted
29
+ * sessions reference a real account from the start.
15
30
  */
16
31
  export const devLogin: Route = {
17
32
  path: '/dev/login',
18
33
  method: 'get',
19
34
  onRequest: async (request: any, response): Promise<void> => {
20
- const email = (process.env.DEV_AUTO_LOGIN_EMAIL ?? '').trim();
21
- if (!process.env.DEVELOPMENT || !email) {
35
+ const envEmail = (process.env.DEV_AUTO_LOGIN_EMAIL ?? '').trim();
36
+ if (!process.env.DEVELOPMENT || !envEmail) {
22
37
  response.status(404).send();
23
38
  return;
24
39
  }
25
40
 
41
+ let email = envEmail;
42
+ const emailParam = request.query?.email;
43
+ if (emailParam !== undefined) {
44
+ const requested = typeof emailParam === 'string' ? emailParam.trim() : '';
45
+ if (emailDomain(requested) !== emailDomain(envEmail)) {
46
+ response.status(400).send(`/dev/login only accepts accounts on the @${emailDomain(envEmail)} domain`);
47
+ return;
48
+ }
49
+ email = requested;
50
+ }
51
+ email = email.toLowerCase();
52
+
53
+ const creation = await new Signup().createAccount({
54
+ name: email.slice(0, email.indexOf('@')),
55
+ email,
56
+ password: 'test',
57
+ emailVerified: false, // same shape an inviteless signup produces
58
+ invitedBy: null,
59
+ });
60
+ if (creation === 'created') {
61
+ logger.info({ message: 'Dev auto-login created missing test account', obj: { email } });
62
+ }
63
+
26
64
  await new Promise((resolve) => request.login(email, resolve));
27
65
  // Explicit save before redirecting: with a DB-backed session store, save-on-response-end
28
66
  // races the redirected GET / — the follow-up request can read the session row before the
@@ -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
  });