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
8 changes: 8 additions & 0 deletions .changeset/wire-parsers-remaining-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@haverstack/core': minor
'@haverstack/conformance-fixtures': minor
---

`@haverstack/core/wire` adds a parser for every remaining endpoint core specifies, so a server no longer keeps its own list of their param and field names: `parseAuthChallengeBody()`, `parseAuthTokenBody()`, `parseEntityPatchBody()`, `parseTypeBody()`, `parseMigrationBody()`, `parseDeleteParams()`, `parseDownloadParams()` and `parseAssociationParams()`. Each refuses an unknown name or a malformed boolean with `StackBadRequestError`, and a known body field of the wrong type with `StackValidationError`. Two error fixtures pin a non-boolean `purge` and an unknown key on `POST /auth/token`.

`Stack` refuses an association, permission or grant-target element carrying a key its kind does not define, at every depth, with `StackBadRequestError`. This applies on every write that takes an element, on a `relatedTo` filter target, and on `grantType()`, `revokeType()` and `listTypeGrants()`. A `_grant` record's `grantee` is held to the same keys and refused with `StackValidationError`. A new error fixture pins an unknown key on a permission grantee.
2 changes: 2 additions & 0 deletions docs/spec/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ type RelationshipTarget =

**Association identity is `(kind, label)` plus the payload that names a referent** — `fileId` for an attachment, the target for a relationship. An attachment's `attachmentRecordId` is outside it: the pointer [annotates a reference rather than naming one](./attachments.md#naming-the-upload-a-reference-came-from). Identity is what `dissociate()` matches on, what a second `associate()` of the same reference lands on, and the primary key every adapter stores an association under.

