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
6 changes: 6 additions & 0 deletions .changeset/refuse-unrecognized-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@haverstack/core': minor
'@haverstack/conformance-fixtures': minor
---

The `./wire` request parsers refuse input their endpoint does not define instead of ignoring it. `parseQueryParams()`, `parseChangeParams()` and `parseJournalParams()` throw `StackBadRequestError` for an unknown query param, a repeat of a param that names one value, or a boolean param other than `true`/`false`. `parseQueryBody()` does the same for an unknown key at any depth, a non-object body, a non-boolean `includeDeleted`/`includeUnlisted` or a non-string `cursor`. `createOptionsFromWireRecord()` refuses a key no wire record carries. The rule is specified in docs/spec/wire-format.md § Unrecognized input, and four new error fixtures cover it.
12 changes: 12 additions & 0 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ Empty is not an encoding of anything — nor is a literal `null`. Absence is `40

The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 covers a request that couldn't be parsed at all, or that is well-formed but addresses something that doesn't exist (an undefined `typeId`, a malformed TypeId, search text the engine cannot parse); a 422 means the server understood the request and what it addressed, but the content didn't satisfy the type schema. The distinguishing question is whether there was a schema to fail against: `StackValidationError.details` names the fields that broke, and an unknown type has no such detail to report — `Stack.create()` resolves the type and throws before the validation block runs for exactly this reason.

### Unrecognized input

**A query param or JSON body key an endpoint does not define is refused with 400 (`bad_request`), never ignored.** Ignoring it answers a different request than the one sent, and the difference is always in the direction the caller didn't ask for: a misspelled filter widens a query, a misspelled `purge` soft-deletes, a misspelled field on a token request mints a token for someone else. A 400 tells the caller at once; a 200 for the wrong request never does. The rule holds at every depth of a body: a filter's `createdBy`, a sort, a relationship target. The same goes for values: a boolean param takes only `true` or `false`, and a param that names one value appears at most once, since reading a repeat as its first value ignores the rest.

Three things sit outside it:

- **Keys an endpoint defines as ignored are not unrecognized.** A create body is a whole record, so it may carry every key a wire record has, and the server-assigned ones are dropped as [Records](#records) requires rather than refused.
- **Headers.** A request carries headers the application never sees — proxies and browsers add them — so an unrecognized one is ignored, as is `If-Match` on the association endpoints (see [Records](#records)).
- **Responses.** A client reading a server's response ignores what it doesn't recognize, the way it ignores an [unrecognized frame](./change-feed.md). Strictness is the server's side of the contract, where the input is the client's intent.

A server built on core reaches this through the wire parsers in `@haverstack/core/wire` — `parseQueryParams()`, `parseQueryBody()`, `parseChangeParams()`, `parseJournalParams()`, `createOptionsFromWireRecord()` and `changesFromWireBody()` — each of which refuses what its endpoint does not define. Endpoints a server parses itself — `/auth/token` among them — owe the same refusal.

### The taxonomy root

Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`, `UseAfterCloseError`, `InvalidAdapterError`, `RelayScopeError`) stay outside the hierarchy for that reason.
Expand Down
13 changes: 10 additions & 3 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,10 +741,17 @@ const ERROR_CLASS_FOR_CODE = {
/**
* Fixtures no conformant client can originate: `QuerySort` is a union, so
* a request naming both sort parameters is unrepresentable before it ever
* reaches the wire. The rule is a server's to enforce, and the fixture
* exists for server implementations to test against.
* reaches the wire, and APIAdapter never sends a name the wire does not
* define. The rules are a server's to enforce, and the fixtures exist for
* server implementations to test against.
*/
const SERVER_ONLY_ERROR_FIXTURES = new Set(['error-bad-request-both-sort-parameters']);
const SERVER_ONLY_ERROR_FIXTURES = new Set([
'error-bad-request-both-sort-parameters',
'error-bad-request-unknown-query-param',
'error-bad-request-non-boolean-param',
'error-bad-request-unknown-query-body-key',
'error-bad-request-unknown-record-key',
]);

describe('error response fixtures', () => {
for (const fixture of errorResponseFixtures.filter(
Expand Down
60 changes: 60 additions & 0 deletions packages/conformance-fixtures/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,66 @@ export const errorResponseFixtures: ConformanceFixture<unknown, WireError>[] = [
},
},
},
{
name: 'error-bad-request-unknown-query-param',
description:
'A query param the endpoint does not define returns 400 with code "bad_request" rather ' +
'than being ignored: an ignored filter param answers a wider query than the one sent, ' +
'with a 200 that never says so. The same holds on every endpoint. See ' +
'docs/spec/wire-format.md § Unrecognized input.',
method: 'GET',
path: '/records?authorId=did%3Akey%3Az6MkMember',
responseStatus: 400,
responseBody: {
error: { code: 'bad_request', message: 'Unknown query param: authorId' },
},
},
{
name: 'error-bad-request-non-boolean-param',
description:
'A boolean query param takes only "true" or "false"; any other value returns 400 with ' +
'code "bad_request" rather than reading as false. See docs/spec/wire-format.md ' +
'§ Unrecognized input.',
method: 'GET',
path: '/records?includeDeleted=1',
responseStatus: 400,
responseBody: {
error: {
code: 'bad_request',
message: 'Invalid includeDeleted: expected true or false, got "1"',
},
},
},
{
name: 'error-bad-request-unknown-query-body-key',
description:
'POST /records/query refuses a key it does not define at any depth of the body — here ' +
'inside filter.createdBy — with 400 / code "bad_request". See docs/spec/wire-format.md ' +
'§ Unrecognized input.',
method: 'POST',
path: '/records/query',
requestBody: { filter: { createdBy: { authorId: 'did:key:z6MkMember' } } },
responseStatus: 400,
responseBody: {
error: { code: 'bad_request', message: 'Unknown key in filter.createdBy: authorId' },
},
},
{
name: 'error-bad-request-unknown-record-key',
description:
'POST /records accepts every key a wire record carries — dropping the server-assigned ' +
'ones — and refuses any other with 400 / code "bad_request". See ' +
'docs/spec/wire-format.md § Unrecognized input.',
method: 'POST',
path: '/records',
requestBody: {
typeId: 'com.example/note@1',
content: { title: 'Hello' },
title: 'Hello',
},
responseStatus: 400,
responseBody: { error: { code: 'bad_request', message: 'Unknown record key: title' } },
},
{
name: 'error-validation-failed',
description:
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/wire-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
AuthorityAssociation,
DataAssociation,
RecordChangeSet,
StackRecord,
TokenSession,
TypeId,
} from './types.js';
Expand All @@ -35,6 +36,29 @@ export type WireCreateRequest = {
options: Omit<BackdatableCreateRecordOptions, 'createdBy'>;
};

/**
* Every top-level key a wire record carries — a `StackRecord`'s, so a new
* field fails to compile here until it is listed. A create body is a whole
* record, so each of these is accepted — stamped ones are then dropped —
* and anything else is refused. See docs/spec/wire-format.md § Unrecognized input.
*/
const WIRE_RECORD_KEYS: readonly string[] = Object.keys({
id: true,
typeId: true,
createdAt: true,
updatedAt: true,
content: true,
version: true,
parentId: true,
appId: true,
createdBy: true,
updatedBy: true,
deletedAt: true,
unlistedAt: true,
permissions: true,
associations: true,
} satisfies Record<keyof StackRecord, true>);

function requireBody(body: unknown): Record<string, unknown> {
if (typeof body !== 'object' || body === null || Array.isArray(body))
throw new StackBadRequestError('Invalid record body: expected an object');
Expand Down Expand Up @@ -94,6 +118,11 @@ export function createOptionsFromWireRecord(
ownerEntityId: EntityId,
): WireCreateRequest {
const record = requireBody(body);
const unknown = Object.keys(record).filter((key) => !WIRE_RECORD_KEYS.includes(key));
if (unknown.length > 0)
throw new StackBadRequestError(
`Unknown record key${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`,
);

const typeId = record.typeId;
if (typeof typeId !== 'string' || typeId === '')
Expand Down
Loading
Loading