Skip to content

Commit f1f40b4

Browse files
authored
refactor(service-settings)!: settings error bodies use the declared details slot (#4224) (#4237)
Four `/api/settings/*` error branches spread ad-hoc context as siblings of `code` and `message` inside `error`. `ApiErrorSchema` declares none of `namespace`, `key`, `reason`, `fields`. The bodies passed every gate anyway: the schema is a plain `z.object` so unknown keys were STRIPPED rather than rejected, and `envelopeViolations` inspects only the body's top level. Conformant by stripping, not by declaration. All four move into the declared `details` slot, values unchanged: SETTINGS_FORBIDDEN error.namespace -> error.details.namespace UNKNOWN_KEY error.namespace/.key -> error.details.{namespace,key} SETTINGS_LOCKED error.namespace/.key/.reason -> error.details.{namespace,key,reason} SETTINGS_VALIDATION error.namespace/.fields -> error.details.{namespace,fields} `SETTINGS_VALIDATION.fields` also changes shape — the decision #4224 asked for. `fields` is the name ADR-0114 (#3977) closed for `FieldError[]`, so keeping a `Record<key, message>` under it would leave one spelling meaning two shapes. It becomes the declared array with `code` from the closed field-level catalog (`required` / `invalid_format`) and `constraint.pattern` carrying what the message interpolates. `sendError`'s last parameter is tightened from `Record<string, unknown>` to `ApiError`'s own optional fields, and `code` to the closed ADR-0112 `ErrorCode` union — an undeclared sibling is now a compile error rather than a key that evaporates at the schema boundary. The conformance suite pins that every key of `body.error` is one `ApiErrorSchema.shape` declares. Console-side tolerant dual-read landed first in objectui#3079.
1 parent bf478e1 commit f1f40b4

7 files changed

Lines changed: 317 additions & 28 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/service-settings": minor
3+
---
4+
5+
refactor!: settings error bodies stop hanging undeclared keys beside `code`/`message` (#4224)
6+
7+
Four `/api/settings/*` error branches spread ad-hoc context as SIBLINGS of `code`
8+
and `message` inside `error`. `ApiErrorSchema` declares `code`, `message`,
9+
`category?`, `httpStatus?`, `details?`, `requestId?` — and none of `namespace`,
10+
`key`, `reason`, `fields`. The bodies passed every gate anyway: `ApiErrorSchema`
11+
is a plain `z.object`, so unknown keys were **stripped** rather than rejected,
12+
and `envelopeViolations` inspects only the body's top level. They were conformant
13+
*by stripping*, not by declaration. The same module already used the declared
14+
slot correctly one branch over (`SETTINGS_ACTION_FAILED``error.details`), so
15+
this is one file speaking two dialects, not a missing capability.
16+
17+
**Wire change — FROM → TO.** In every case the values are unchanged; only their
18+
position moves, into the `details` slot the contract declares:
19+
20+
| Code | HTTP | FROM | TO |
21+
|---|---|---|---|
22+
| `SETTINGS_FORBIDDEN` | 403 | `error.namespace` | `error.details.namespace` |
23+
| `UNKNOWN_KEY` | 400 | `error.namespace`, `error.key` | `error.details.namespace`, `error.details.key` |
24+
| `SETTINGS_LOCKED` | 409 | `error.namespace`, `error.key`, `error.reason` | `error.details.namespace`, `error.details.key`, `error.details.reason` |
25+
| `SETTINGS_VALIDATION` | 400 | `error.namespace`, `error.fields` | `error.details.namespace`, `error.details.fields` |
26+
27+
**One-line fix for a consumer:** read `error.details.<key>` where you read
28+
`error.<key>`, or `error.details?.<key> ?? error.<key>` if you support servers on
29+
both sides of the change. The console's own fix (objectui#3078) is the tolerant
30+
form.
31+
32+
**`SETTINGS_VALIDATION.fields` also changes shape**, because `fields` is the name
33+
ADR-0114 (#3977) closed for `FieldError[]` and keeping a map under it would leave
34+
one spelling meaning two shapes:
35+
36+
- **FROM** `{ [key]: message }` — a `Record<string, string>`, the constraint named
37+
only in the prose of the message.
38+
- **TO** `FieldError[]``{ field, code, message, label, constraint? }`, where
39+
`code` is a member of the closed field-level catalog: `required` for an empty
40+
required specifier, `invalid_format` for a value that misses its declared
41+
`pattern` (which travels as `constraint.pattern`).
42+
43+
A consumer that rendered the map's values reads `f.message` per entry instead;
44+
one that wants to branch on *why* a value was rejected can now read `f.code`
45+
rather than substring-matching English. objectui's `extractFieldErrors` already
46+
reads `details.fields`, so settings validation failures become renderable
47+
per-field there with no further change.
48+
49+
**The exported `SettingsValidationError.fields` changes with it** — same
50+
`Record<string, string>``FieldError[]` mapping — since the route only relays
51+
what the service throws, and the constraint kind is knowable at the throw site
52+
and nowhere after it.
53+
54+
`sendError`'s last parameter is tightened from `extra?: Record<string, unknown>`
55+
to `ApiError`'s own optional fields, and its `code` from `string` to the closed
56+
ADR-0112 `ErrorCode` union. That is what keeps this fixed: an undeclared sibling
57+
is now a compile error at the call site rather than a key that quietly evaporates
58+
at the schema boundary.

content/docs/kernel/runtime-services/settings-service.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ services.settings.subscribe(namespace: string | undefined, handler: SettingsChan
2626
- `SETTINGS_LOCKED` (`SettingsLockedError`)
2727
- `SETTINGS_UNKNOWN_NAMESPACE` (`UnknownNamespaceError`)
2828
- `SETTINGS_UNKNOWN_KEY` (`UnknownKeyError`)
29-
- `SETTINGS_VALIDATION` (`SettingsValidationError`) — thrown by `set` / `setMany` when a write would leave a visible `required` specifier empty or violate its `pattern`
29+
- `SETTINGS_VALIDATION` (`SettingsValidationError`) — thrown by `set` / `setMany` when a write would leave a visible `required` specifier empty or violate its `pattern`. Its `fields` is a `FieldError[]` (ADR-0114), one entry per offending key, with `code` naming the constraint: `required` or `invalid_format`.
30+
31+
Over REST, each of these puts its context in the declared `error.details` slot — `{ namespace, key, reason, fields }` as the branch carries them — never as siblings of `error.code` / `error.message` (#4224).
3032
3133
## Notes
3234

packages/services/service-settings/src/envelope.conformance.test.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*/
3333

3434
import { describe, expect, it, vi } from 'vitest';
35-
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
35+
import { ApiErrorSchema, BaseResponseSchema, FieldErrorSchema, envelopeViolations } from '@objectstack/spec/api';
3636
import { SettingsNamespacePayloadSchema } from '@objectstack/spec/system';
3737
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
3838
import { SettingsService } from './settings-service';
@@ -288,10 +288,162 @@ describe('settings envelope (#3843) — error bodies', () => {
288288
// bare-string dialect the sibling modules carried cannot land quietly.
289289
expect(typeof body.error).not.toBe('string');
290290
expect(body.code).toBeUndefined();
291+
292+
// [#4224] No key inside `error` that `ApiErrorSchema` does not declare.
293+
//
294+
// This is the assertion neither gate above can make. `safeParse` STRIPS
295+
// unknown keys (`ApiErrorSchema` is a plain `z.object`), so it passed
296+
// `error.namespace` / `.key` / `.reason` / `.fields` for as long as this
297+
// module emitted them; `envelopeViolations` deliberately inspects only the
298+
// body's top level. Between them a body could carry four undeclared keys
299+
// and read as fully conformant — conformant *by stripping*, which is not
300+
// the same claim as conformant by declaration.
301+
//
302+
// Derived from `ApiErrorSchema.shape` rather than a hand-written list, so
303+
// a field added to the contract is allowed here the moment it is declared,
304+
// and one removed stops being allowed — the failure mode of a restated
305+
// list is that it silently keeps blessing a retired key.
306+
expect(
307+
Object.keys(body.error).filter((k) => !(k in (ApiErrorSchema as any).shape)),
308+
`error carries keys ApiErrorSchema does not declare: ${JSON.stringify(body.error)}`,
309+
).toEqual([]);
291310
});
292311
}
293312
});
294313

314+
describe('settings envelope (#4224) — the four ad-hoc keys travel in the declared slot', () => {
315+
/**
316+
* Each of these branches used to spread its context as SIBLINGS of `code` and
317+
* `message`. They now use `error.details`, the slot `ApiErrorSchema` declares
318+
* for exactly this and the one `SETTINGS_ACTION_FAILED` was already using one
319+
* branch over — so the module speaks one dialect rather than two.
320+
*
321+
* Both directions are asserted per case: the value is under `details`, AND the
322+
* old top-level spelling is gone. Asserting only the first would pass a body
323+
* that emitted both, which is how a "migration" quietly becomes a permanent
324+
* dual-write.
325+
*/
326+
const CASES: Array<{
327+
name: string;
328+
status: number;
329+
code: string;
330+
details: Record<string, unknown>;
331+
gone: string[];
332+
run: () => Promise<Captured>;
333+
}> = [
334+
{
335+
name: 'SETTINGS_FORBIDDEN carries its namespace',
336+
status: 403,
337+
code: 'SETTINGS_FORBIDDEN',
338+
details: { namespace: 'branding' },
339+
gone: ['namespace'],
340+
run: async () => {
341+
const { http } = mount(anon);
342+
return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } });
343+
},
344+
},
345+
{
346+
name: 'UNKNOWN_KEY carries its namespace and key',
347+
status: 400,
348+
code: 'UNKNOWN_KEY',
349+
details: { namespace: 'branding', key: 'not_a_key' },
350+
gone: ['namespace', 'key'],
351+
run: async () => {
352+
const { http } = mount();
353+
return drive(http, 'PUT /api/settings/:namespace', {
354+
params: { namespace: 'branding' },
355+
body: { not_a_key: 1 },
356+
});
357+
},
358+
},
359+
{
360+
name: 'SETTINGS_LOCKED carries its namespace, key and reason',
361+
status: 409,
362+
code: 'SETTINGS_LOCKED',
363+
details: { namespace: 'branding', key: 'workspace_name', reason: 'locked-by-env' },
364+
gone: ['namespace', 'key', 'reason'],
365+
run: async () => {
366+
// An `OS_BRANDING_WORKSPACE_NAME` in the environment locks the key, so a
367+
// write to it is refused — the one branch that needs a locked namespace.
368+
const { http } = mount(admin, { OS_BRANDING_WORKSPACE_NAME: 'Locked Co' });
369+
return drive(http, 'PUT /api/settings/:namespace', {
370+
params: { namespace: 'branding' },
371+
body: { workspace_name: 'Acme' },
372+
});
373+
},
374+
},
375+
];
376+
377+
for (const c of CASES) {
378+
it(`${c.name} under error.details, not beside code/message`, async () => {
379+
const { status, body } = await c.run();
380+
expect(status).toBe(c.status);
381+
expect(body.error.code).toBe(c.code);
382+
expect(body.error.details).toMatchObject(c.details);
383+
for (const k of c.gone) {
384+
expect(body.error[k], `error.${k} is still a sibling of code/message`).toBeUndefined();
385+
}
386+
});
387+
}
388+
});
389+
390+
describe('settings envelope (#4224) — SETTINGS_VALIDATION speaks the field-level vocabulary', () => {
391+
/**
392+
* The decision #4224 asked for: `fields` was a `Record<key, message>` hung
393+
* beside `code`, and `fields` is the name ADR-0114 (#3977) closed for
394+
* `FieldError[]`. Keeping the map under that name would have left one spelling
395+
* meaning two shapes — so it became the declared array, in the declared slot.
396+
*/
397+
const lockedPattern = () => {
398+
const { http, service } = mount();
399+
service.registerManifest({
400+
namespace: 'validated',
401+
label: 'Validated',
402+
writePermission: 'setup.write',
403+
readPermission: 'setup.access',
404+
specifiers: [
405+
{ key: 'model', type: 'text', label: 'Model', pattern: '^[a-z]+/[a-z]+$', description: 'Use provider/model.' },
406+
{ key: 'token', type: 'text', label: 'API token', required: true },
407+
],
408+
} as any);
409+
return http;
410+
};
411+
412+
it('every entry parses as the declared FieldError', async () => {
413+
const http = lockedPattern();
414+
const { status, body } = await drive(http, 'PUT /api/settings/:namespace', {
415+
params: { namespace: 'validated' },
416+
body: { model: 'gpt-4o', token: '' },
417+
});
418+
expect(status).toBe(400);
419+
expect(body.error.code).toBe('SETTINGS_VALIDATION');
420+
421+
const fields = body.error.details?.fields;
422+
expect(Array.isArray(fields), `details.fields is not an array: ${JSON.stringify(body.error)}`).toBe(true);
423+
expect(fields.length).toBeGreaterThan(0);
424+
for (const f of fields) {
425+
const parsed = FieldErrorSchema.safeParse(f);
426+
expect(parsed.success, `not a FieldError: ${JSON.stringify(parsed.error ?? f)}`).toBe(true);
427+
}
428+
// The codes come from the closed ADR-0114 catalog, so a consumer can branch
429+
// on the constraint instead of substring-matching the message.
430+
expect(fields.map((f: any) => f.code).sort()).toEqual(['invalid_format', 'required']);
431+
});
432+
433+
it('the pre-#4224 map is gone from both of its old spellings', async () => {
434+
const http = lockedPattern();
435+
const { body } = await drive(http, 'PUT /api/settings/:namespace', {
436+
params: { namespace: 'validated' },
437+
body: { token: '' },
438+
});
439+
// Not a sibling of code/message any more …
440+
expect(body.error.fields).toBeUndefined();
441+
expect(body.error.namespace).toBeUndefined();
442+
// … and not the `key → message` object under its new home either.
443+
expect(Array.isArray(body.error.details.fields)).toBe(true);
444+
});
445+
});
446+
295447
describe('settings envelope (#3843) — a reported action failure keeps its detail', () => {
296448
it('carries the whole SettingsActionResult under error.details', async () => {
297449
const { http } = mount();

packages/services/service-settings/src/settings-routes.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* `SettingsService`.
1414
*/
1515

16+
import type { ApiError, ErrorCode } from '@objectstack/spec/api';
1617
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
1718
import { SettingsService } from './settings-service.js';
1819
import {
@@ -57,8 +58,31 @@ const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: tru
5758
* response, success and error alike, and a caller keying on `success` (as
5859
* `ObjectStackClient.unwrapResponse` does) could not tell these routes' bodies
5960
* apart from an already-unwrapped payload.
61+
*
62+
* ## Why the last parameter is the declared fields, not a `Record` (#4224)
63+
*
64+
* It used to be `extra?: Record<string, unknown>`, spread straight into `error`,
65+
* and four branches used it to hang `namespace` / `key` / `reason` / `fields`
66+
* beside `code` and `message`. `ApiErrorSchema` declares none of those. The
67+
* bodies still passed every gate — `ApiErrorSchema` is a plain `z.object`, so
68+
* unknown keys were STRIPPED rather than rejected, and `envelopeViolations`
69+
* inspects only the body's top level — which made them conformant *by
70+
* stripping* rather than by declaration, and made a `safeParse` pass mean less
71+
* than it looked like it meant.
72+
*
73+
* Typing the parameter as `ApiError`'s own optional fields is what stops that
74+
* coming back: an undeclared sibling is now a compile error at the call site
75+
* instead of a key that quietly evaporates at the schema boundary. `code` is
76+
* likewise the closed ADR-0112 `ErrorCode` union rather than `string`, so an
77+
* unregistered code fails to typecheck rather than to parse.
6078
*/
61-
function sendError(res: IHttpResponse, status: number, code: string, message: string, extra?: Record<string, unknown>) {
79+
function sendError(
80+
res: IHttpResponse,
81+
status: number,
82+
code: ErrorCode,
83+
message: string,
84+
extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>,
85+
) {
6286
res.status(status).json({ success: false, error: { code, message, ...extra } });
6387
}
6488

@@ -91,7 +115,7 @@ export function registerSettingsRoutes(
91115
sendOk(res, { manifests });
92116
} catch (err: any) {
93117
if (err instanceof SettingsForbiddenError) {
94-
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
118+
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } });
95119
} else {
96120
sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to list manifests');
97121
}
@@ -106,7 +130,7 @@ export function registerSettingsRoutes(
106130
sendOk(res, payload);
107131
} catch (err: any) {
108132
if (err instanceof SettingsForbiddenError) {
109-
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
133+
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } });
110134
} else if (err instanceof UnknownNamespaceError) {
111135
sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message);
112136
} else {
@@ -144,21 +168,28 @@ export function registerSettingsRoutes(
144168
sendOk(res, { values: result });
145169
} catch (err: any) {
146170
if (err instanceof SettingsForbiddenError) {
147-
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
171+
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } });
148172
} else if (err instanceof SettingsLockedError) {
149173
sendError(res, 409, 'SETTINGS_LOCKED', err.message, {
150-
namespace: err.namespace,
151-
key: err.key,
152-
reason: err.reason,
174+
details: { namespace: err.namespace, key: err.key, reason: err.reason },
153175
});
154176
} else if (err instanceof UnknownNamespaceError) {
155177
sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message);
156178
} else if (err instanceof UnknownKeyError) {
157-
sendError(res, 400, 'UNKNOWN_KEY', err.message, { namespace: err.namespace, key: err.key });
179+
sendError(res, 400, 'UNKNOWN_KEY', err.message, {
180+
details: { namespace: err.namespace, key: err.key },
181+
});
158182
} else if (err instanceof SettingsValidationError) {
183+
// `details.fields` is `FieldError[]` (ADR-0114) — the same array shape
184+
// the record validators and the dispatcher's validation exit carry, so
185+
// the console's field-error extractor reads this one with no per-surface
186+
// special case. It is `details.fields` rather than a top-level
187+
// `error.fields` because `fields` is declared on
188+
// `EnhancedApiErrorSchema`, not on the base `ApiErrorSchema` these
189+
// routes emit; `validation-failure.ts` puts the array in the same place
190+
// for the same reason.
159191
sendError(res, 400, 'SETTINGS_VALIDATION', err.message, {
160-
namespace: err.namespace,
161-
fields: err.fields,
192+
details: { namespace: err.namespace, fields: err.fields },
162193
});
163194
} else {
164195
sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to write namespace');
@@ -190,7 +221,7 @@ export function registerSettingsRoutes(
190221
}
191222
} catch (err: any) {
192223
if (err instanceof SettingsForbiddenError) {
193-
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace });
224+
sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } });
194225
} else if (err instanceof UnknownNamespaceError) {
195226
sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message);
196227
} else {

packages/services/service-settings/src/settings-service.test.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,15 @@ describe('SettingsService — save-time validation (required/visible/pattern)',
311311
}),
312312
).rejects.toMatchObject({
313313
code: 'SETTINGS_VALIDATION',
314-
fields: { cloudflare_api_key: expect.stringContaining('required') },
314+
// `FieldError[]` since #4224 — the constraint is named by `code`, not
315+
// only described in the prose of `message` (ADR-0114).
316+
fields: [
317+
{
318+
field: 'cloudflare_api_key',
319+
code: 'required',
320+
message: expect.stringContaining('required'),
321+
},
322+
],
315323
});
316324
// Nothing was persisted — the batch is atomic.
317325
expect((await svc.get('ai', 'provider')).source).toBe('default');
@@ -321,10 +329,10 @@ describe('SettingsService — save-time validation (required/visible/pattern)',
321329
const svc = aiService();
322330
await expect(svc.setMany('ai', { provider: 'cloudflare' })).rejects.toMatchObject({
323331
code: 'SETTINGS_VALIDATION',
324-
fields: {
325-
cloudflare_account_id: expect.any(String),
326-
cloudflare_api_key: expect.any(String),
327-
},
332+
fields: expect.arrayContaining([
333+
expect.objectContaining({ field: 'cloudflare_account_id', code: 'required' }),
334+
expect.objectContaining({ field: 'cloudflare_api_key', code: 'required' }),
335+
]),
328336
});
329337
});
330338

@@ -357,7 +365,16 @@ describe('SettingsService — save-time validation (required/visible/pattern)',
357365
svc.setMany('ai', { provider: 'gateway', gateway_model: 'gpt-4o' }),
358366
).rejects.toMatchObject({
359367
code: 'SETTINGS_VALIDATION',
360-
fields: { gateway_model: expect.stringContaining('format') },
368+
// A pattern miss is `invalid_format`, and the pattern it missed travels
369+
// as a discrete `constraint` rather than only inside the sentence.
370+
fields: [
371+
{
372+
field: 'gateway_model',
373+
code: 'invalid_format',
374+
message: expect.stringContaining('format'),
375+
constraint: { pattern: expect.any(String) },
376+
},
377+
],
361378
});
362379
await expect(
363380
svc.setMany('ai', { provider: 'gateway', gateway_model: 'anthropic/claude-sonnet-4.6' }),

0 commit comments

Comments
 (0)