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
50 changes: 48 additions & 2 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,14 @@
}
},
"400": {
"description": "Malformed batch (invalid entry, name, or CID)"
"description": "Malformed or over-cap batch; the body carries code REGISTRY_BATCH_REFUSED",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BatchRefusedDto"
}
}
}
},
"401": {
"description": "Missing or invalid access token"
Expand Down Expand Up @@ -506,7 +513,14 @@
}
},
"400": {
"description": "Malformed batch"
"description": "Malformed or over-cap batch; the body carries code REGISTRY_BATCH_REFUSED",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BatchRefusedDto"
}
}
}
},
"401": {
"description": "Missing or invalid access token"
Expand Down Expand Up @@ -1114,6 +1128,38 @@
"cids"
]
},
"BatchRefusedDto": {
"type": "object",
"properties": {
"statusCode": {
"type": "number",
"example": 400
},
"message": {
"description": "Constraint strings only — never the rejected entry",
"type": "array",
"items": {
"type": "string"
}
},
"error": {
"type": "string",
"example": "Bad Request"
},
"code": {
"type": "string",
"enum": [
"REGISTRY_BATCH_REFUSED"
]
}
},
"required": [
"statusCode",
"message",
"error",
"code"
]
},
"RetireResponseDto": {
"type": "object",
"properties": {
Expand Down
9 changes: 6 additions & 3 deletions apps/api/src/registry/dto/registry.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateIf,
} from 'class-validator';

/**
Expand All @@ -19,7 +19,7 @@ const CID_OR_NAME = /^[A-Za-z0-9]{1,256}$/;

/** Batch bounds: bulk name waves and sweeps are large but not unbounded. */
export const MAX_BATCH = 1000;
const MAX_CONTENT_CIDS = 1000;
export const MAX_CONTENT_CIDS = 1000;

export class RegisterEntryDto {
@ApiProperty({
Expand All @@ -36,7 +36,10 @@ export class RegisterEntryDto {
required: false,
description: 'Current head (metadata) CID this name publishes; omit to register the name only.',
})
@IsOptional()
// Omitted, never null: a chunked registration's continuation entries leave
// the field out so the stored head survives, and an explicit null would clear
// it instead. Refused rather than silently ignored (blueprint/api.md).
@ValidateIf((entry: RegisterEntryDto) => entry.headCid !== undefined)
@IsString()
@MaxLength(256)
@Matches(CID_OR_NAME, { message: 'headCid must be a bare CID token' })
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/registry/registry-error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';

/**
* The registry's batch routes answer 400 for a refusal the caller can never
* retry past. Clients classify on this stable `code`, so a 400 from anything
* that is NOT this gate stays unattributable (blueprint/api.md).
*/
export const REGISTRY_BATCH_REFUSED = 'REGISTRY_BATCH_REFUSED';

/** The documented 400 body; `batchRefusedBody` returns exactly this shape. */
export class BatchRefusedDto {
@ApiProperty({ example: 400 })
statusCode!: number;

@ApiProperty({
type: [String],
description: 'Constraint strings only — never the rejected entry',
})
message!: string[];

@ApiProperty({ example: 'Bad Request' })
error!: string;

@ApiProperty({ enum: [REGISTRY_BATCH_REFUSED] })
code!: string;
}

/** Nest stops synthesizing `statusCode`/`error` once an exception carries an
* object, so the whole envelope is built here rather than at each throw site. */
export function batchRefusedBody(message: string[]): BatchRefusedDto {
return { statusCode: 400, message, error: 'Bad Request', code: REGISTRY_BATCH_REFUSED };
}
13 changes: 11 additions & 2 deletions apps/api/src/registry/registry.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
RETIRE_TARGET_MAX_LENGTH,
RetireResponseDto,
} from './dto/registry.dto';
import { BatchRefusedDto, REGISTRY_BATCH_REFUSED } from './registry-error-codes';
import { registerBodyPipes, retireBodyPipes } from './registry.pipes';
import { RegistryService } from './services/registry.service';

Expand Down Expand Up @@ -51,7 +52,11 @@ export class RegistryController {
},
})
@ApiCreatedResponse({ type: RegisterResponseDto })
@ApiResponse({ status: 400, description: 'Malformed batch (invalid entry, name, or CID)' })
@ApiResponse({
status: 400,
type: BatchRefusedDto,
description: `Malformed or over-cap batch; the body carries code ${REGISTRY_BATCH_REFUSED}`,
})
@ApiResponse({ status: 401, description: 'Missing or invalid access token' })
@ApiResponse({ status: 429, description: 'Registry rate limit exceeded' })
@ApiResponse({ status: 503, description: 'Token serialization contended; retry shortly' })
Expand All @@ -76,7 +81,11 @@ export class RegistryController {
},
})
@ApiCreatedResponse({ type: RetireResponseDto })
@ApiResponse({ status: 400, description: 'Malformed batch' })
@ApiResponse({
status: 400,
type: BatchRefusedDto,
description: `Malformed or over-cap batch; the body carries code ${REGISTRY_BATCH_REFUSED}`,
})
@ApiResponse({ status: 401, description: 'Missing or invalid access token' })
@ApiResponse({ status: 429, description: 'Registry rate limit exceeded' })
@ApiResponse({ status: 503, description: 'Token serialization contended; retry shortly' })
Expand Down
64 changes: 63 additions & 1 deletion apps/api/src/registry/registry.http.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
} from '../testing/http-integration-app';
import { createIntegrationDatabase, IntegrationDatabase } from '../testing/integration-db';
import { AccountController } from './account.controller';
import { MAX_CONTENT_CIDS } from './dto/registry.dto';
import { NameInventory } from './entities/name-inventory.entity';
import { PinnedCid } from './entities/pinned-cid.entity';
import { PinStore } from './pin-store';
import { REGISTRY_BATCH_REFUSED } from './registry-error-codes';
import { RegistryController } from './registry.controller';
import { AccountService } from './services/account.service';
import { RegistryService } from './services/registry.service';
Expand Down Expand Up @@ -147,6 +149,64 @@ describe('registry HTTP surface (real Postgres)', () => {
expect(await namesFor(acct.id)).toHaveLength(0);
expect((await pinsFor(acct.id)).some((r) => r.cid === 'bafyX')).toBe(false);
});

