From 6ed0fc383b2e64e2a43f5d049cd027c3234d3994 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:44:22 +0200 Subject: [PATCH 1/9] Record the account a client error was reported for --- .../__tests__/client-error.service.spec.ts | 9 +++++ .../__tests__/create-client-error.dto.spec.ts | 35 +++++++++++++++++++ .../supporting/log/client-error.service.ts | 7 +++- .../log/dto/create-client-error.dto.ts | 11 +++++- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts diff --git a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts index 435306d9de..4e5f2332ed 100644 --- a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts @@ -40,10 +40,19 @@ describe('ClientErrorService', () => { expect(loggedLine()).toContain('userAgent="Mozilla/5.0"'); }); + // What the report is for: tying a failure to the customer who called support. The value is the + // caller's own, which is why it is logged as context and never used for anything else. + it('logs the reported account', () => { + service.logError(dto({ accountId: 123456 })); + + expect(loggedLine()).toContain('account="123456"'); + }); + it('logs absent context as an empty value', () => { service.logError(dto()); expect(loggedLine()).toContain('client=""'); + expect(loggedLine()).toContain('account=""'); expect(loggedLine()).toContain('route=""'); expect(loggedLine()).toContain('version=""'); expect(loggedLine()).toContain('userAgent=""'); diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts new file mode 100644 index 0000000000..2c6c541a2b --- /dev/null +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -0,0 +1,35 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateClientErrorDto } from '../dto/create-client-error.dto'; + +// The account is the one field on this DTO that is read back as a number rather than as text. It is +// pinned here because the endpoint is unauthenticated: whatever shape is accepted is a shape anyone +// can post, and the field exists to be looked up in the logs, not to carry prose. +describe('CreateClientErrorDto.accountId', () => { + async function errorsFor(accountId: unknown): Promise { + const errors = await validate(plainToInstance(CreateClientErrorDto, { message: 'boom', accountId })); + + return errors.map((e) => e.property); + } + + it('accepts an account id', async () => { + await expect(errorsFor(123456)).resolves.toEqual([]); + }); + + it('accepts a report without an account, which is what an error before sign-in looks like', async () => { + const errors = await validate(plainToInstance(CreateClientErrorDto, { message: 'boom' })); + + expect(errors).toEqual([]); + }); + + it.each([ + ['free text', 'Robert'], + // The pipe runs without implicit conversion, so a client that sends the id as text is rejected + // rather than silently accepted - and a rejected report is a report nobody sees. + ['the id as a string', '123456'], + ['a fraction', 1.5], + ['a boolean', true], + ])('rejects %s', async (_case, value) => { + await expect(errorsFor(value)).resolves.toEqual(['accountId']); + }); +}); diff --git a/src/subdomains/supporting/log/client-error.service.ts b/src/subdomains/supporting/log/client-error.service.ts index 7750470f80..1454823b14 100644 --- a/src/subdomains/supporting/log/client-error.service.ts +++ b/src/subdomains/supporting/log/client-error.service.ts @@ -84,12 +84,17 @@ export class ClientErrorService { // Checked before the fields are built, so a flood cannot buy sanitizing work it never uses. if (!this.isWithinBudget()) return; - const { message, type, stack, route, version } = dto; + const { message, type, stack, route, version, accountId } = dto; // Context first, free text last and quoted: message, type and stack are attacker-controlled // and would otherwise be indistinguishable from the key=value context a log query parses. + // + // The account is a correlation hint and nothing else. This endpoint takes no session (see the + // controller), so the id is whatever the caller sent — it answers "which reports belong to the + // customer who called support", never "who is this". const fields = [ `client=${ClientErrorService.quote(client)}`, + `account=${ClientErrorService.quote(accountId?.toString())}`, `route=${ClientErrorService.quote(ClientErrorService.toPath(route))}`, `version=${ClientErrorService.quote(version)}`, `userAgent=${ClientErrorService.quote(userAgent)}`, diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 189f8fc61f..443fbffcc8 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { Util } from 'src/shared/utils/util'; export class CreateClientErrorDto { @@ -40,4 +40,13 @@ export class CreateClientErrorDto { @Transform(Util.trim) @MaxLength(50) version?: string; + + @ApiPropertyOptional({ + description: + 'Account ID of the signed-in user, absent when nobody is signed in. Correlation hint only: this endpoint is ' + + 'unauthenticated, so the value is whatever the caller sent and says nothing about who they are.', + }) + @IsOptional() + @IsInt() + accountId?: number; } From 5c545b6418b6bb36ec600afa6528891b7cac0cc3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:11:38 +0200 Subject: [PATCH 2/9] Name the log field accountId, like the other id fields in the logs --- .../supporting/log/__tests__/client-error.service.spec.ts | 8 ++++---- .../log/__tests__/create-client-error.dto.spec.ts | 6 ++++++ src/subdomains/supporting/log/client-error.service.ts | 6 +++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts index 4e5f2332ed..f89c1711c1 100644 --- a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts @@ -40,19 +40,19 @@ describe('ClientErrorService', () => { expect(loggedLine()).toContain('userAgent="Mozilla/5.0"'); }); - // What the report is for: tying a failure to the customer who called support. The value is the - // caller's own, which is why it is logged as context and never used for anything else. + // What the report is for: tying a failure to the customer who called support. The value comes + // from the request, which is why it is logged as context and never used for anything else. it('logs the reported account', () => { service.logError(dto({ accountId: 123456 })); - expect(loggedLine()).toContain('account="123456"'); + expect(loggedLine()).toContain('accountId="123456"'); }); it('logs absent context as an empty value', () => { service.logError(dto()); expect(loggedLine()).toContain('client=""'); - expect(loggedLine()).toContain('account=""'); + expect(loggedLine()).toContain('accountId=""'); expect(loggedLine()).toContain('route=""'); expect(loggedLine()).toContain('version=""'); expect(loggedLine()).toContain('userAgent=""'); diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 2c6c541a2b..f231279e43 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -22,6 +22,12 @@ describe('CreateClientErrorDto.accountId', () => { expect(errors).toEqual([]); }); + // A client that fills the field with null rather than leaving it out sends a report worth + // keeping, and `@IsOptional()` treats it as absent - the log then carries an empty account. + it('accepts null as an absent account', async () => { + await expect(errorsFor(null)).resolves.toEqual([]); + }); + it.each([ ['free text', 'Robert'], // The pipe runs without implicit conversion, so a client that sends the id as text is rejected diff --git a/src/subdomains/supporting/log/client-error.service.ts b/src/subdomains/supporting/log/client-error.service.ts index 1454823b14..db2fc2df5e 100644 --- a/src/subdomains/supporting/log/client-error.service.ts +++ b/src/subdomains/supporting/log/client-error.service.ts @@ -90,11 +90,11 @@ export class ClientErrorService { // and would otherwise be indistinguishable from the key=value context a log query parses. // // The account is a correlation hint and nothing else. This endpoint takes no session (see the - // controller), so the id is whatever the caller sent — it answers "which reports belong to the - // customer who called support", never "who is this". + // controller), so the id is whatever the request carried — it answers "which reports belong to + // the customer who called support", never "who is this". const fields = [ `client=${ClientErrorService.quote(client)}`, - `account=${ClientErrorService.quote(accountId?.toString())}`, + `accountId=${ClientErrorService.quote(accountId?.toString())}`, `route=${ClientErrorService.quote(ClientErrorService.toPath(route))}`, `version=${ClientErrorService.quote(version)}`, `userAgent=${ClientErrorService.quote(userAgent)}`, From 982fc2fd1925a13e80e84809bb08cb48d4ceb95c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:46:42 +0200 Subject: [PATCH 3/9] Reject an account id the parser would alter on the way in --- .../log/__tests__/create-client-error.dto.spec.ts | 9 +++++++++ .../supporting/log/dto/create-client-error.dto.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index f231279e43..55eb61efcb 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -28,6 +28,10 @@ describe('CreateClientErrorDto.accountId', () => { await expect(errorsFor(null)).resolves.toEqual([]); }); + it('accepts the largest id that survives being parsed', async () => { + await expect(errorsFor(Number.MAX_SAFE_INTEGER)).resolves.toEqual([]); + }); + it.each([ ['free text', 'Robert'], // The pipe runs without implicit conversion, so a client that sends the id as text is rejected @@ -35,6 +39,11 @@ describe('CreateClientErrorDto.accountId', () => { ['the id as a string', '123456'], ['a fraction', 1.5], ['a boolean', true], + ['zero, which is no account', 0], + ['a negative id', -1], + // What an id beyond the safe range arrives as: the parser rounds 9007199254740993 to this, + // so two different ids sent would otherwise be logged as the same one. + ['an id past the safe integer range', Number.MAX_SAFE_INTEGER + 1], ])('rejects %s', async (_case, value) => { await expect(errorsFor(value)).resolves.toEqual(['accountId']); }); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 443fbffcc8..1d55ee5808 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { Util } from 'src/shared/utils/util'; export class CreateClientErrorDto { @@ -44,9 +44,14 @@ export class CreateClientErrorDto { @ApiPropertyOptional({ description: 'Account ID of the signed-in user, absent when nobody is signed in. Correlation hint only: this endpoint is ' + - 'unauthenticated, so the value is whatever the caller sent and says nothing about who they are.', + 'unauthenticated, so the value is whatever the request carried and says nothing about who sent it.', }) @IsOptional() @IsInt() + // An account id is a positive number, and one beyond the safe integer range would be rounded on + // the way in: two different ids sent would then be logged as the same one, which is this service + // altering the value rather than merely recording an unverified one. + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) accountId?: number; } From f1c35f8f676ba87cffc3ccf6cd86b2794d64df1a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:55 +0200 Subject: [PATCH 4/9] Drop an unusable account hint instead of losing the report with it --- .../__tests__/create-client-error.dto.spec.ts | 64 ++++++++++++------- .../log/dto/create-client-error.dto.ts | 13 ++-- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 55eb61efcb..89141902e6 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -1,19 +1,29 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { DetailedValidationPipe } from 'src/shared/pipes/detailed-validation.pipe'; import { CreateClientErrorDto } from '../dto/create-client-error.dto'; -// The account is the one field on this DTO that is read back as a number rather than as text. It is -// pinned here because the endpoint is unauthenticated: whatever shape is accepted is a shape anyone -// can post, and the field exists to be looked up in the logs, not to carry prose. +// The account is the one field on this DTO that is read back as a number rather than as text, and +// the only one whose value may be discarded without the report going with it. Both are pinned here +// because the endpoint is unauthenticated: whatever shape arrives is a shape anyone can post, and +// the field exists to be looked up in the logs, not to carry prose. describe('CreateClientErrorDto.accountId', () => { - async function errorsFor(accountId: unknown): Promise { - const errors = await validate(plainToInstance(CreateClientErrorDto, { message: 'boom', accountId })); + async function submit(accountId: unknown): Promise<{ rejected: string[]; accountId?: number }> { + const dto = plainToInstance(CreateClientErrorDto, { message: 'boom', accountId }); + const errors = await validate(dto); - return errors.map((e) => e.property); + return { rejected: errors.map((e) => e.property), accountId: dto.accountId }; } - it('accepts an account id', async () => { - await expect(errorsFor(123456)).resolves.toEqual([]); + it('keeps an account id', async () => { + await expect(submit(123456)).resolves.toEqual({ rejected: [], accountId: 123456 }); + }); + + it('keeps the largest id that survives being parsed', async () => { + await expect(submit(Number.MAX_SAFE_INTEGER)).resolves.toEqual({ + rejected: [], + accountId: Number.MAX_SAFE_INTEGER, + }); }); it('accepts a report without an account, which is what an error before sign-in looks like', async () => { @@ -22,29 +32,35 @@ describe('CreateClientErrorDto.accountId', () => { expect(errors).toEqual([]); }); - // A client that fills the field with null rather than leaving it out sends a report worth - // keeping, and `@IsOptional()` treats it as absent - the log then carries an empty account. - it('accepts null as an absent account', async () => { - await expect(errorsFor(null)).resolves.toEqual([]); - }); - - it('accepts the largest id that survives being parsed', async () => { - await expect(errorsFor(Number.MAX_SAFE_INTEGER)).resolves.toEqual([]); - }); - + // Everything below is dropped rather than rejected. A 400 would take message, stack and route + // with it, and those are what the report exists for. it.each([ ['free text', 'Robert'], - // The pipe runs without implicit conversion, so a client that sends the id as text is rejected - // rather than silently accepted - and a rejected report is a report nobody sees. + // The pipe runs without implicit conversion, so an id sent as text is not silently read as one. ['the id as a string', '123456'], ['a fraction', 1.5], ['a boolean', true], + ['null', null], ['zero, which is no account', 0], ['a negative id', -1], - // What an id beyond the safe range arrives as: the parser rounds 9007199254740993 to this, - // so two different ids sent would otherwise be logged as the same one. + // What an id beyond the safe range arrives as: parsing rounds 9007199254740993 to this, so two + // different ids sent would otherwise be logged as the same one. ['an id past the safe integer range', Number.MAX_SAFE_INTEGER + 1], - ])('rejects %s', async (_case, value) => { - await expect(errorsFor(value)).resolves.toEqual(['accountId']); + ])('drops %s and keeps the report', async (_case, value) => { + await expect(submit(value)).resolves.toEqual({ rejected: [], accountId: undefined }); + }); + + // Dropping instead of rejecting only holds if the pipe this app is bootstrapped with applies the + // transformation at all. Same options as main.ts passes, so a change there that stops it fails + // here rather than in production. + it('drops a bad account through the pipe the app runs, instead of answering 400', async () => { + const pipe = new DetailedValidationPipe({ whitelist: true, transformOptions: { exposeUnsetFields: false } }); + + const body = await pipe.transform( + { message: 'boom', accountId: 'Robert' }, + { type: 'body', metatype: CreateClientErrorDto }, + ); + + expect(body).toEqual({ message: 'boom' }); }); }); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 1d55ee5808..9cafd6da7e 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -44,13 +44,18 @@ export class CreateClientErrorDto { @ApiPropertyOptional({ description: 'Account ID of the signed-in user, absent when nobody is signed in. Correlation hint only: this endpoint is ' + - 'unauthenticated, so the value is whatever the request carried and says nothing about who sent it.', + 'unauthenticated, so the value is whatever the request carried and says nothing about who sent it. A value ' + + 'outside 1..2^53-1 is dropped rather than rejected, so a bad hint does not cost the report.', }) @IsOptional() + // Dropped, not rejected: the pipe answers 400 for the whole body, and losing message, stack and + // route over the one field that only helps to find them is the blind spot this endpoint exists + // to close. The range is what the value has to be to stay the value that was sent - beyond the + // safe integers, parsing the body rounds, and two different ids arrive as the same one. + @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) + // Kept as the contract this field advertises, in Swagger and to a reader. The transform above + // means they are never what rejects a report. @IsInt() - // An account id is a positive number, and one beyond the safe integer range would be rounded on - // the way in: two different ids sent would then be logged as the same one, which is this service - // altering the value rather than merely recording an unverified one. @Min(1) @Max(Number.MAX_SAFE_INTEGER) accountId?: number; From 9c1f378cfa787d0a6fabc06dc52a6022f175ace9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:05:05 +0200 Subject: [PATCH 5/9] Say the accepted range in the API docs, and say the reason precisely --- .../log/__tests__/create-client-error.dto.spec.ts | 4 ++-- .../supporting/log/dto/create-client-error.dto.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 89141902e6..2a19692e39 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -43,8 +43,8 @@ describe('CreateClientErrorDto.accountId', () => { ['null', null], ['zero, which is no account', 0], ['a negative id', -1], - // What an id beyond the safe range arrives as: parsing rounds 9007199254740993 to this, so two - // different ids sent would otherwise be logged as the same one. + // What 9007199254740993 arrives as once the body is parsed, which is why the range ends here: + // past the safe integers, two ids that differ can reach the log as the same number. ['an id past the safe integer range', Number.MAX_SAFE_INTEGER + 1], ])('drops %s and keeps the report', async (_case, value) => { await expect(submit(value)).resolves.toEqual({ rejected: [], accountId: undefined }); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 9cafd6da7e..fbd36a4c24 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -42,16 +42,18 @@ export class CreateClientErrorDto { version?: string; @ApiPropertyOptional({ + minimum: 1, + maximum: Number.MAX_SAFE_INTEGER, description: 'Account ID of the signed-in user, absent when nobody is signed in. Correlation hint only: this endpoint is ' + 'unauthenticated, so the value is whatever the request carried and says nothing about who sent it. A value ' + - 'outside 1..2^53-1 is dropped rather than rejected, so a bad hint does not cost the report.', + 'outside the range is dropped rather than rejected, so a bad hint does not cost the report.', }) @IsOptional() // Dropped, not rejected: the pipe answers 400 for the whole body, and losing message, stack and // route over the one field that only helps to find them is the blind spot this endpoint exists - // to close. The range is what the value has to be to stay the value that was sent - beyond the - // safe integers, parsing the body rounds, and two different ids arrive as the same one. + // to close. The range is what the value has to be to stay the value that was sent - past the + // safe integers, parsing the body can round, and two ids that differ arrive as the same number. @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) // Kept as the contract this field advertises, in Swagger and to a reader. The transform above // means they are never what rejects a report. From bce2f803128e711ab2eb064db500cdf790df9261 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:21:25 +0200 Subject: [PATCH 6/9] Enforce the account range where it does not cost the report --- .../__tests__/create-client-error.dto.spec.ts | 24 ++++++++++++------- .../log/dto/create-client-error.dto.ts | 17 ++++++------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 2a19692e39..7de0bd6e64 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -50,17 +50,23 @@ describe('CreateClientErrorDto.accountId', () => { await expect(submit(value)).resolves.toEqual({ rejected: [], accountId: undefined }); }); - // Dropping instead of rejecting only holds if the pipe this app is bootstrapped with applies the - // transformation at all. Same options as main.ts passes, so a change there that stops it fails - // here rather than in production. - it('drops a bad account through the pipe the app runs, instead of answering 400', async () => { + // What the field does under the pipe the app is actually bootstrapped with - same options as + // main.ts passes. Two things only hold there: `whitelist: true` strips a property the DTO does + // not declare, so an account that is declared but loses its decorators would silently stop being + // recorded; and the transformation is what drops a bad value instead of rejecting the report. + describe('under the pipe the app runs', () => { const pipe = new DetailedValidationPipe({ whitelist: true, transformOptions: { exposeUnsetFields: false } }); - const body = await pipe.transform( - { message: 'boom', accountId: 'Robert' }, - { type: 'body', metatype: CreateClientErrorDto }, - ); + function submitTo(accountId: unknown): Promise { + return pipe.transform({ message: 'boom', accountId }, { type: 'body', metatype: CreateClientErrorDto }); + } - expect(body).toEqual({ message: 'boom' }); + it('records the account rather than stripping it', async () => { + await expect(submitTo(123456)).resolves.toEqual({ message: 'boom', accountId: 123456 }); + }); + + it('drops a bad account instead of answering 400', async () => { + await expect(submitTo('Robert')).resolves.toEqual({ message: 'boom' }); + }); }); }); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index fbd36a4c24..421b5c77a1 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; import { Util } from 'src/shared/utils/util'; export class CreateClientErrorDto { @@ -50,15 +50,12 @@ export class CreateClientErrorDto { 'outside the range is dropped rather than rejected, so a bad hint does not cost the report.', }) @IsOptional() - // Dropped, not rejected: the pipe answers 400 for the whole body, and losing message, stack and - // route over the one field that only helps to find them is the blind spot this endpoint exists - // to close. The range is what the value has to be to stay the value that was sent - past the - // safe integers, parsing the body can round, and two ids that differ arrive as the same number. + // The range is enforced here rather than by a validator, because a validator would reject the + // report along with the value: the pipe answers 400 for the whole body, and losing message, + // stack and route over the one field that only helps to find them is the blind spot this + // endpoint exists to close. The range itself is what the value has to be to stay the value that + // was sent - past the safe integers, parsing the body can round, and two ids that differ arrive + // as the same number. @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) - // Kept as the contract this field advertises, in Swagger and to a reader. The transform above - // means they are never what rejects a report. - @IsInt() - @Min(1) - @Max(Number.MAX_SAFE_INTEGER) accountId?: number; } From 792237b2133a5a6ee1f1567d2e9e1103df6d2026 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:32:09 +0200 Subject: [PATCH 7/9] Bound what arrives, and say only that --- .../log/__tests__/create-client-error.dto.spec.ts | 10 ++++++++++ .../supporting/log/dto/create-client-error.dto.ts | 9 ++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 7de0bd6e64..64bf00596a 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -26,6 +26,16 @@ describe('CreateClientErrorDto.accountId', () => { }); }); + // The limit is on what arrives, not on what was written: the body is parsed before any of this + // runs, and a fractional value near the limit arrives as an integer. Recorded as parsed - pinned + // so the guarantee is not read as wider than it is. + it('keeps what the parser made of a value written with a fraction', async () => { + await expect(submit(JSON.parse('9007199254740991.1'))).resolves.toEqual({ + rejected: [], + accountId: Number.MAX_SAFE_INTEGER, + }); + }); + it('accepts a report without an account, which is what an error before sign-in looks like', async () => { const errors = await validate(plainToInstance(CreateClientErrorDto, { message: 'boom' })); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 421b5c77a1..26f3759bc5 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -53,9 +53,12 @@ export class CreateClientErrorDto { // The range is enforced here rather than by a validator, because a validator would reject the // report along with the value: the pipe answers 400 for the whole body, and losing message, // stack and route over the one field that only helps to find them is the blind spot this - // endpoint exists to close. The range itself is what the value has to be to stay the value that - // was sent - past the safe integers, parsing the body can round, and two ids that differ arrive - // as the same number. + // endpoint exists to close. + // + // The upper bound is where parsing stops being faithful: past the safe integers, ids that differ + // arrive as the same number. It bounds what arrives, not what was written - the body is parsed + // before anything here sees it, so a value written with a fractional part near the bound arrives + // as an integer and is recorded as one. Nothing on this side can tell the two apart. @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) accountId?: number; } From 00d83fe32499adac5fb26d01a18f0952cc985990 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:42:28 +0200 Subject: [PATCH 8/9] Document the field as what it is: a client-reported hint --- .../__tests__/client-error.service.spec.ts | 4 ++-- .../__tests__/create-client-error.dto.spec.ts | 6 +++--- .../supporting/log/client-error.service.ts | 4 ++-- .../log/dto/create-client-error.dto.ts | 21 +++++++++---------- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts index f89c1711c1..73fcbe8384 100644 --- a/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts +++ b/src/subdomains/supporting/log/__tests__/client-error.service.spec.ts @@ -40,8 +40,8 @@ describe('ClientErrorService', () => { expect(loggedLine()).toContain('userAgent="Mozilla/5.0"'); }); - // What the report is for: tying a failure to the customer who called support. The value comes - // from the request, which is why it is logged as context and never used for anything else. + // What the report is for: matching a support case against the failures recorded under the same + // id. The value comes from the request, which is why it is logged as context and nothing else. it('logs the reported account', () => { service.logError(dto({ accountId: 123456 })); diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index 64bf00596a..cbaa43cd96 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -19,7 +19,7 @@ describe('CreateClientErrorDto.accountId', () => { await expect(submit(123456)).resolves.toEqual({ rejected: [], accountId: 123456 }); }); - it('keeps the largest id that survives being parsed', async () => { + it('keeps the largest safe account id', async () => { await expect(submit(Number.MAX_SAFE_INTEGER)).resolves.toEqual({ rejected: [], accountId: Number.MAX_SAFE_INTEGER, @@ -53,8 +53,8 @@ describe('CreateClientErrorDto.accountId', () => { ['null', null], ['zero, which is no account', 0], ['a negative id', -1], - // What 9007199254740993 arrives as once the body is parsed, which is why the range ends here: - // past the safe integers, two ids that differ can reach the log as the same number. + // What 9007199254740993 arrives as once the body is parsed. Dropping it is the point: without + // the bound, ids that differ would be recorded under the same number. ['an id past the safe integer range', Number.MAX_SAFE_INTEGER + 1], ])('drops %s and keeps the report', async (_case, value) => { await expect(submit(value)).resolves.toEqual({ rejected: [], accountId: undefined }); diff --git a/src/subdomains/supporting/log/client-error.service.ts b/src/subdomains/supporting/log/client-error.service.ts index db2fc2df5e..3ec3b13ade 100644 --- a/src/subdomains/supporting/log/client-error.service.ts +++ b/src/subdomains/supporting/log/client-error.service.ts @@ -90,8 +90,8 @@ export class ClientErrorService { // and would otherwise be indistinguishable from the key=value context a log query parses. // // The account is a correlation hint and nothing else. This endpoint takes no session (see the - // controller), so the id is whatever the request carried — it answers "which reports belong to - // the customer who called support", never "who is this". + // controller), so the id is whatever the request carried: it lets a support case be matched + // against the reports carrying the same id, and says nothing about who sent them. const fields = [ `client=${ClientErrorService.quote(client)}`, `accountId=${ClientErrorService.quote(accountId?.toString())}`, diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 26f3759bc5..85ecef0f15 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -42,23 +42,22 @@ export class CreateClientErrorDto { version?: string; @ApiPropertyOptional({ + type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: - 'Account ID of the signed-in user, absent when nobody is signed in. Correlation hint only: this endpoint is ' + - 'unauthenticated, so the value is whatever the request carried and says nothing about who sent it. A value ' + - 'outside the range is dropped rather than rejected, so a bad hint does not cost the report.', + 'Account ID the client reports for the signed-in user, absent when nobody is signed in. Correlation hint ' + + 'only: this endpoint is unauthenticated, so the value is whatever the request carried and says nothing ' + + 'about who sent it. A value that does not arrive as a positive safe integer is dropped rather than ' + + 'rejected, so a bad hint does not cost the report.', }) @IsOptional() - // The range is enforced here rather than by a validator, because a validator would reject the - // report along with the value: the pipe answers 400 for the whole body, and losing message, - // stack and route over the one field that only helps to find them is the blind spot this - // endpoint exists to close. + // Dropped here rather than rejected by a validator, which would reject the report along with the + // value: the pipe answers 400 for the whole body, and losing message, stack and route over the + // one field that only helps to find them is the blind spot this endpoint exists to close. // - // The upper bound is where parsing stops being faithful: past the safe integers, ids that differ - // arrive as the same number. It bounds what arrives, not what was written - the body is parsed - // before anything here sees it, so a value written with a fractional part near the bound arrives - // as an integer and is recorded as one. Nothing on this side can tell the two apart. + // The bound is on what arrives. The body is parsed first, and past the safe integers that parsing + // is no longer faithful - which is what the bound is for, not something it can undo. @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) accountId?: number; } From ba6bf6f58d94a1623361cfbd01d2211868591d1b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:01:13 +0200 Subject: [PATCH 9/9] Describe the field without claiming a session the endpoint never sees --- .../log/__tests__/create-client-error.dto.spec.ts | 2 +- .../supporting/log/dto/create-client-error.dto.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts index cbaa43cd96..2ee070459d 100644 --- a/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts +++ b/src/subdomains/supporting/log/__tests__/create-client-error.dto.spec.ts @@ -36,7 +36,7 @@ describe('CreateClientErrorDto.accountId', () => { }); }); - it('accepts a report without an account, which is what an error before sign-in looks like', async () => { + it('accepts a report that carries no account at all', async () => { const errors = await validate(plainToInstance(CreateClientErrorDto, { message: 'boom' })); expect(errors).toEqual([]); diff --git a/src/subdomains/supporting/log/dto/create-client-error.dto.ts b/src/subdomains/supporting/log/dto/create-client-error.dto.ts index 85ecef0f15..098eeabd66 100644 --- a/src/subdomains/supporting/log/dto/create-client-error.dto.ts +++ b/src/subdomains/supporting/log/dto/create-client-error.dto.ts @@ -46,18 +46,18 @@ export class CreateClientErrorDto { minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: - 'Account ID the client reports for the signed-in user, absent when nobody is signed in. Correlation hint ' + - 'only: this endpoint is unauthenticated, so the value is whatever the request carried and says nothing ' + - 'about who sent it. A value that does not arrive as a positive safe integer is dropped rather than ' + - 'rejected, so a bad hint does not cost the report.', + 'Account ID the client reports for the signed-in user, left out when it has none to report. Correlation ' + + 'hint only: this endpoint is unauthenticated, so the value is whatever the request carried and says ' + + 'nothing about who sent it. A value that does not arrive as a positive safe integer is dropped rather ' + + 'than rejected, so a bad hint does not cost the report.', }) @IsOptional() // Dropped here rather than rejected by a validator, which would reject the report along with the // value: the pipe answers 400 for the whole body, and losing message, stack and route over the // one field that only helps to find them is the blind spot this endpoint exists to close. // - // The bound is on what arrives. The body is parsed first, and past the safe integers that parsing - // is no longer faithful - which is what the bound is for, not something it can undo. + // What it checks is the value that arrived. The body is parsed before this runs, and what parsing + // did to the number on the way is not visible from here. @Transform(({ value }) => (Number.isSafeInteger(value) && value > 0 ? value : undefined)) accountId?: number; }