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