Skip to content

Commit 03db5a2

Browse files
committed
fix(console): read the SETTINGS_LOCKED key from error.details, tolerating both shapes (objectstack#4224)
`SettingsView.onSave` rendered its lock toast from `err.payload.error.key`. That key was a SIBLING of `code`/`message` inside `error`, a position `ApiErrorSchema` never declared — it reached the console only because the schema is a plain `z.object` and strips undeclared keys instead of rejecting them, so nothing on either side of the wire ever flagged it. objectstack#4224 moves it to `error.details.key`, the slot the contract does declare. This is the console's half and ships FIRST: the read is now the declared slot with the old position as fallback, so the toast keeps naming the locked key against servers on either side of that change rather than degrading to "Locked by environment: undefined" during the window where the two repos are on different versions. The fallback goes once the oldest supported server carries the fix. It lives in `settings/api.ts` as `lockedKeyOf` rather than inline in the view, because that module already owns what the server's error body looks like (`jsonOrThrow` is what builds `err.payload`) and because a compat shim no test exercises is one the next reader deletes half of. Both branches are pinned, so removing either is a red test naming the server version it breaks. Also stops interpolating a missing key: with neither position carrying one the toast reads "Locked by environment" rather than appending `undefined`. This was the only in-console reader of the four keys objectstack#4224 relocated — a repo-wide grep for `namespace` / `reason` / `fields` in that position finds no consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tv2NYMBTrzVVRWiWWuyec5
1 parent 97c72a0 commit 03db5a2

4 files changed

Lines changed: 125 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@object-ui/console": patch
3+
---
4+
5+
fix(settings): read the locked key from `error.details`, tolerating both wire shapes — objectstack#4224
6+
7+
`SettingsView.onSave` rendered the `SETTINGS_LOCKED` toast from
8+
`err.payload.error.key`. That key was a SIBLING of `code`/`message` inside
9+
`error`, a position `ApiErrorSchema` never declared — it reached the console only
10+
because the schema is a plain `z.object` and silently strips what it does not
11+
declare, so nothing ever failed to flag it. objectstack#4224 moves it into
12+
`error.details`, the slot the contract does declare.
13+
14+
This is the console's half, and it ships **first**: the read is now
15+
`error.details?.key ?? error.key`, so the toast keeps naming the locked key
16+
against servers on either side of that change rather than degrading to
17+
`Locked by environment: undefined` during the window where the two repos are on
18+
different versions. The fallback can go once the oldest supported server carries
19+
the fix.
20+
21+
Also stops interpolating a missing key: when neither position carries one the
22+
toast now reads `Locked by environment` rather than appending `undefined`.
23+
24+
This was the only in-console reader of the four keys objectstack#4224 relocated
25+
(`namespace`, `key`, `reason`, `fields`) — a repo-wide grep for the other three
26+
finds no consumer.

apps/console/src/pages/settings/SettingsView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getIcon } from '../../utils/getIcon';
1515
import { SettingsField } from './SettingsField';
1616
import {
1717
getSettingsNamespace,
18+
lockedKeyOf,
1819
runSettingsAction,
1920
saveSettingsNamespace,
2021
} from './api';
@@ -128,8 +129,11 @@ export function SettingsView() {
128129
setDraft({});
129130
toast.success('Settings saved');
130131
} catch (err: any) {
131-
if (err?.payload?.error?.code === 'SETTINGS_LOCKED') {
132-
toast.error(`Locked by environment: ${err.payload.error.key}`);
132+
const apiError = err?.payload?.error;
133+
if (apiError?.code === 'SETTINGS_LOCKED') {
134+
// `lockedKeyOf` reads both wire positions — see its note (objectstack#4224).
135+
const key = lockedKeyOf(apiError);
136+
toast.error(key ? `Locked by environment: ${key}` : 'Locked by environment');
133137
} else {
134138
toast.error(err?.message ?? 'Save failed');
135139
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* `lockedKeyOf` — the two wire positions of a `SETTINGS_LOCKED` key
9+
* (objectstack#4224).
10+
*
11+
* Why this is pinned rather than left as an inline `??`: it is a COMPAT read,
12+
* and the failure mode of a compat read is that someone deletes the half they
13+
* did not know was load-bearing. Both branches are asserted here, so removing
14+
* either one is a red test that names the server version it breaks — which is
15+
* also the signal for when the old branch may legitimately go.
16+
*
17+
* The old position (`error.key`) was never declared by `ApiErrorSchema`; it
18+
* reached the console only because the schema is a plain `z.object` and strips
19+
* undeclared keys instead of rejecting them. That is exactly why no test on
20+
* either side of the wire ever caught it.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { lockedKeyOf } from './api';
25+
26+
describe('lockedKeyOf', () => {
27+
it('reads the declared slot — a server carrying objectstack#4224', () => {
28+
expect(
29+
lockedKeyOf({
30+
code: 'SETTINGS_LOCKED',
31+
message: "Setting 'branding.workspace_name' is locked (locked-by-env).",
32+
details: { namespace: 'branding', key: 'workspace_name', reason: 'locked-by-env' },
33+
}),
34+
).toBe('workspace_name');
35+
});
36+
37+
it('falls back to the pre-#4224 sibling — a server without it', () => {
38+
expect(
39+
lockedKeyOf({
40+
code: 'SETTINGS_LOCKED',
41+
message: "Setting 'branding.workspace_name' is locked (locked-by-env).",
42+
namespace: 'branding',
43+
key: 'workspace_name',
44+
reason: 'locked-by-env',
45+
}),
46+
).toBe('workspace_name');
47+
});
48+
49+
it('prefers the declared slot when a body somehow carries both', () => {
50+
expect(lockedKeyOf({ key: 'old_key', details: { key: 'new_key' } })).toBe('new_key');
51+
});
52+
53+
it('yields undefined rather than a string when no position carries a key', () => {
54+
// The toast branches on this: no key means "Locked by environment" rather
55+
// than "Locked by environment: undefined".
56+
expect(lockedKeyOf({ code: 'SETTINGS_LOCKED', message: 'Locked.' })).toBeUndefined();
57+
expect(lockedKeyOf({ details: {} })).toBeUndefined();
58+
expect(lockedKeyOf({ details: null })).toBeUndefined();
59+
expect(lockedKeyOf({ key: '' })).toBeUndefined();
60+
expect(lockedKeyOf({ key: 42 })).toBeUndefined();
61+
expect(lockedKeyOf(undefined)).toBeUndefined();
62+
expect(lockedKeyOf('not an object')).toBeUndefined();
63+
});
64+
});

apps/console/src/pages/settings/api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,35 @@ const jsonHeaders = (): HeadersInit => ({
2828
Accept: 'application/json',
2929
});
3030

31+
/**
32+
* The env-locked key named by a `SETTINGS_LOCKED` error, read from whichever
33+
* position the serving version puts it in (objectstack#4224).
34+
*
35+
* It used to arrive as `error.key` — a SIBLING of `code`/`message` that
36+
* `ApiErrorSchema` never declared. That body was accepted only because the
37+
* schema is a plain `z.object` and strips undeclared keys rather than rejecting
38+
* them, so nothing on either side ever flagged it. objectstack#4224 moves it to
39+
* `error.details.key`, the slot the contract does declare.
40+
*
41+
* Declared home first, old position second, so the console names the key against
42+
* servers on either side of that change instead of rendering `undefined` for the
43+
* duration of the window. Drop the second read once the oldest supported server
44+
* carries the fix.
45+
*
46+
* Lives here rather than inline in the view because this module already owns
47+
* what the server's error body looks like (`jsonOrThrow` is what builds
48+
* `err.payload`), and because a compat shim that no test exercises is a compat
49+
* shim that gets deleted by the next person who reads only one of the two
50+
* shapes.
51+
*/
52+
export function lockedKeyOf(apiError: unknown): string | undefined {
53+
if (!apiError || typeof apiError !== 'object') return undefined;
54+
const e = apiError as { key?: unknown; details?: { key?: unknown } | null };
55+
const declared = e.details && typeof e.details === 'object' ? e.details.key : undefined;
56+
const found = declared ?? e.key;
57+
return typeof found === 'string' && found.length > 0 ? found : undefined;
58+
}
59+
3160
export async function listSettingsManifests(): Promise<SettingsListResponse> {
3261
const res = await fetch(BASE, { credentials: 'include', headers: jsonHeaders() });
3362
return jsonOrThrow<SettingsListResponse>(res);

0 commit comments

Comments
 (0)