@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.
- package/CHANGELOG.md +19 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +9 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/src/authentication/PasswordHasher.d.ts +45 -0
- package/dist/src/authentication/PasswordHasher.d.ts.map +1 -0
- package/dist/src/authentication/PasswordHasher.js +152 -0
- package/dist/src/authentication/PasswordHasher.js.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts +19 -0
- package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -0
- package/dist/src/authentication/UserStatusTableWatcher.js +115 -0
- package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -0
- package/dist/src/authentication/authenticate.d.ts.map +1 -1
- package/dist/src/authentication/authenticate.js +41 -13
- package/dist/src/authentication/authenticate.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +10 -1
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts +35 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js +36 -0
- package/dist/src/emails/AccountDeletionEmailConfigs.js.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts +13 -0
- package/dist/src/emails/AccountDeletionEmails.d.ts.map +1 -0
- package/dist/src/emails/AccountDeletionEmails.js +98 -0
- package/dist/src/emails/AccountDeletionEmails.js.map +1 -0
- package/dist/src/routes/executePasswordReset.js +5 -3
- package/dist/src/routes/executePasswordReset.js.map +1 -1
- package/dist/src/routes/login.d.ts.map +1 -1
- package/dist/src/routes/login.js +26 -2
- package/dist/src/routes/login.js.map +1 -1
- package/dist/src/services/AccountDeletion.d.ts +52 -0
- package/dist/src/services/AccountDeletion.d.ts.map +1 -0
- package/dist/src/services/AccountDeletion.js +426 -0
- package/dist/src/services/AccountDeletion.js.map +1 -0
- package/dist/src/services/MachineCredentials.d.ts +21 -0
- package/dist/src/services/MachineCredentials.d.ts.map +1 -0
- package/dist/src/services/MachineCredentials.js +164 -0
- package/dist/src/services/MachineCredentials.js.map +1 -0
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +7 -0
- package/dist/src/services/Roles.js.map +1 -1
- package/dist/src/services/SetUserStatus.d.ts +18 -0
- package/dist/src/services/SetUserStatus.d.ts.map +1 -0
- package/dist/src/services/SetUserStatus.js +118 -0
- package/dist/src/services/SetUserStatus.js.map +1 -0
- package/dist/src/services/Signup.d.ts +2 -2
- package/dist/src/services/Signup.d.ts.map +1 -1
- package/dist/src/services/Signup.js +22 -17
- package/dist/src/services/Signup.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +20 -17
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/AccountDeletion.integration.test.d.ts +2 -0
- package/dist/test/AccountDeletion.integration.test.d.ts.map +1 -0
- package/dist/test/AccountDeletion.integration.test.js +710 -0
- package/dist/test/AccountDeletion.integration.test.js.map +1 -0
- package/dist/test/DevLogin.test.js +12 -11
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/MachineAccounts.integration.test.d.ts +2 -0
- package/dist/test/MachineAccounts.integration.test.d.ts.map +1 -0
- package/dist/test/MachineAccounts.integration.test.js +597 -0
- package/dist/test/MachineAccounts.integration.test.js.map +1 -0
- package/dist/test/PasswordMigration.integration.test.d.ts +2 -0
- package/dist/test/PasswordMigration.integration.test.d.ts.map +1 -0
- package/dist/test/PasswordMigration.integration.test.js +332 -0
- package/dist/test/PasswordMigration.integration.test.js.map +1 -0
- package/dist/test/SetUserStatus.integration.test.d.ts +2 -0
- package/dist/test/SetUserStatus.integration.test.d.ts.map +1 -0
- package/dist/test/SetUserStatus.integration.test.js +266 -0
- package/dist/test/SetUserStatus.integration.test.js.map +1 -0
- package/dist/test/UserStatusGate.integration.test.d.ts +2 -0
- package/dist/test/UserStatusGate.integration.test.d.ts.map +1 -0
- package/dist/test/UserStatusGate.integration.test.js +162 -0
- package/dist/test/UserStatusGate.integration.test.js.map +1 -0
- package/generated/index.ts +9 -1
- package/index.ts +3 -0
- package/package.json +8 -6
- package/src/authentication/PasswordHasher.ts +87 -0
- package/src/authentication/UserStatusTableWatcher.ts +56 -0
- package/src/authentication/authenticate.ts +31 -6
- package/src/authorization/userCache.ts +9 -1
- package/src/emails/AccountDeletionEmailConfigs.ts +79 -0
- package/src/emails/AccountDeletionEmails.ts +39 -0
- package/src/routes/executePasswordReset.ts +2 -2
- package/src/routes/login.ts +23 -0
- package/src/services/AccountDeletion.ts +291 -0
- package/src/services/MachineCredentials.ts +94 -0
- package/src/services/Roles.ts +10 -0
- package/src/services/SetUserStatus.ts +56 -0
- package/src/services/Signup.ts +4 -4
- package/src/services/UpdateUserInfo.ts +4 -5
- package/test/AccountDeletion.integration.test.ts +391 -0
- package/test/DevLogin.test.ts +5 -4
- package/test/MachineAccounts.integration.test.ts +312 -0
- package/test/PasswordMigration.integration.test.ts +132 -0
- package/test/SetUserStatus.integration.test.ts +120 -0
- 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
|
+
}
|
package/src/services/Roles.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/services/Signup.ts
CHANGED
|
@@ -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 +
|
|
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:
|
|
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 (
|
|
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 =
|
|
72
|
+
const hashedNewPassword = await new PasswordHasher().hash(newPassword);
|
|
74
73
|
await this.saveUserInfo({ password: hashedNewPassword });
|
|
75
74
|
} catch (error: any) {
|
|
76
75
|
return {
|