@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,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
+ }
@@ -0,0 +1,94 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import {
4
+ MachineAccountView,
5
+ MachineCredentialsService,
6
+ MintedMachineCredential,
7
+ USER_PERMISSIONS,
8
+ getMachineAccounts,
9
+ tables,
10
+ } from '@proteinjs/user';
11
+ import { Logger } from '@proteinjs/logger';
12
+ import { Service } from '@proteinjs/service';
13
+ import { PasswordHasher } from '../authentication/PasswordHasher';
14
+
15
+ /**
16
+ * Credential minting for code-declared machine accounts — the ONLY manual step in a machine
17
+ * account's life (identity and grants come from the `MachineAccount` declaration at boot).
18
+ *
19
+ * Generates a strong random password (never human-chosen), stores its hash on the account row
20
+ * via PasswordHasher's machine mode — sha256, deliberately not the argon2id human KDF: a
21
+ * generated 256-bit secret gains nothing from key stretching, and the bridge logs in fresh per
22
+ * poll, so verifies stay cheap. (The plaintext is never persisted or logged.) Kills the
23
+ * account's existing sessions (the old
24
+ * credential dies with them), and returns the plaintext ONCE for pasting into the declaration's
25
+ * Secret Manager secret. The same call rotates. Machine rows only (`is_loaded_from_source`);
26
+ * human credentials go through the password-reset flow.
27
+ */
28
+ export class MachineCredentials implements MachineCredentialsService {
29
+ public serviceMetadata: Service['serviceMetadata'] = {
30
+ auth: {
31
+ permission: USER_PERMISSIONS.users,
32
+ },
33
+ };
34
+
35
+ async listMachineAccounts(): Promise<MachineAccountView[]> {
36
+ const db = getDbAsSystem();
37
+ const views: MachineAccountView[] = [];
38
+ for (const declaration of getMachineAccounts()) {
39
+ const row = await db.get(tables.User, { email: declaration.email });
40
+ const booted = !!row && row.isLoadedFromSource === true;
41
+ views.push({
42
+ email: declaration.email,
43
+ accountName: declaration.accountName,
44
+ roles: [...declaration.roles],
45
+ secretName: declaration.secretName,
46
+ status: booted ? (row.status === 'deactivated' ? 'deactivated' : 'active') : 'pending first boot',
47
+ hasCredential: booted && !!row.password,
48
+ });
49
+ }
50
+
51
+ return views;
52
+ }
53
+
54
+ async mintCredential(email: string): Promise<MintedMachineCredential> {
55
+ const logger = new Logger({ name: 'MachineCredentials.mintCredential' });
56
+ const normalizedEmail = email?.toLowerCase();
57
+ const declaration = getMachineAccounts().find((machineAccount) => machineAccount.email === normalizedEmail);
58
+ if (!declaration) {
59
+ throw new Error(
60
+ `No machine account is declared for '${email}'. Credentials can only be minted for ` +
61
+ `code-declared machine accounts (MachineAccount declarations).`
62
+ );
63
+ }
64
+
65
+ const db = getDbAsSystem();
66
+ const user = await db.get(tables.User, { email: normalizedEmail });
67
+ if (!user || user.isLoadedFromSource !== true) {
68
+ throw new Error(
69
+ `The machine account row for '${normalizedEmail}' has not been loaded from source yet — ` +
70
+ `the boot sync creates (or adopts) it. Boot the server with the declaration, then mint.`
71
+ );
72
+ }
73
+
74
+ // 256 bits of entropy; standard API-key UX — generated, shown once, hash-only at rest.
75
+ const password = randomBytes(32).toString('hex');
76
+ await db.update(tables.User, { id: user.id, password: await new PasswordHasher().hash(password, 'machine') });
77
+ // Rotation kills the old credential's sessions immediately (the bridge logs in fresh per
78
+ // poll, so this is cheap for it — the mechanism is the categorical one).
79
+ await db.delete(tables.Session, { userEmail: normalizedEmail });
80
+ logger.info({
81
+ message: `Minted machine credential`,
82
+ obj: { email: normalizedEmail, secretName: declaration.secretName },
83
+ });
84
+
85
+ return {
86
+ email: normalizedEmail,
87
+ password,
88
+ secretName: declaration.secretName,
89
+ note:
90
+ `Shown once. Paste this password into the '${declaration.secretName}' Secret Manager ` +
91
+ `secret, then restart the service that reads it — the previous credential is already invalid.`,
92
+ };
93
+ }
94
+ }
@@ -36,6 +36,16 @@ export class Roles implements RolesService {
36
36
  throw new Error(`No user found for id: ${userId}`);
37
37
  }