**An element carries only the keys its kind defines**, at every depth: the keys in the types above, a relationship target's keys for its own `kind`, and a permission grantee's for its own (see [Access control § Record-level permissions](./access-control.md#record-level-permissions)). Any other key is refused with `StackBadRequestError` (wire: **400**) on every write that takes an element — `create()`, `mutate()`, `associate()`, `dissociate()`, `grantAccess()`, `revokeAccess()` — and on a `relatedTo` filter target. A key from another arm counts as unknown: `role` on an entity grantee is refused, not ignored. An adapter stores only the keys it has a place for, so an extra key would otherwise answer 200 and then disappear. The same holds for a [type-level grant](./access-control.md#type-level-grants) target given to `grantType()`, `revokeType()` or `listTypeGrants()`, and for a `_grant` record's `grantee`, which as content is refused with `StackValidationError` (**422**).

**An association list holds distinct identities.** A list naming one identity twice — `create()`'s `associations`, or a change set's — is refused with `StackValidationError` (wire: **400**), not collapsed to the last entry. Two entries under one identity describe a state no store can hold, since a store keys them; accepting the list would leave which of the two the record ends up with to whichever adapter is underneath, and would leave [a journal entry](./journal.md#the-entry) reporting a prior state twice with no way to say which edit displaced it. Every way of producing such a list is a caller bug, which is why it is refused on the same terms as [an empty change set](#mutations).

### Reparenting
Expand Down
27 changes: 25 additions & 2 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,34 @@ The distinction between **400** and **422** matters for write endpoints (`POST /

### 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.
**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, an association or permission element (see [Data model § Associations](./data-model.md#associations)). 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.
A server built on core reaches this through the wire parsers in `@haverstack/core/wire`, one per endpoint that takes input, each of which refuses what its endpoint does not define:

| Endpoint | Parser |
| ------------------------------- | ------------------------------- |
| `GET /records` | `parseQueryParams()` |
| `POST /records/query` | `parseQueryBody()` |
| `POST /records` | `createOptionsFromWireRecord()` |
| `PATCH /records/:id` | `changesFromWireBody()` |
| `DELETE /records/:id` | `parseDeleteParams()` |
| `POST /records/:id/migrate` | `parseMigrationBody()` |
| `GET /records/:id/journal` | `parseJournalParams()` |
| `GET /records/:id/associations` | `parseAssociationParams()` |
| `GET /changes` | `parseChangeParams()` |
| `GET /attachments/:fileId` | `parseDownloadParams()` |
| `POST /types` | `parseTypeBody()` |
| `PATCH /entity` | `parseEntityPatchBody()` |
| `POST /auth/challenge` | `parseAuthChallengeBody()` |
| `POST /auth/token` | `parseAuthTokenBody()` |

The body parsers split errors the way [Records](#records) does for a create: a body that is not an object, carries a key its endpoint does not define, or lacks a field the endpoint requires is not that request at all, so it is **400**; a known field whose value is the wrong type is **422**, carrying the field's path. Endpoints a server defines beyond this spec owe the same refusal, parsed by the server itself.

### The taxonomy root

Expand Down Expand Up @@ -577,6 +596,8 @@ GET /types/:id — get one type definition (id is URL-encoded)
POST /types — register a type, or evolve an existing one in place
```

**The body is a whole Type**, as `GET /types/:id` returns one. `id`, `name` and `schema` are read, as is `migratesFrom` when present; `baseId`, `version`, `schemaHash` and `createdAt` are accepted and ignored, since `defineType()` derives or stamps each of them. Any other key is refused (see [Unrecognized input](#unrecognized-input)).

`POST /types` on an `id` that already has a stored Type runs the same [schema drift check](./data-model.md#schema-drift-detection) as `Stack.defineType()` — the server-side storage layer never blindly overwrites a Type definition; legality is decided once, in the same invariant layer both the local and wire paths share.

**A malformed schema answers 422** (code `validation`) rather than failing inside the server: a body here is parsed JSON, so a definition that is not an object, one naming no `kind` or an unrecognized one, a non-boolean `required`/`open`, and a container declaring neither its interior nor `open` — or both — are each reported against the field they sit on. See [Data model § Types](./data-model.md#types).
Expand Down Expand Up @@ -674,6 +695,8 @@ PATCH /entity — update it

A convenience alias for the owner entity rather than requiring clients to look it up by ID.

**The `PATCH` body is `{ "contentPatch": { … } }`** and nothing else, merged at the top level exactly as the same key on `PATCH /records/:id` is — omitted keeps, `null` removes. It carries that key's name because `content` on the wire means whole content, as on `POST /records` and `POST /records/:id/migrate`; see [Data model § Mutations](./data-model.md#mutations). The records envelope's other keys, and `content`, are refused with **400** like any other unrecognized key (see [Unrecognized input](#unrecognized-input)); a missing or non-object `contentPatch` is **400** and **422** respectively.

## Change feed

`GET /changes` streams record changes over SSE. Its frames, resumption rules and the obligations that fall on a server are [Change feed](./change-feed.md); the model it encodes is [Change events](./events.md).
3 changes: 3 additions & 0 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,9 @@ const SERVER_ONLY_ERROR_FIXTURES = new Set([
'error-bad-request-non-boolean-param',
'error-bad-request-unknown-query-body-key',
'error-bad-request-unknown-record-key',
'error-bad-request-unknown-grantee-key',
'error-bad-request-non-boolean-purge',
'error-bad-request-unknown-auth-token-key',
]);

describe('error response fixtures', () => {
Expand Down
53 changes: 53 additions & 0 deletions packages/conformance-fixtures/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2196,6 +2196,59 @@ export const errorResponseFixtures: ConformanceFixture<unknown, WireError>[] = [
responseStatus: 400,
responseBody: { error: { code: 'bad_request', message: 'Unknown record key: title' } },
},
{
name: 'error-bad-request-unknown-grantee-key',
description:
'A permission element carries only the keys its grantee kind defines; any other — here a ' +
'scope on an entity grantee — returns 400 with code "bad_request" and grants nothing. ' +
'Ignored, it would store a narrower-looking grant than the one that applies. See ' +
'docs/spec/data-model.md § Associations.',
method: 'POST',
path: '/records/1hk153x00001/permissions',
requestBody: {
kind: 'permission',
label: 'read',
grantee: { kind: 'entity', entityId: 'did:key:z6MkMember', scope: 'comments' },
},
responseStatus: 400,
responseBody: {
error: { code: 'bad_request', message: 'Unknown key in permission.grantee: scope' },
},
},
{
name: 'error-bad-request-non-boolean-purge',
description:
'DELETE /records/:id takes purge only as "true" or "false"; any other value returns 400 ' +
'with code "bad_request" and deletes nothing. Read as false, a purge the caller meant ' +
'becomes a soft delete that answers 200. See docs/spec/wire-format.md § Unrecognized input.',
method: 'DELETE',
path: '/records/1hk153x00001?purge=1',
responseStatus: 400,
responseBody: {
error: { code: 'bad_request', message: 'Invalid purge: expected true or false, got "1"' },
},
},
{
name: 'error-bad-request-unknown-auth-token-key',
description:
'POST /auth/token takes did, nonce and signature and nothing else; any other key — here ' +
'a subjectId naming whom the token should act for — returns 400 with code "bad_request" ' +
'and issues no token. Ignoring it would answer with a token the client did not ask for. ' +
'See docs/spec/wire-format.md § Unrecognized input.',
method: 'POST',
path: '/auth/token',
requestBody: {
did: 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum',
nonce: 'k7Qm2ZxRt9vLbNc4Hy8Wf3',
signature:
'CIvHvqS75hEpPDZi7hwLFOMM44-UCMuF5HzZ9_OIAMQvsGAYGsvXXpXQTP3KaPH2qKnQxl2j3xcB_v-axIx8Bg',
subjectId: 'did:key:z6Mktp5FtRqj2M7JxnPz9JWGMCUTE5o3XGt1br11TczKGp7B',
},
responseStatus: 400,
responseBody: {
error: { code: 'bad_request', message: 'Unknown key in auth token body: subjectId' },
},
},
{
name: 'error-validation-failed',
description:
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { baseIdOf } from './schema.js';
import { StackBadRequestError } from './errors.js';
import { assertKnownKeys, GRANTEE_KEYS, unknownKeys } from './query-validation.js';
import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js';
import { carriesRoster, groupRoleFromAssociations } from './access.js';
import type {
Expand Down Expand Up @@ -117,11 +118,14 @@ export function matchesGrantTarget(content: GrantContent, target: GrantQuery): b
* empty groupId or entityId reaches no one, so storing it would leave a
* grant that can only ever deny while looking like a share that worked.
* Read as data, not as the type: a target reaching Stack from a request
* body or an import has whatever shape it arrived with. `allowAny` admits
* body or an import has whatever shape it arrived with, so a key its tier
* does not define is refused too. `allowAny` admits
* the listing-only `role: 'any'`, which grantType() and revokeType() refuse.
*/
export function validateGrantTarget(target: GrantQuery, allowAny = false): void {
const t = target as Partial<Record<'kind' | 'entityId' | 'groupId' | 'role', unknown>> | null;
if (t?.kind === 'authenticated' || t?.kind === 'entity' || t?.kind === 'group')
assertKnownKeys(t, GRANTEE_KEYS[t.kind], 'grant target');
switch (t?.kind) {
case 'authenticated':
return;
Expand Down Expand Up @@ -163,6 +167,14 @@ export function validateGrantee(typeId: TypeId, content: unknown): ValidationErr
const g = c?.grantee as Partial<Record<'kind' | 'entityId' | 'groupId' | 'role', unknown>> | null;
// An absent or non-object grantee is the schema's to refuse, and it does.
if (!g || typeof g !== 'object') return [];
if (g.kind === 'authenticated' || g.kind === 'entity' || g.kind === 'group') {
const unknown = unknownKeys(g, GRANTEE_KEYS[g.kind]);
if (unknown.length > 0)
return unknown.map((key) => ({
path: `grantee.${key}`,
message: `A ${String(g.kind)} grantee does not carry ${key}`,
}));
}
switch (g.kind) {
case 'authenticated':
return [];
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/query-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,20 @@ import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE } from './vali
import type { ValidationError } from './validate.js';
import { NATIVE_SORT_FIELDS } from './types.js';
import type {
AnyoneAssociation,
Association,
AttachmentAssociation,
AuthorityAssociation,
DataAssociation,
EntityTarget,
ExternalTarget,
GrantGrantee,
JournalQuery,
Grantee,
PermissionAssociation,
RecordTarget,
RelationshipAssociation,
TagAssociation,
QuerySort,
RecordFilter,
RelationshipTarget,
Expand Down Expand Up @@ -214,6 +223,60 @@ export function assertSortCapability(
/** The identifier spaces a relationship target may name. */
const TARGET_KINDS = new Set(['record', 'entity', 'external']);

/**
* The keys of one arm of a union, listed as an object so the compiler
* holds the list to the type: a field added to the arm fails to compile
* here until it is listed.
*/
const keysOf = <T>(keys: Record<keyof T, true>): readonly string[] => Object.keys(keys);

/** Every key each relationship target arm defines. */
export const TARGET_KEYS: Record<RelationshipTarget['kind'], readonly string[]> = {
record: keysOf<RecordTarget>({ kind: true, recordId: true, stackUrl: true }),
entity: keysOf<EntityTarget>({ kind: true, entityId: true }),
external: keysOf<ExternalTarget>({ kind: true, ns: true, id: true }),
};

/** Every key each association kind defines. */
const ASSOCIATION_KEYS: Record<Association['kind'], readonly string[]> = {
tag: keysOf<TagAssociation>({ kind: true, label: true }),
attachment: keysOf<AttachmentAssociation>({
kind: true,
label: true,
fileId: true,
attachmentRecordId: true,
}),
relationship: keysOf<RelationshipAssociation>({ kind: true, label: true, target: true }),
permission: keysOf<PermissionAssociation>({ kind: true, label: true, grantee: true }),
anyone: keysOf<AnyoneAssociation>({ kind: true, label: true }),
};

/** Every key each grantee arm defines, the default tier included. */
export const GRANTEE_KEYS: Record<GrantGrantee['kind'], readonly string[]> = {
entity: keysOf<Extract<Grantee, { kind: 'entity' }>>({ kind: true, entityId: true }),
group: keysOf<Extract<Grantee, { kind: 'group' }>>({ kind: true, groupId: true, role: true }),
authenticated: keysOf<Extract<GrantGrantee, { kind: 'authenticated' }>>({ kind: true }),
};

/**
* The keys of `value` its arm does not define. An element carrying one is
* refused rather than stored: an adapter keeps only the keys it has
* columns for, so the rest would answer 200 and then vanish.
* See docs/spec/data-model.md § Associations.
*/
export function unknownKeys(value: object, keys: readonly string[]): string[] {
return Object.keys(value).filter((key) => !keys.includes(key));
}

/** unknownKeys() as a refusal. 400, not 422: the key addresses nothing. */
export function assertKnownKeys(value: object, keys: readonly string[], path: string): void {
const unknown = unknownKeys(value, keys);
if (unknown.length > 0)
throw new StackBadRequestError(
`Unknown key${unknown.length > 1 ? 's' : ''} in ${path}: ${unknown.join(', ')}`,
);
}

/**
* Collect what makes a relationship target malformed. Absence is
* meaningful on `stackUrl` and an external `id` — this stack, and the
Expand All @@ -234,6 +297,7 @@ function targetErrors(
`Unknown relationship target kind "${target.kind}": expected "record", "entity" or "external".`,
);
}
assertKnownKeys(target, TARGET_KEYS[target.kind], path);
if (target.kind === 'record') {
if (!target.recordId) return fail('A record target requires a non-empty recordId.');
if (target.stackUrl !== undefined && !target.stackUrl) {
Expand All @@ -258,6 +322,10 @@ function targetErrors(
* unrecognized kind would otherwise be stored under the one arm that
* names a Record in this stack. See docs/spec/data-model.md
* § Relationship targets.
*
* A key the element's kind does not define is thrown as a 400 rather than
* collected, at every depth: it addresses nothing, the way an unknown key
* on a request body does. See docs/spec/data-model.md § Associations.
*/
export function validateAssociation(
association: Association,
Expand All @@ -271,6 +339,7 @@ export function validateAssociation(
},
];
}
assertKnownKeys(association, ASSOCIATION_KEYS[association.kind], path);
if (association.kind === 'permission') {
return granteeErrors(association, `${path}.grantee`);
}
Expand Down Expand Up @@ -335,6 +404,8 @@ function granteeErrors(
}
const grantee = association.grantee;
if (!grantee || typeof grantee !== 'object') return fail('A permission requires a grantee.');
if (grantee.kind === 'entity' || grantee.kind === 'group')
assertKnownKeys(grantee, GRANTEE_KEYS[grantee.kind], path);
if (grantee.kind === 'entity') {
return grantee.entityId ? [] : fail('An entity grantee requires a non-empty entityId.');
}
Expand Down
Loading
Loading