From 0a48ffce61dd750e3d9e750b6869cf3f402e4be1 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 25 Aug 2026 18:06:27 +0900 Subject: [PATCH] feat(account-removal): public removal-request endpoint with server math challenge GET /v1/account-removal/challenge issues a stateless HMAC-signed math problem (10 min expiry, key derived from the JWT secret); POST /request verifies the answer server-side, then emails kevin@ and sayaka@ via SMTP (fail-closed 503 when unconfigured). Anonymous callers are IP-throttled per route (challenge 10/min, request 3/min). Co-Authored-By: Claude Fable 5 --- package.json | 2 + pnpm-lock.yaml | 19 +++ src/app.module.ts | 2 + .../account-removal.controller.ts | 54 ++++++ .../account-removal/account-removal.module.ts | 11 ++ .../account-removal.service.spec.ts | 119 ++++++++++++++ .../account-removal.service.ts | 154 ++++++++++++++++++ .../dto/request-account-removal.dto.ts | 27 +++ 8 files changed, 388 insertions(+) create mode 100644 src/v1/account-removal/account-removal.controller.ts create mode 100644 src/v1/account-removal/account-removal.module.ts create mode 100644 src/v1/account-removal/account-removal.service.spec.ts create mode 100644 src/v1/account-removal/account-removal.service.ts create mode 100644 src/v1/account-removal/dto/request-account-removal.dto.ts diff --git a/package.json b/package.json index 4899b6d..55aa2ad 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "helmet": "^8.1.0", + "nodemailer": "^9.0.5", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -52,6 +53,7 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^22.19.17", + "@types/nodemailer": "^8.0.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.3", "eslint": "^9.39.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d8779..05daf9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: helmet: specifier: ^8.1.0 version: 8.1.0 + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -96,6 +99,9 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 @@ -1033,6 +1039,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} @@ -2692,6 +2701,10 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4602,6 +4615,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 22.19.17 + '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.10 @@ -6437,6 +6454,8 @@ snapshots: node-releases@2.0.37: {} + nodemailer@9.0.5: {} + normalize-path@3.0.0: {} npm-normalize-package-bin@5.0.0: {} diff --git a/src/app.module.ts b/src/app.module.ts index ac54374..b1a37be 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { DashboardModule } from './v1/dashboard/dashboard.module'; import { PaymentsModule } from './v1/payments/payments.module'; import { LineModule } from './v1/line/line.module'; import { PushModule } from './v1/push/push.module'; +import { AccountRemovalModule } from './v1/account-removal/account-removal.module'; import { CropwatchMcpModule } from './v1/mcp/mcp.module'; @Module({ @@ -72,6 +73,7 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; PaymentsModule, LineModule, PushModule, + AccountRemovalModule, CropwatchMcpModule, ], controllers: [AppController], diff --git a/src/v1/account-removal/account-removal.controller.ts b/src/v1/account-removal/account-removal.controller.ts new file mode 100644 index 0000000..9fca442 --- /dev/null +++ b/src/v1/account-removal/account-removal.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { + AccountRemovalService, + type AccountRemovalChallenge, +} from './account-removal.service'; +import { RequestAccountRemovalDto } from './dto/request-account-removal.dto'; + +// Public, unauthenticated endpoints. Anonymous callers are IP-keyed by +// UserThrottlerGuard (trust proxy is pinned in main.ts), so these tight +// per-route limits track the real client, not Vercel's shared egress — +// provided the browser calls the API directly rather than via SSR. +@ApiTags('account-removal') +@Controller({ path: 'account-removal', version: '1' }) +export class AccountRemovalController { + constructor( + private readonly accountRemovalService: AccountRemovalService, + ) {} + + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + @Get('challenge') + @ApiOperation({ + summary: 'Issue a human-verification math challenge (public)', + }) + getChallenge(): AccountRemovalChallenge { + return this.accountRemovalService.createChallenge(); + } + + @Throttle({ default: { ttl: 60_000, limit: 3 } }) + @Post('request') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: + 'Submit an account removal request (public; emails the operators)', + }) + async submitRequest( + @Body() body: RequestAccountRemovalDto, + ): Promise<{ requested: boolean }> { + this.accountRemovalService.verifyChallenge(body.answer, body.token); + await this.accountRemovalService.sendRemovalRequest( + body.email, + body.message, + ); + return { requested: true }; + } +} diff --git a/src/v1/account-removal/account-removal.module.ts b/src/v1/account-removal/account-removal.module.ts new file mode 100644 index 0000000..e57dbc6 --- /dev/null +++ b/src/v1/account-removal/account-removal.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AccountRemovalController } from './account-removal.controller'; +import { AccountRemovalService } from './account-removal.service'; + +@Module({ + imports: [ConfigModule], + controllers: [AccountRemovalController], + providers: [AccountRemovalService], +}) +export class AccountRemovalModule {} diff --git a/src/v1/account-removal/account-removal.service.spec.ts b/src/v1/account-removal/account-removal.service.spec.ts new file mode 100644 index 0000000..ceb2b27 --- /dev/null +++ b/src/v1/account-removal/account-removal.service.spec.ts @@ -0,0 +1,119 @@ +import { + BadRequestException, + ServiceUnavailableException, +} from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { AccountRemovalService } from './account-removal.service'; + +const sendMailMock = jest.fn(); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ sendMail: sendMailMock })), +})); + +function buildService( + overrides: Record = {}, +): AccountRemovalService { + const values: Record = { + PRIVATE_SUPABASE_JWT_SECRET: 'test-secret', + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: '465', + SMTP_USER: 'noreply@example.com', + SMTP_PASS: 'hunter2', + ...overrides, + }; + const configService = { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; + return new AccountRemovalService(configService); +} + +describe('AccountRemovalService', () => { + beforeEach(() => { + sendMailMock.mockReset(); + sendMailMock.mockResolvedValue(undefined); + }); + + describe('challenge', () => { + it('issues a solvable challenge that verifies with the correct answer', () => { + const service = buildService(); + const challenge = service.createChallenge(); + + const [a, b] = challenge.question.split(' + ').map(Number); + expect(Number.isInteger(a)).toBe(true); + expect(Number.isInteger(b)).toBe(true); + + expect(() => service.verifyChallenge(a + b, challenge.token)).not.toThrow(); + }); + + it('rejects a wrong answer', () => { + const service = buildService(); + const challenge = service.createChallenge(); + const [a, b] = challenge.question.split(' + ').map(Number); + + expect(() => service.verifyChallenge(a + b + 1, challenge.token)).toThrow( + BadRequestException, + ); + }); + + it('rejects an expired token', () => { + const service = buildService(); + const challenge = service.createChallenge(); + const [a, b] = challenge.question.split(' + ').map(Number); + const [, signature] = challenge.token.split('.'); + const expiredToken = `${Date.now() - 1000}.${signature}`; + + expect(() => service.verifyChallenge(a + b, expiredToken)).toThrow( + /expired/i, + ); + }); + + it('rejects a malformed token', () => { + const service = buildService(); + expect(() => service.verifyChallenge(4, 'not-a-token')).toThrow( + BadRequestException, + ); + }); + + it('fails closed when the signing secret is missing', () => { + const service = buildService({ PRIVATE_SUPABASE_JWT_SECRET: undefined }); + expect(() => service.createChallenge()).toThrow( + ServiceUnavailableException, + ); + }); + }); + + describe('sendRemovalRequest', () => { + it('emails both operators with the requester email and message', async () => { + const service = buildService(); + await service.sendRemovalRequest('leaving@example.com', ' bye now '); + + expect(sendMailMock).toHaveBeenCalledTimes(1); + const args = sendMailMock.mock.calls[0][0] as { + to: string[]; + subject: string; + text: string; + }; + expect(args.to).toEqual(['kevin@cropwatch.io', 'sayaka@cropwatch.io']); + expect(args.subject).toContain('leaving@example.com'); + expect(args.text).toContain('leaving@example.com'); + expect(args.text).toContain('bye now'); + }); + + it('fails closed when SMTP is not configured', async () => { + const service = buildService({ SMTP_HOST: undefined }); + await expect( + service.sendRemovalRequest('leaving@example.com'), + ).rejects.toThrow(ServiceUnavailableException); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('maps transport failures to a 503 without leaking details', async () => { + sendMailMock.mockRejectedValueOnce(new Error('SMTP down')); + const service = buildService(); + await expect( + service.sendRemovalRequest('leaving@example.com'), + ).rejects.toThrow(ServiceUnavailableException); + }); + }); +}); diff --git a/src/v1/account-removal/account-removal.service.ts b/src/v1/account-removal/account-removal.service.ts new file mode 100644 index 0000000..8661dfd --- /dev/null +++ b/src/v1/account-removal/account-removal.service.ts @@ -0,0 +1,154 @@ +import { + createHmac, + randomInt, + timingSafeEqual, +} from 'crypto'; +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createTransport, type Transporter } from 'nodemailer'; + +export interface AccountRemovalChallenge { + question: string; + token: string; +} + +// Deliberately hardcoded: removal requests are read and actioned by these two +// humans, not by configuration. Change requires a code change on purpose. +const REQUEST_RECIPIENTS = ['kevin@cropwatch.io', 'sayaka@cropwatch.io']; + +const CHALLENGE_TTL_MS = 10 * 60 * 1000; +// Context string keeps the derived HMAC key distinct from every other use of +// the shared secret. +const CHALLENGE_KEY_CONTEXT = 'account-removal-challenge-v1'; + +/** + * Public "request account removal" flow: a stateless server-issued math + * challenge (HMAC over the expected answer + expiry — nothing stored), and an + * email to the operators once the challenge verifies. There is no account + * mutation here by design: removal itself stays a human action. + */ +@Injectable() +export class AccountRemovalService { + private readonly logger = new Logger(AccountRemovalService.name); + private transporter: Transporter | null = null; + + constructor(private readonly configService: ConfigService) {} + + createChallenge(): AccountRemovalChallenge { + const a = randomInt(2, 21); + const b = randomInt(2, 21); + const expiresAt = Date.now() + CHALLENGE_TTL_MS; + const signature = this.signAnswer(a + b, expiresAt); + return { + question: `${a} + ${b}`, + token: `${expiresAt}.${signature}`, + }; + } + + /** Throws BadRequestException unless the answer matches an unexpired token. */ + verifyChallenge(answer: number, token: string): void { + const [expiresAtRaw, signature] = token.split('.'); + const expiresAt = Number(expiresAtRaw); + if ( + !Number.isFinite(expiresAt) || + typeof signature !== 'string' || + signature.length === 0 + ) { + throw new BadRequestException('Invalid challenge token'); + } + if (Date.now() > expiresAt) { + throw new BadRequestException('Challenge expired — request a new one'); + } + if (!Number.isInteger(answer)) { + throw new BadRequestException('Incorrect answer'); + } + + const expected = Buffer.from(this.signAnswer(answer, expiresAt), 'hex'); + const provided = Buffer.from(signature, 'hex'); + if ( + expected.length === 0 || + expected.length !== provided.length || + !timingSafeEqual(expected, provided) + ) { + throw new BadRequestException('Incorrect answer'); + } + } + + async sendRemovalRequest(email: string, message?: string): Promise { + const transporter = this.getTransporter(); + const from = + this.configService.get('SMTP_FROM') ?? + this.configService.get('SMTP_USER'); + const now = new Date(); + + const lines = [ + 'An account removal request was submitted from the public form.', + '', + `Account email: ${email}`, + `Submitted at: ${now.toISOString()} (UTC)`, + ]; + if (message && message.trim().length > 0) { + lines.push('', 'Message from the requester:', message.trim()); + } + lines.push( + '', + 'This request only notifies you — no account data has been changed.', + ); + + try { + await transporter.sendMail({ + from, + to: REQUEST_RECIPIENTS, + subject: `Account removal request: ${email}`, + text: lines.join('\n'), + }); + } catch (error) { + this.logger.error(`Failed to send account removal request email`, error); + throw new ServiceUnavailableException( + 'Could not deliver the request — please try again later', + ); + } + } + + private signAnswer(answer: number, expiresAt: number): string { + const secret = this.configService.get( + 'PRIVATE_SUPABASE_JWT_SECRET', + ); + // Fail closed like LineService does on a missing webhook secret. + if (!secret) { + throw new ServiceUnavailableException( + 'Account removal challenges are not configured', + ); + } + return createHmac('sha256', `${CHALLENGE_KEY_CONTEXT}:${secret}`) + .update(`${answer}:${expiresAt}`) + .digest('hex'); + } + + private getTransporter(): Transporter { + if (this.transporter) return this.transporter; + + const host = this.configService.get('SMTP_HOST'); + const user = this.configService.get('SMTP_USER'); + const pass = this.configService.get('SMTP_PASS'); + if (!host || !user || !pass) { + throw new ServiceUnavailableException( + 'Email delivery is not configured', + ); + } + const port = Number(this.configService.get('SMTP_PORT') ?? '465'); + + this.transporter = createTransport({ + host, + port, + secure: port === 465, + auth: { user, pass }, + }); + return this.transporter; + } +} diff --git a/src/v1/account-removal/dto/request-account-removal.dto.ts b/src/v1/account-removal/dto/request-account-removal.dto.ts new file mode 100644 index 0000000..cd09252 --- /dev/null +++ b/src/v1/account-removal/dto/request-account-removal.dto.ts @@ -0,0 +1,27 @@ +import { + IsEmail, + IsInt, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class RequestAccountRemovalDto { + @IsEmail() + @MaxLength(254) + email!: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + message?: string; + + @Type(() => Number) + @IsInt() + answer!: number; + + @IsString() + @MaxLength(120) + token!: string; +}