38
38
 
39
+ // Machine grants live in the MachineAccount declaration and reconcile at boot — git history
40
+ // is their audit ledger, role_grant_event is the HUMAN ledger, and the two never interleave
41
+ // on one row. A runtime grant here would also just be reverted by the next boot.
42
+ if (user.isLoadedFromSource === true) {
43
+ throw new Error(
44
+ `'${user.email}' is a machine account: its roles are declared in code (its MachineAccount ` +
45
+ `declaration) and reverted to the declaration on every boot. Change the declaration instead.`
46
+ );
47
+ }
48
+
39
49
  const roles = user.roles ?? [];
40
50
  if (action === 'grant' ? roles.includes(role) : !roles.includes(role)) {
41
51
  // No change, no audit row: the trail records what happened, not what was re-asked.
@@ -0,0 +1,56 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { SetUserStatusService, USER_STATUSES, UserRepo, UserStatus, tables } from '@proteinjs/user';
3
+ import { Logger } from '@proteinjs/logger';
4
+ import { Service } from '@proteinjs/service';
5
+
6
+ /**
7
+ * The ONE write path for a user's account standing (the `user.status` column is
8
+ * service-protected — generic record writes cannot touch it). The status update and its
9
+ * `user_status_event` audit row (actor, target, status; `created` is the timestamp) commit in
10
+ * one transaction, so the trail cannot diverge from the standing.
11
+ *
12
+ * Admin-only by explicit scope: deactivation locks the target out of the product (login refused,
13
+ * live sessions stop resolving via userCache), so the door is the break-glass role, not a mapped
14
+ * permission. This method only flips standing — `deleteRequestedAt`/`purgeAfter` belong to the
15
+ * account-deletion flow.
16
+ */
17
+ export class SetUserStatus implements SetUserStatusService {
18
+ public serviceMetadata: Service['serviceMetadata'] = {
19
+ auth: {
20
+ roles: ['admin'],
21
+ },
22
+ };
23
+
24
+ async setUserStatus(userId: string, status: UserStatus): Promise<void> {
25
+ const logger = new Logger({ name: 'SetUserStatus.setUserStatus' });
26
+ if (!USER_STATUSES.includes(status)) {
27
+ throw new Error(`'${status}' is not a known user status. Pick one of: ${USER_STATUSES.join(', ')}.`);
28
+ }
29
+
30
+ const db = getDbAsSystem();
31
+ const user = await db.get(tables.User, { id: userId });
32
+ if (!user) {
33
+ throw new Error(`No user found for id: ${userId}`);
34
+ }
35
+
36
+ // Rows predating the status column read null, which every gate treats as active.
37
+ if ((user.status ?? 'active') === status) {
38
+ // No change, no audit row: the trail records what happened, not what was re-asked.
39
+ return;
40
+ }
41
+
42
+ const actor = new UserRepo().getUser();
43
+ await db.runTransaction(async () => {
44
+ await db.update(tables.User, { id: userId, status });
45
+ await db.insert(tables.UserStatusEvent, {
46
+ actor: actor.id,
47
+ target: userId,
48
+ status,
49
+ });
50
+ });
51
+ logger.info({
52
+ message: `User status set to ${status}`,
53
+ obj: { actor: actor.id, target: userId, status },
54
+ });
55
+ }
56
+ }
@@ -20,8 +20,8 @@ import {
20
20
  getDefaultInviteEmailConfigFactory,
21
21
  getDefaultSignupConfirmationEmailConfigFactory,
22
22
  } from '@proteinjs/email-server';
23
- import sha256 from 'crypto-js/sha256';
24
23
  import { Loadable, SourceRepository } from '@proteinjs/reflection';
24
+ import { PasswordHasher } from '../authentication/PasswordHasher';
25
25
 
26
26
  /**
27
27
  * How long an invite link stays usable. Deliberately generous: a legitimate invite that gets
@@ -256,8 +256,8 @@ export class Signup implements SignupService {
256
256
  }
257
257
 
258
258
  /**
259
- * Single owner of account-record creation: case-normalized existence check + sha256-hashed
260
- * insert. Both doors into a user row go through here — the signup flow (`createUser`, which
259
+ * Single owner of account-record creation: case-normalized existence check + argon2id-hashed
260
+ * insert (PasswordHasher). Both doors into a user row go through here — the signup flow (`createUser`, which
261
261
  * layers invite validation and confirmation emails on top) and the dev-login bootstrap
262
262
  * (`devLogin`, which auto-creates missing same-domain test accounts). Not exposed as an RPC:
263
263
  * the service surface is the `SignupService` INTERFACE (ServiceRouter walks its declared
@@ -280,7 +280,7 @@ export class Signup implements SignupService {
280
280
  await db.insert(tables.User, {
281
281
  name: account.name,
282
282
  email,
283
- password: sha256(account.password).toString(),
283
+ password: await new PasswordHasher().hash(account.password),
284
284
  emailVerified: account.emailVerified,
285
285
  roles: [],
286
286
  invitedBy: account.invitedBy,
@@ -1,10 +1,10 @@
1
1
  import sharp from 'sharp';
2
2
  import heicDecode from 'heic-decode';
3
- import sha256 from 'crypto-js/sha256';
4
3
  import { getDbAsSystem } from '@proteinjs/db';
5
4
  import { FileStorage } from '@proteinjs/db-file';
6
5
  import { tables, User, UserRepo, UpdatePasswordResponse, UpdatedUser, UpdateUserInfoService } from '@proteinjs/user';
7
6
  import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
7
+ import { PasswordHasher } from '../authentication/PasswordHasher';
8
8
 
9
9
  export class UpdateUserInfo implements UpdateUserInfoService {
10
10
  /** Stored avatar photos are exactly this: cover-cropped square JPEG. */
@@ -37,10 +37,9 @@ export class UpdateUserInfo implements UpdateUserInfoService {
37
37
  const db = getDbAsSystem();
38
38
  const userId = new UserRepo().getUser().id;
39
39
 
40
- // verify current password
41
- const hashedCurrentPassword = sha256(currentPassword).toString();
40
+ // verify current password (format-discriminating: legacy sha256 rows verify too)
42
41
  const user = await db.get(tables.User, { id: userId });
43
- if (hashedCurrentPassword !== user.password) {
42
+ if (!(await new PasswordHasher().verify(user.password, currentPassword))) {
44
43
  return {
45
44
  updated: false,
46
45
  error: `Invalid password`,
@@ -70,7 +69,7 @@ export class UpdateUserInfo implements UpdateUserInfoService {
70
69
  // If email is sent successfully,
71
70
  // hash and store new password
72
71
  try {
73
- const hashedNewPassword = sha256(newPassword).toString();
72
+ const hashedNewPassword = await new PasswordHasher().hash(newPassword);
74
73
  await this.saveUserInfo({ password: hashedNewPassword });
75
74
  } catch (error: any) {
76
75
  return {