Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,22 @@ strings (digits only, no leading zeros except `0`, at most 39 digits) so
values that flow into `BigInt(...)` fail with a 400 at the DTO boundary
instead of a 500 from a parse error.

`IsSeat` (in `src/common/decorators`) validates seat format to prevent
arbitrary strings from bloating the database or causing Soroban contract
issues. Seats must be 1–64 characters, alphanumeric with spaces, hyphens,
slashes, or periods. Whitespace-only strings are rejected.

## Max-length limits

Free-text fields have length limits to prevent database bloat:

| Field | DTOs | Limit | Reason |
|-------|------|-------|--------|
| `name` | `CreateEventDto`, `CreateOrganizationDto`, `CreateTicketTypeDto` | 256 | Event/organization/ticket-type names |
| `venue` | `CreateEventDto` | 256 | Event venue names |
| `seat` | `IssueTicketDto`, `PurchasePrimaryDto` | 64 | Ticket seat identifiers |
| `slug` | `CreateOrganizationDto` | 128 | Organization URL slugs |

String fields are trimmed (leading/trailing whitespace removed) via
`@Transform` decorators on DTOs, and validators reject whitespace-only values.

5 changes: 4 additions & 1 deletion src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class RegisterDto {
@IsEmail()
Expand All @@ -8,7 +9,9 @@ export class RegisterDto {
@MinLength(10)
password!: string;

@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(256)
name!: string;
}
73 changes: 73 additions & 0 deletions src/common/decorators/is-seat.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { validate } from 'class-validator';
import { IsSeat } from './is-seat.decorator';

class TestDto {
@IsSeat()
seat!: string;
}

describe('IsSeat decorator', () => {
it('accepts valid seats', async () => {
const dto = new TestDto();
dto.seat = 'A1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('accepts seats with spaces', async () => {
const dto = new TestDto();
dto.seat = 'Row A Seat 1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('accepts seats with hyphens and slashes', async () => {
const dto = new TestDto();
dto.seat = 'A-1/Floor-2';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('accepts seats with periods', async () => {
const dto = new TestDto();
dto.seat = 'A.1.Floor.2';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('rejects empty strings', async () => {
const dto = new TestDto();
dto.seat = '';
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0]?.constraints?.isSeat).toContain('1-64 characters');
});

it('rejects whitespace-only values', async () => {
const dto = new TestDto();
dto.seat = ' ';
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});

it('rejects seats longer than 64 characters', async () => {
const dto = new TestDto();
dto.seat = 'A'.repeat(65);
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});

it('rejects seats with special characters', async () => {
const dto = new TestDto();
dto.seat = 'A1@Special!';
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});

it('rejects non-string values', async () => {
const dto = new TestDto();
(dto.seat as unknown) = 123;
const errors = await validate(dto);
expect(errors).toHaveLength(1);
});
});
32 changes: 32 additions & 0 deletions src/common/decorators/is-seat.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';

@ValidatorConstraint({ name: 'isSeat', async: false })
export class IsSeatConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') return false;
if (value.length === 0 || value.length > 64) return false;
// Allow alphanumeric, spaces, and common seat characters (-, /, .)
return /^[a-zA-Z0-9\s\-/.]+$/.test(value) && !/^\s+$/.test(value);
}

defaultMessage(): string {
return 'seat must be 1-64 characters, alphanumeric with spaces, hyphens, slashes, or periods only';
}
}

export function IsSeat(validationOptions?: ValidationOptions) {
return function (target: object, propertyName: string) {
registerDecorator({
target: target.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsSeatConstraint,
});
};
}
7 changes: 6 additions & 1 deletion src/events/dto/create-event.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsDate,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinDate,
MinLength,
Expand All @@ -20,17 +21,21 @@ import { Industry } from '@prisma/client';
export const STARTS_AT_PAST_TOLERANCE_MS = 60_000;

export class CreateEventDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(2)
@MaxLength(256)
name!: string;

@IsEnum(Industry, {
message: `category must be one of: ${Object.values(Industry).join(', ')}`,
})
category!: Industry;

@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(256)
venue!: string;

@Type(() => Date)
Expand Down
5 changes: 4 additions & 1 deletion src/events/dto/create-ticket-type.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsDate,
IsInt,
IsOptional,
IsPositive,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { IsBigIntString } from '../../common/decorators/is-bigint-string.decorator';

export class CreateTicketTypeDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(256)
name!: string;

/** Face-value price in the settlement token's smallest unit, as a string to preserve i128 precision over JSON. */
Expand Down
5 changes: 4 additions & 1 deletion src/gates/dto/create-gate.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { IsString, MinLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';

export class CreateGateDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(256)
name!: string;
}
5 changes: 5 additions & 0 deletions src/organizations/dto/create-organization.dto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { Transform } from 'class-transformer';
import {
IsEnum,
IsOptional,
IsString,
IsUrl,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { Industry } from '@prisma/client';
import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator';

export class CreateOrganizationDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(2)
@MaxLength(256)
name!: string;

@Matches(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
message: 'slug must be lowercase, alphanumeric, and hyphen-separated',
})
@MaxLength(128)
slug!: string;

@IsEnum(Industry)
Expand Down
5 changes: 4 additions & 1 deletion src/scanner-devices/dto/register-scanner-device.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { IsString, MinLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';

export class RegisterScannerDeviceDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(256)
name!: string;
}
5 changes: 4 additions & 1 deletion src/tickets/dto/confirm-check-in.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { ConfirmSignedTxDto } from './confirm-signed-tx.dto';

export class ConfirmCheckInDto extends ConfirmSignedTxDto {
Expand All @@ -9,6 +10,8 @@ export class ConfirmCheckInDto extends ConfirmSignedTxDto {

/** Reason for check-in when scanner fails and staff override is used. */
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(512)
reason?: string;
}
5 changes: 3 additions & 2 deletions src/tickets/dto/confirm-issue-ticket.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator';
import { IsSeat } from '../../common/decorators/is-seat.decorator';
import { ConfirmSignedTxDto } from './confirm-signed-tx.dto';

export class ConfirmIssueTicketDto extends ConfirmSignedTxDto {
Expand All @@ -13,6 +14,6 @@ export class ConfirmIssueTicketDto extends ConfirmSignedTxDto {
toPublicKey!: string;

@IsOptional()
@IsString()
@IsSeat()
seat?: string;
}
3 changes: 2 additions & 1 deletion src/tickets/dto/confirm-purchase-primary.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
import { IsSeat } from '../../common/decorators/is-seat.decorator';
import { ConfirmSignedTxDto } from './confirm-signed-tx.dto';

export class ConfirmPurchasePrimaryDto extends ConfirmSignedTxDto {
@IsUUID()
ticketTypeId!: string;

@IsOptional()
@IsString()
@IsSeat()
seat?: string;

@IsOptional()
Expand Down
5 changes: 3 additions & 2 deletions src/tickets/dto/issue-ticket.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator';
import { IsSeat } from '../../common/decorators/is-seat.decorator';

export class IssueTicketDto {
@IsUUID()
Expand All @@ -13,6 +14,6 @@ export class IssueTicketDto {
toPublicKey!: string;

@IsOptional()
@IsString()
@IsSeat()
seat?: string;
}
3 changes: 2 additions & 1 deletion src/tickets/dto/purchase-primary.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
import { IsSeat } from '../../common/decorators/is-seat.decorator';

export class PurchasePrimaryDto {
@IsUUID()
ticketTypeId!: string;

@IsOptional()
@IsString()
@IsSeat()
seat?: string;

@IsOptional()
Expand Down
7 changes: 6 additions & 1 deletion src/webhooks/dto/create-webhook-endpoint.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsOptional, IsString, IsUrl } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';

export class CreateWebhookEndpointDto {
@IsString()
Expand All @@ -13,10 +14,14 @@ export class CreateWebhookEndpointDto {
url!: string;

@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(512)
events?: string;

@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(256)
secret?: string;
}
Loading