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
28 changes: 28 additions & 0 deletions .changeset/cloud-connection-bind-failure-i18n-5054.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

A cloud-connection bind failure now reads in the user's language whichever clock
noticed it (objectui#5054).

One abandoned device approval could be noticed by either of two clocks, and the
Cloud Connection panel had a different answer for each. When the panel's own
`expires_in` deadline fired first it rendered `cloudConnection.errors.expired` —
translated in all ten packs. When the SERVER noticed first, `/bind/poll` answered
HTTP 400 with `message: 'Device authorization failed: expired_token'`; `getJson`
threw a bare `Error` carrying only that sentence, and the catch rendered it
verbatim. Same user, same failure, two languages, decided by which clock got
there first — visible on a zh console as the same abandoned approval reading
Chinese or English depending on whether the tab sat open past `expires_in`.

`getJson` now carries the envelope's `declaredCode` and `code` across its throw,
and a single closed map turns the two RFC 8628 outcomes a user can actually cause
into console copy: `expired_token` → the existing `cloudConnection.errors.expired`,
`access_denied` → a new `cloudConnection.errors.accessDenied` added to all ten
locale packs. `declaredCode` is read first, because ADR-0112 keeps the upstream
spelling there — `code` is `DEVICE_CODE_FAILED` for both.

Every other code is unchanged: `invalid_grant`, and anything upstream invents
next, still render the wire `message`, which stays the single source of truth for
failures this console has no copy for. No API, export or resolver was widened.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,42 @@ type Phase =
| { kind: 'bound'; status: StatusData }
| { kind: 'error'; message: string };

/**
* The two RFC 8628 device-authorization outcomes a user can actually cause,
* rendered in the user's language instead of the producer's (objectui#5054).
*
* `declaredCode` is read FIRST because that is where the upstream spelling
* lives: it is not a member of the closed `ApiErrorSchema.code` vocabulary, so
* ADR-0112 puts it in the open producer-authored channel and `code` carries the
* registered member — `DEVICE_CODE_FAILED` for BOTH of these. `code` is
* consulted second only so a producer that does put the RFC spelling in the
* registered slot is still understood.
*
* ⛔ Closed on purpose. Every other code — `invalid_grant`, `slow_down`,
* whatever upstream invents next — returns `null` here and keeps rendering the
* wire `message`, which is both today's behaviour and the single source of
* truth for failures this console has no copy for. Widening this map means
* adding a key to all ten packs, not adding a branch here.
*
* The `t()` arguments are string literals so `check:i18n-keys` can resolve
* them against the `en` pack; a `Record<code, key>` read as `t(key)` is a
* dynamic key and that gate goes blind to it.
*/
function translateFailureCode(
t: (key: string) => string,
declaredCode?: unknown,
code?: unknown,
): string | null {
for (const candidate of [declaredCode, code]) {
if (candidate === 'expired_token') return t('cloudConnection.errors.expired');
if (candidate === 'access_denied') return t('cloudConnection.errors.accessDenied');
}
return null;
}

/** An `Error` that still carries the envelope's codes — see `getJson`. */
type ApiFailure = Error & { declaredCode?: string; code?: string };

async function getJson(url: string, init?: RequestInit): Promise<any> {
const resp = await fetch(url, {
credentials: 'same-origin',
Expand All @@ -74,7 +110,19 @@ async function getJson(url: string, init?: RequestInit): Promise<any> {
const body = await resp.json().catch(() => ({}));
if (!resp.ok && body?.success !== true) {
const msg = body?.error?.message ?? body?.error?.code ?? body?.error ?? `HTTP ${resp.status}`;
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
// The codes ride along. A bare `Error` dropped them, and the message was
// then the only thing that survived the throw — which is exactly why a
// SERVER-detected expiry rendered the producer's English while the same
// expiry noticed by this panel's own clock rendered the locale. Attached,
// not subclassed: extending `Error` is brittle under a downlevel target.
const failure: ApiFailure = Object.assign(
new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)),
{
declaredCode: typeof body?.error?.declaredCode === 'string' ? body.error.declaredCode : undefined,
code: typeof body?.error?.code === 'string' ? body.error.code : undefined,
},
);
throw failure;
}
return body;
}
Expand All @@ -86,6 +134,28 @@ export function CloudConnectionPanel() {
const [copied, setCopied] = useState(false);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

// `t` is a fresh function on every render under several translation
// providers, so it must not reach a `useCallback` dependency list that the
// MOUNT effect transitively depends on: `refreshStatus` is exactly that, and
// an unstable dep there re-runs the effect on every state update — an
// infinite render loop. Measured: routing `t` into `refreshStatus`'s deps
// timed out all nine cases in this directory's two suites at 15s. A latest-ref
// gives the helper the current `t` while keeping its own identity stable.
const tRef = useRef(t);
useEffect(() => { tRef.current = t; }, [t]);

/**
* What a caught failure says to a human. One reading for all four catch
* sites, so no future call site can reintroduce the split this card closed.
*/
const failureText = useCallback((err: unknown): string => {
const e = err as { declaredCode?: unknown; code?: unknown; message?: unknown } | null | undefined;
return (
translateFailureCode(tRef.current, e?.declaredCode, e?.code) ??
((e?.message as string | undefined) ?? String(err))
);
}, []);

const stopPolling = useCallback(() => {
if (pollTimer.current) { clearTimeout(pollTimer.current); pollTimer.current = null; }
}, []);
Expand All @@ -96,9 +166,9 @@ export function CloudConnectionPanel() {
const data: StatusData = body?.data ?? { environmentId: null, bound: false, connection: null };
setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound' });
} catch (err: any) {
setPhase({ kind: 'error', message: err?.message ?? String(err) });
setPhase({ kind: 'error', message: failureText(err) });
}
}, []);
}, [failureText]);