it('refuses an over-cap contentCids and stamps the batch-refused code', async () => {
const acct = await account();
const contentCids = Array.from({ length: MAX_CONTENT_CIDS + 1 }, (_, i) => `bafyOverCap${i}`);
const response = await request(http())
.post('/registry/register')
.set('Authorization', `Bearer ${acct.token}`)
.send([{ ipnsName: 'k51overcap', contentCids }])
.expect(400);
// The engine's failure valve dead-letters on this code, never on the
// status alone — a 400 from anything but this gate must not carry it.
expect(response.body.code).toBe(REGISTRY_BATCH_REFUSED);
// Constraint strings only: an error body that echoed the rejected entry
// would put the caller's name and CIDs everywhere it is logged.
expect(response.body.message).toEqual(expect.arrayContaining([expect.any(String)]));
expect(JSON.stringify(response.body)).not.toContain('k51overcap');
expect(JSON.stringify(response.body)).not.toContain(contentCids[0]);
expect(await namesFor(acct.id)).toHaveLength(0);
expect(await pinsFor(acct.id)).toHaveLength(0);
});
Comment thread
FSM1 marked this conversation as resolved.

it('refuses an explicit null headCid rather than clearing the stored head', async () => {
const acct = await account();
await request(http())
.post('/registry/register')
.set('Authorization', `Bearer ${acct.token}`)
.send([{ ipnsName: 'k51nullhead', headCid: 'bafyKeepMe', contentCids: [] }])
.expect(201);
const refused = await request(http())
.post('/registry/register')
.set('Authorization', `Bearer ${acct.token}`)
.send([{ ipnsName: 'k51nullhead', headCid: null, contentCids: [] }])
.expect(400);
// DTO validation refuses inside ParseArrayPipe, which hands its
// exceptionFactory flattened strings — the code must survive that path.
expect(refused.body.code).toBe(REGISTRY_BATCH_REFUSED);
expect(refused.body.message).toEqual(expect.arrayContaining([expect.any(String)]));
expect((await namesFor(acct.id))[0].headCid).toBe('bafyKeepMe');
});

it('splits an over-cap version across entries under one name, keeping the head', async () => {
const acct = await account();
// The shape the engine's chunker sends: the head rides the first entry,
// the remainder follows as content-only entries under the same name.
await request(http())
.post('/registry/register')
.set('Authorization', `Bearer ${acct.token}`)
.send([
{ ipnsName: 'k51chunked', headCid: 'bafyChunkedHead', contentCids: ['bafyChunkA'] },
{ ipnsName: 'k51chunked', contentCids: ['bafyChunkB'] },
])
.expect(201);
const names = await namesFor(acct.id);
expect(names).toHaveLength(1);
expect(names[0].headCid).toBe('bafyChunkedHead');
const cids = (await pinsFor(acct.id)).map((r) => r.cid).sort();
expect(cids).toEqual(['bafyChunkA', 'bafyChunkB', 'bafyChunkedHead']);
});
});

