feat: add grace period for account deletions

This commit is contained in:
Jonathan Barrow 2026-01-20 21:00:35 -05:00
parent d99d02cb0a
commit 9838fb314f
No known key found for this signature in database
GPG Key ID: 2A7DAA6DED5A77E5
23 changed files with 107 additions and 27 deletions

41
package-lock.json generated
View File

@ -18,6 +18,7 @@
"colors": "^1.4.0",
"cors": "^2.8.5",
"crc": "^4.3.2",
"cron": "^4.4.0",
"dicer": "^0.2.5",
"dotenv": "^16.0.3",
"ejs": "^3.1.10",
@ -2383,7 +2384,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3400,6 +3400,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -3429,7 +3435,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.112.tgz",
"integrity": "sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -3575,7 +3580,6 @@
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
@ -4061,7 +4065,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5070,6 +5073,23 @@
}
}
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@ -5855,7 +5875,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -6002,7 +6021,6 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -8122,6 +8140,15 @@
"node": ">=8"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.1.tgz",
@ -8440,7 +8467,6 @@
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.7.tgz",
"integrity": "sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bson": "^5.5.0",
"kareem": "2.5.1",
@ -10679,7 +10705,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -34,6 +34,7 @@
"colors": "^1.4.0",
"cors": "^2.8.5",
"crc": "^4.3.2",
"cron": "^4.4.0",
"dicer": "^0.2.5",
"dotenv": "^16.0.3",
"ejs": "^3.1.10",

View File

@ -339,3 +339,17 @@ export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): P
status: 200
};
}
export async function checkMarkedDeletions(): Promise<void> {
const pnids = await PNID.find({
marked_for_deletion: true,
deleted: false,
hard_delete_time: {
$lte: new Date()
}
});
for (const pnid of pnids) {
await pnid.scrub();
}
}

View File

@ -53,7 +53,7 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
return;
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {

View File

@ -33,6 +33,11 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
type: Boolean,
default: false
},
marked_for_deletion: {
type: Boolean,
default: false
},
hard_delete_time: Date,
permissions: {
type: BigInt,
default: 0n
@ -225,6 +230,11 @@ PNIDSchema.method('generateMiiImages', async function generateMiiImages(): Promi
await uploadCDNAsset(config.s3.bucket, `${userMiiKey}/body.png`, miiStudioBodyImageData, 'public-read');
});
PNIDSchema.method('markForDeletion', async function markForDeletion() {
this.marked_for_deletion = true;
this.hard_delete_time = new Date(Date.now() + (7 * 24 * 3600 * 1000)); // * 7 day grace period
});
PNIDSchema.method('scrub', async function scrub() {
// * Remove all personal info from a PNID
// * Username and PID remain so thye do not get assigned again
@ -287,6 +297,7 @@ PNIDSchema.method('scrub', async function scrub() {
});
this.deleted = true;
this.marked_for_deletion = false;
this.access_level = 0;
this.server_access_level = 'prod';
this.creation_date = '';

View File

@ -3,7 +3,7 @@ import morgan from 'morgan';
import xmlbuilder from 'xmlbuilder';
import xmlparser from '@/middleware/xml-parser';
import { connect as connectCache } from '@/cache';
import { connect as connectDatabase } from '@/database';
import { checkMarkedDeletions, connect as connectDatabase } from '@/database';
import { startGRPCServer } from '@/services/grpc/server';
import { fullUrl, getValueFromHeaders } from '@/util';
import { LOG_INFO, LOG_SUCCESS, LOG_WARN } from '@/logger';
@ -116,6 +116,8 @@ async function main(): Promise<void> {
startProvisioner();
await checkMarkedDeletions();
app.listen(config.http.port, () => {
LOG_SUCCESS(`HTTP server started on port ${config.http.port}`);
});

View File

@ -102,7 +102,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
response.status(400).json({
app: 'api',
status: 400,

View File

@ -17,7 +17,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
}
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@ -15,7 +15,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
}
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@ -17,7 +17,7 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
try {
const email = pnid.email.address;
await pnid.scrub();
pnid.markForDeletion();
await pnid.save();
await sendPNIDDeletedEmail(email, pnid.username);
@ -26,6 +26,6 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
}
return {
hasDeleted: pnid.deleted
hasDeleted: pnid.deleted || pnid.marked_for_deletion
};
}

View File

@ -30,7 +30,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
});
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@ -28,7 +28,7 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
});
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,