useEffect(() => {
void refreshStatus();
Expand Down Expand Up @@ -142,14 +212,20 @@ export function CloudConnectionPanel() {
// render, and an unguarded object would reach JSX as a child.
setPhase({
kind: 'error',
message: body?.error?.message ?? body?.error?.code ?? t('cloudConnection.errors.bindFailed'),
// The code -> copy map runs FIRST here too (objectui#5054): a body
// that reaches this branch carrying a spelling the console knows must
// read the same as the same spelling arriving on a 400, or the
// asymmetry just moves to a third reader.
message:
translateFailureCode(t, body?.error?.declaredCode, body?.error?.code) ??
body?.error?.message ?? body?.error?.code ?? t('cloudConnection.errors.bindFailed'),
});
} catch (err: any) {
setPhase({ kind: 'error', message: err?.message ?? String(err) });
setPhase({ kind: 'error', message: failureText(err) });
}
};
pollTimer.current = setTimeout(tick, intervalMs);
}, [refreshStatus, t]);
}, [failureText, refreshStatus, t]);

const connect = useCallback(async () => {
setBusy(true);
Expand All @@ -170,23 +246,23 @@ export function CloudConnectionPanel() {
setPhase({ kind: 'waiting', code, popupOpened });
poll(code, Date.now());
} catch (err: any) {
setPhase({ kind: 'error', message: err?.message ?? String(err) });
setPhase({ kind: 'error', message: failureText(err) });
} finally {
setBusy(false);
}
}, [poll, t]);
}, [failureText, poll, t]);

const disconnect = useCallback(async () => {
setBusy(true);
try {
await getJson(`${BASE}/unbind`, { method: 'POST', body: '{}' });
await refreshStatus();
} catch (err: any) {
setPhase({ kind: 'error', message: err?.message ?? String(err) });
setPhase({ kind: 'error', message: failureText(err) });
} finally {
setBusy(false);
}
}, [refreshStatus]);
}, [failureText, refreshStatus]);

const copyCode = useCallback(async (code: string) => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,26 @@ describe('objectui#5028 — the bind-failure text a human reads', () => {
// objectstack#9267's body, verbatim, at the status it is served with.
// `getJson` throws on it, so the message shown is the one IT picked; the
// changed line in poll() is not on this path in either direction.
//
// objectui#5054 moved what "the one IT picked" IS. This case used to assert
// the wire sentence `Device authorization failed: expired_token`; that was
// the measured asymmetry — the SAME expiry noticed by the panel's own clock
// rendered `cloudConnection.errors.expired`, so one condition read in two
// languages. `getJson` now carries the envelope's codes across its throw and
// the catch maps the two user-causable RFC 8628 spellings onto that key, so
// both readers land on it. The fixture is untouched — it is still the
// producer's verbatim envelope — and the `DEVICE_CODE_FAILED` negative is
// still this file's own subject: no machine code reaches a human.
//
// ⚠️ What this case lost, and where it went: the wire English used to
// DISCRIMINATE the two readers, because only `getJson` produced it. Post-fix
// both readers answer `expired_token` identically — that is the point of the
// card — so the route claim in this case's title now rests on the argument
// above (400 -> `getJson` throws) rather than on its own assertion. The
// measurable discriminator moved to `CloudConnectionPanel.bindErrorLocale
// .test.tsx`'s ROUTE case, which uses a 400 carrying no `error` object at
// all: `getJson` renders `HTTP 400`, poll()'s terminal branch would render
// `cloudConnection.errors.bindFailed`, and nothing else tells them apart.
pollReply = {
status: 400,
body: {
Expand All @@ -140,7 +160,8 @@ describe('objectui#5028 — the bind-failure text a human reads', () => {

await connectAndPollOnce();

expect(screen.getByText('Device authorization failed: expired_token')).toBeInTheDocument();
expect(screen.getByText('cloudConnection.errors.expired')).toBeInTheDocument();
expect(screen.queryByText('Device authorization failed: expired_token')).not.toBeInTheDocument();
expect(screen.queryByText('DEVICE_CODE_FAILED')).not.toBeInTheDocument();
});

Expand Down
Loading
Loading