describe('retire — union liveness, refcounted unpin', () => {
Expand Down Expand Up @@ -187,11 +247,13 @@ describe('registry HTTP surface (real Postgres)', () => {

it('rejects an over-length target at the pipe (256-char cap)', async () => {
const acct = await account();
await request(http())
const refused = await request(http())
.post('/registry/retire')
.set('Authorization', `Bearer ${acct.token}`)
.send(['a'.repeat(257)])
.expect(400);
expect(refused.body.code).toBe(REGISTRY_BATCH_REFUSED);
expect(refused.body.message).toEqual(expect.arrayContaining([expect.any(String)]));
});
});

Expand Down
32 changes: 28 additions & 4 deletions apps/api/src/registry/registry.pipes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import { BadRequestException, ParseArrayPipe, PipeTransform } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import {
MAX_BATCH,
REGISTER_ARRAY_OPTIONS,
RETIRE_ARRAY_OPTIONS,
RETIRE_TARGET_MAX_LENGTH,
} from './dto/registry.dto';
import { batchRefusedBody } from './registry-error-codes';

/** The constraint strings alone: a validation error also carries the rejected
* entry, and echoing a caller's whole batch back into an error body puts its
* names and CIDs everywhere the response is logged. */
function constraintMessages(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.values(error.constraints ?? {}),
...constraintMessages(error.children ?? []),
]);
}

/** `ParseArrayPipe` hands its `exceptionFactory` already-flattened strings; the
* size and length guards hand a single string. */
function messagesOf(error: unknown): string[] {
if (!Array.isArray(error)) return [String(error)];
return error.every((item) => item instanceof ValidationError)
? constraintMessages(error)
: error.map(String);
}

/** Every batch-gate refusal answers the one documented body (see its home). */
const refuse = (error: unknown) => new BadRequestException(batchRefusedBody(messagesOf(error)));

/** Reject an oversize batch up front, before per-item validation runs. */
class BatchSizePipe implements PipeTransform {
Expand All @@ -15,7 +39,7 @@ class BatchSizePipe implements PipeTransform {

transform(value: unknown): unknown {
if (Array.isArray(value) && value.length > this.max) {
throw new BadRequestException(`Batch exceeds ${this.max} ${this.noun}`);
throw refuse(`Batch exceeds ${this.max} ${this.noun}`);
}
return value;
}
Expand All @@ -28,7 +52,7 @@ class TargetLengthPipe implements PipeTransform {
transform(value: string[]): string[] {
for (const target of value) {
if (target.length > this.max) {
throw new BadRequestException(`target exceeds ${this.max} characters`);
throw refuse(`target exceeds ${this.max} characters`);
}
}
return value;
Expand All @@ -38,12 +62,12 @@ class TargetLengthPipe implements PipeTransform {
/** Size guard first, then the register DTO validation. */
export const registerBodyPipes = [
new BatchSizePipe(MAX_BATCH, 'entries'),
new ParseArrayPipe(REGISTER_ARRAY_OPTIONS),
new ParseArrayPipe({ ...REGISTER_ARRAY_OPTIONS, exceptionFactory: refuse }),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

/** Size guard first, then array parse, then the per-target length cap. */
export const retireBodyPipes = [
new BatchSizePipe(MAX_BATCH, 'targets'),
new ParseArrayPipe(RETIRE_ARRAY_OPTIONS),
new ParseArrayPipe({ ...RETIRE_ARRAY_OPTIONS, exceptionFactory: refuse }),
new TargetLengthPipe(RETIRE_TARGET_MAX_LENGTH),
];
8 changes: 7 additions & 1 deletion blueprint/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ decay) inverted into structure.
caps `contentCids` at 1000 per entry), enforced fail-closed with `400` before
per-item validation and published as `maxItems` in the OpenAPI document. A bulk
caller — a name wave, or an abandoned version whose leaves all need retiring —
chunks to the cap; retire is idempotent, so a replayed chunk is a no-op.
chunks to the cap; retire is idempotent, so a replayed chunk is a no-op. A
version with more leaves than the per-entry cap registers as several entries
under one `ipnsName`, the head riding the first; the server collapses them to
one name row, and a bare re-register carrying no `headCid` leaves the stored
head untouched. The refusal carries `code: REGISTRY_BATCH_REFUSED`, so a
client classifies on the gate's own discriminator rather than on a bare `400`
an intermediary could have answered.
- **Register-first, fail-closed**: registration precedes the first publish of a
name, and publish is blocked on it. A live-but-uninventoried name is
structurally impossible; the worst failure is a registered-never-published
Expand Down
Loading