feat(auth): add row/size caps + streaming to export-user-data

- Add per-collection row cap (default 10k, env EXPORT_ROW_CAP) via Prisma
  take on all findMany calls
- Add total size cap (default 100MB, env EXPORT_SIZE_CAP_MB); throws
  PayloadTooLargeException (413) when exceeded
- Convert response to Node.js Readable stream piped via NestJS StreamableFile
  to avoid large in-memory buffers
- Export ExportUserDataResult interface (stream + truncated flag) from handler
- Update controller to set Content-Type/Content-Disposition headers and
  return StreamableFile
- Document EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars in Swagger
- Extend tests: row-cap assertion (take arg), size-cap 413 path, stream assertions

Fixes GOO-223 (M-1 from GOO-200 audit).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:10:54 +07:00
parent b4bb05479e
commit fa3ba88f40
34 changed files with 1494 additions and 45 deletions

View File

@@ -0,0 +1,49 @@
import { type EventEnvelope, assertValidEnvelope } from '@goodgo/contracts-events';
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
import { PrismaService } from '../prisma.service';
/**
* Transactional outbox writer. Call inside the same Prisma transaction
* as the domain change so the row commits atomically with the state
* mutation it describes. The Outbox **never** publishes directly; the
* relay (`OutboxRelay`) tails `event_outbox` and forwards to the EventBus.
*/
export interface OutboxAppendOptions {
aggregateId?: string;
}
type EventOutboxDelegate = PrismaService['eventOutbox'];
type PrismaTxLike = Pick<EventOutboxDelegate, 'create'> | { eventOutbox: Pick<EventOutboxDelegate, 'create'> };
@Injectable()
export class OutboxService {
private readonly logger = new Logger(OutboxService.name);
constructor(private readonly prisma: PrismaService) {}
async append(
tx: PrismaTxLike | PrismaService,
envelope: EventEnvelope,
options: OutboxAppendOptions = {},
): Promise<void> {
assertValidEnvelope(envelope);
const client = ('eventOutbox' in tx ? tx.eventOutbox : tx) as EventOutboxDelegate;
await client.create({
data: {
eventId: envelope.eventId,
eventType: envelope.eventType,
aggregateId: options.aggregateId ?? null,
envelope: envelope as unknown as Prisma.InputJsonValue,
},
});
}
async appendStandalone(envelope: EventEnvelope, options: OutboxAppendOptions = {}): Promise<void> {
await this.append(this.prisma, envelope, options);
this.logger.warn(
`appendStandalone used for ${envelope.eventType} eventId=${envelope.eventId} — prefer the transactional append()`,
);
}
}