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

`StackValidationError`'s message header names what was validated: record content keeps "Content validation failed", a type schema reads "Schema validation failed", and argument checks read "Invalid arguments". `associate()`/`dissociate()` and `grantAccess()`/`revokeAccess()` report list errors under `associations`/`permissions`, the parameter the caller passed, rather than `changes`. `deserializeError()` keeps the header a server sent.
8 changes: 4 additions & 4 deletions packages/conformance-fixtures/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2243,7 +2243,7 @@ export const installRequestFixtures: ConformanceFixture<
responseBody: {
error: {
code: 'validation',
message: 'Content validation failed',
message: 'Invalid arguments',
details: [
{
path: 'types[0].id',
Expand Down Expand Up @@ -2719,7 +2719,7 @@ export const errorResponseFixtures: ConformanceFixture<unknown, WireError>[] = [
responseBody: {
error: {
code: 'validation',
message: 'Content validation failed',
message: 'Invalid arguments',
details: [
{
path: 'permissions[0]',
Expand Down Expand Up @@ -2755,7 +2755,7 @@ export const errorResponseFixtures: ConformanceFixture<unknown, WireError>[] = [
responseBody: {
error: {
code: 'validation',
message: 'Content validation failed',
message: 'Invalid arguments',
details: [
{
path: 'permissions[0]',
Expand Down Expand Up @@ -3156,7 +3156,7 @@ export const errorResponseFixtures: ConformanceFixture<unknown, WireError>[] = [
responseBody: {
error: {
code: 'validation',
message: 'Content validation failed',
message: 'Invalid arguments',
details: [
{
path: 'changes[0].association.label',
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,23 @@ export abstract class StackError extends Error {
abstract readonly code: StackErrorCode;
}

export const CONTENT_INVALID = 'Content validation failed';
export const ARGUMENTS_INVALID = 'Invalid arguments';
export const SCHEMA_INVALID = 'Schema validation failed';

/**
* `header` names what was validated — record content, or the arguments a
* call was given — so a refusal of `associate([])` doesn't read as a
* content failure. Only the message varies: `code` is the contract.
*/
export class StackValidationError extends StackError {
static readonly code = 'validation' as const;
override readonly code = StackValidationError.code;
constructor(public readonly errors: ValidationError[]) {
super(
`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
);
constructor(
public readonly errors: ValidationError[],
header: string = CONTENT_INVALID,
) {
super(`${header}:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'));
this.name = 'StackValidationError';
}
}
Expand Down
50 changes: 43 additions & 7 deletions packages/core/src/query-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* See docs/spec/data-model.md § Capability-gated filters.
*/

import { StackBadRequestError, StackValidationError } from './errors.js';
import { ARGUMENTS_INVALID, StackBadRequestError, StackValidationError } from './errors.js';
import { familyIdProblem } from './schema.js';
import { associationEqual, isAuthorityAssociation } from './record-changes.js';
import { CONTENT_SEGMENT_METACHARACTERS, SEGMENT_METACHARACTER_RE } from './validate.js';
Expand Down Expand Up @@ -525,9 +525,10 @@ export function assertAssociationEdits(
half: 'data' | 'authority',
): asserts changes is AssociationEdit[] {
if (!Array.isArray(changes) || changes.length === 0) {
throw new StackValidationError([
{ path: 'changes', message: `${surface} names at least one change.` },
]);
throw new StackValidationError(
[{ path: 'changes', message: `${surface} names at least one change.` }],
ARGUMENTS_INVALID,
);
}
const errors: ValidationError[] = [];
changes.forEach((raw: unknown, i) => {
Expand All @@ -550,7 +551,7 @@ export function assertAssociationEdits(
}
errors.push(...validateAssociation(edit.association as Association, `${path}.association`));
});
if (errors.length > 0) throw new StackValidationError(errors);
if (errors.length > 0) throw new StackValidationError(errors, ARGUMENTS_INVALID);

const associations = (changes as AssociationEdit[]).map((c) => c.association);
if (half === 'data') assertDataAssociations(associations, surface);
Expand All @@ -565,7 +566,42 @@ export function assertAssociationEdits(
});
}
});
if (duplicates.length > 0) throw new StackValidationError(duplicates);
if (duplicates.length > 0) throw new StackValidationError(duplicates, ARGUMENTS_INVALID);
}

/**
* assertAssociationEdits() for the verbs taking a bare association list —
* associate(), grantAccess() and their inverses — asked before the list is
* wrapped as edits, so each problem is reported under `param`, the name the
* caller passed it as, rather than the wrapped list's `changes`. The checks
* run in the same order, so a wrong-surface element is refused as such
* before its duplicates are counted.
*/
export function assertAssociationList(
associations: unknown,
surface: string,
param: string,
half: 'data' | 'authority',
): asserts associations is Association[] {
if (!Array.isArray(associations) || associations.length === 0) {
throw new StackValidationError(
[{ path: param, message: `${surface} names at least one association.` }],
ARGUMENTS_INVALID,
);
}
const errors = associations.flatMap((raw: unknown, i) =>
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? []
: [{ path: `${param}[${i}]`, message: `${param}[${i}] must be an object.` }],
);
if (errors.length > 0) throw new StackValidationError(errors, ARGUMENTS_INVALID);
const list = associations as Association[];
const shapeErrors = list.flatMap((a, i) => validateAssociation(a, `${param}[${i}]`));
if (shapeErrors.length > 0) throw new StackValidationError(shapeErrors, ARGUMENTS_INVALID);
if (half === 'data') assertDataAssociations(list, surface);
else assertAuthorityAssociations(list, surface);
const listErrors = validateAssociations(list, param);
if (listErrors.length > 0) throw new StackValidationError(listErrors, ARGUMENTS_INVALID);
}

/**
Expand Down Expand Up @@ -607,7 +643,7 @@ export function assertValidBaseIdFilter(filter: { baseId?: string | string[] } |
.map((b) => familyIdProblem(b, 'filter.baseId'))
.filter((m): m is string => m !== null)
.map((message) => ({ path: 'filter.baseId', message }));
if (errors.length > 0) throw new StackValidationError(errors);
if (errors.length > 0) throw new StackValidationError(errors, ARGUMENTS_INVALID);
}

/**
Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/record-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { isValidIdFormat, idTimestamp, MAX_ID_TIMESTAMP } from './id.js';
import { StackBadRequestError, StackValidationError } from './errors.js';
import { ARGUMENTS_INVALID, StackBadRequestError, StackValidationError } from './errors.js';
import type { ValidationError } from './validate.js';

// -------------------------------------------------------
Expand Down Expand Up @@ -134,11 +134,14 @@ export function validateIdTimestampSkew(
if (toleranceMs === null) return;
const skew = Math.abs(referenceMs - idTimestamp(id));
if (skew > toleranceMs) {
throw new StackValidationError([
{
path: 'id',
message: `ID "${id}" timestamp disagrees with ${referenceLabel} by more than the allowed clock-skew tolerance (${toleranceMs}ms).`,
},
]);
throw new StackValidationError(
[
{
path: 'id',
message: `ID "${id}" timestamp disagrees with ${referenceLabel} by more than the allowed clock-skew tolerance (${toleranceMs}ms).`,
},
],
ARGUMENTS_INVALID,
);
}
}
5 changes: 5 additions & 0 deletions packages/core/src/scoped-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
} from './errors.js';
import {
assertAssociationEdits,
assertAssociationList,
assertAuthorityAssociations,
assertDataAssociations,
assertSortCapability,
Expand Down Expand Up @@ -1350,6 +1351,7 @@ export class ScopedStack implements StackClient {
* read so it cannot depend on who is asking.
*/
async associate(id: RecordId, associations: DataAssociation[]): Promise<StackRecord> {
assertAssociationList(associations, 'associate()', 'associations', 'data');
return this.amendAssociations(
id,
associations.map((association) => ({ op: 'add', association })),
Expand All @@ -1359,6 +1361,7 @@ export class ScopedStack implements StackClient {

/** See associate() — the same write gate, the same kind refusal. */
async dissociate(id: RecordId, associations: DataAssociation[]): Promise<StackRecord> {
assertAssociationList(associations, 'dissociate()', 'associations', 'data');
return this.amendAssociations(
id,
associations.map((association) => ({ op: 'remove', association })),
Expand Down Expand Up @@ -1394,6 +1397,7 @@ export class ScopedStack implements StackClient {
* See docs/spec/access-control.md § Record-level permissions.
*/
async grantAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise<StackRecord> {
assertAssociationList(permissions, 'grantAccess()', 'permissions', 'authority');
return this.amendAccess(
id,
permissions.map((association) => ({ op: 'add', association })),
Expand All @@ -1403,6 +1407,7 @@ export class ScopedStack implements StackClient {

/** Withdraw elements of who reaches a record — see grantAccess(). */
async revokeAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise<StackRecord> {
assertAssociationList(permissions, 'revokeAccess()', 'permissions', 'authority');
return this.amendAccess(
id,
permissions.map((association) => ({ op: 'remove', association })),
Expand Down
Loading
Loading