View File

@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@ -52,7 +52,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}

View File

@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
// TODO - STUBBED, DO SOMETHING HERE
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@ -17,7 +17,7 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
try {
const email = pnid.email.address;
await pnid.scrub();
pnid.markForDeletion();
await pnid.save();
await sendPNIDDeletedEmail(email, pnid.username);
@ -26,6 +26,6 @@ export async function deleteAccount(request: DeleteAccountRequest): Promise<Dele
}
return {
hasDeleted: pnid.deleted
hasDeleted: pnid.deleted || pnid.marked_for_deletion
};
}

View File

@ -9,7 +9,7 @@ export async function getUserData(_request: Empty, context: CallContext & Authen
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@ -52,7 +52,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}

View File

@ -11,7 +11,7 @@ export async function updateUserData(_request: UpdateUserDataRequest, context: C
// TODO - STUBBED, DO SOMETHING HERE
return {
deleted: pnid.deleted,
deleted: pnid.deleted || pnid.marked_for_deletion,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,

View File

@ -128,7 +128,7 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
}
}
if (pnid.deleted) {
if (pnid.deleted || pnid.marked_for_deletion) {
// * 0112 is the "account deleted" error, but unsure if this unlinks the PNID from the user?
// * 0143 is the "The link to this Nintendo Network ID has been temporarliy removed" error,
// * maybe that is a better error to use here?

View File

@ -561,7 +561,7 @@ router.post('/@me/deletion', async (request: express.Request, response: express.
const email = pnid.email.address;
await pnid.scrub();
pnid.markForDeletion();
await pnid.save();
try {

View File

@ -4,6 +4,8 @@ import type { PNIDPermissionFlag } from '@/types/common/permission-flags';
export interface IPNID {
deleted: boolean;
marked_for_deletion: boolean;
hard_delete_time: Date;
permissions: bigint;
access_level: number;
server_access_level: string;
@ -81,6 +83,7 @@ export interface IPNIDMethods {
generateEmailValidationToken(): Promise<void>;
updateMii(mii: { name: string; primary: string; data: string }): Promise<void>;
generateMiiImages(): Promise<void>;
markForDeletion(): void;
scrub(): Promise<void>;
hasPermission(flag: PNIDPermissionFlag): boolean;
addPermission(flag: PNIDPermissionFlag): void;

View File

@ -4,10 +4,13 @@ import { S3 } from '@aws-sdk/client-s3';
import fs from 'fs-extra';
import bufferCrc32 from 'buffer-crc32';
import { crc32 } from 'crc';
import { CronJob } from 'cron';
import { checkMarkedDeletions } from '@/database';
import { sendMail, CreateEmail } from '@/mailer';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
import { config, disabledFeatures } from '@/config-manager';
import { LOG_ERROR } from '@/logger';
import type { ParsedQs } from 'qs';
import type mongoose from 'mongoose';
import type express from 'express';
@ -392,3 +395,24 @@ export function getAgeFromDate(dateString: string): number {
return age;
}
export async function setupScheduledTasks(): Promise<void> {
scheduledTask('0 2 * * *', 'check-account-deletions', checkMarkedDeletions);
}
function scheduledTask(schedule: string, name: string, fn: () => void | Promise<void>): void {
CronJob.from({
cronTime: schedule,
onTick: async () => {
try {
const result = fn();
await result;
} catch (err) {
LOG_ERROR(`Error in schedule ${name}: ${err}`);
}
},
start: true
});
LOG_ERROR(`Added schedule ${name} for ${schedule}`);
}