fix(list): OBJECT_API_DISABLED renders an honest cannot-work state instead of the empty state (#4408) - #4495
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…orts + find() resolve→reject on the two denial codes)
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 12, 2026 21:25
This was referenced Aug 12, 2026
yinlianghui
pushed a commit
that referenced
this pull request
Aug 13, 2026
`generateTimeScaleHeaders` is reachable from the package entry (`index.tsx` does `export * from './renderer'`) and its published declaration grew an optional trailing `locale?: string`. Entry-reachable additive API growth is minor, not patch — patch is for changes with no API-surface movement at all. `dist/index.d.ts` being byte-identical does not argue for patch: the entry re-exports by reference, so the resolved public surface moved even though the entry file's bytes did not. The contrapositive of #4496, which was graded patch precisely because its .d.ts additions were NOT re-exported from the entry; #4403 / #4177 / #4485 / #4495-regrade are the line this follows. Additive and back-compatible is what minor means — no consumer breaks, existing three-argument callers keep compiling and keep producing byte-identical output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
This was referenced Aug 13, 2026
Merged
akarma-synetal
pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 17, 2026
…effect and is visible (objectstack-ai#4467) (objectstack-ai#4511) * fix(auth): the data lane honors set-auth-token, so impersonation takes effect (objectstack-ai#4467) The console injects the same localStorage bearer from two lanes: the AUTH lane (`createBearerFetch` inside `createAuthClient`) and the DATA lane (`createAuthenticatedFetch` — the adapter, `provider: 'api'` data sources, and every metadata `type: 'api'` action). better-auth's server-side bearer plugin hands a ROTATED session token back in the `set-auth-token` response header on whichever lane the call arrived over, and only the auth lane read it. `POST /auth/admin/impersonate-user` is exactly such a call — an ordinary metadata action — so the impersonated session token was dropped on the floor while the server's bearer plugin kept overwriting the impersonation cookie with the admin bearer the console kept sending. Impersonation was a complete no-op in the console, not merely an invisible one: support staff believed they were seeing a user's view while acting entirely as themselves. This mirrors the auth lane's three-line capture into the data lane — one contract, one answer, with no impersonation-specific logic in a generic lane. Gated on `isApiCall`, the same condition that decided we authenticated the request at all; untrusted targets remain the `sameOriginOnly` option's job. Riding along, because identity has to follow the rotation: - `AuthContextValue.refreshSession()` re-resolves user/session in place, without raising `isLoading`. - `TokenStorage.subscribeRotation()` notifies when a token already in hand is replaced by a different one. First store, `clear()` and re-storing the same value stay silent — those transitions have an owner that updates identity itself. An ownerless rotation is what starting impersonation produces. - `AuthClientSession.impersonatedBy?` — interface widening (objectstack-ai#4495 precedent). `external/api.test.ts` builds a hand-rolled `Response` fake behind a cast; it omitted `headers`, which this lane now reads on every API response. The fake is made honest rather than the lane made lenient — the auth lane has always read `headers` unguarded. * feat(app-shell): a standing impersonation banner, with an exit that fails loudly (objectstack-ai#4467) While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH parties — the impersonated user, whose name every write is recorded under, and the administrator who started it — plus a stop affordance. It derives from the session rather than from client memory of the click, so it survives a full SPA reboot, a new tab and a browser restart, and it cannot disagree with who the server thinks is acting. An ordinary session renders `null` and its chrome is unchanged. It mounts in `ConsoleShell` — the one provider stack every console route passes through — beside the other global surfaces with a single home (`RemediationOverlay`, `NotificationSnackbar`). The page-level bars it resembles visually (`DraftPreviewBar`, `UnpublishedAppBar`) mount inside `ConsoleLayout`, which only wraps `/apps/*`; the card was filed on a console whose `/home` showed no sign of impersonation at all, so a home that could not carry the indicator would have reproduced the bug. The exit calls `POST /auth/admin/stop-impersonating` over the same data lane (so the restored administrator token is adopted) and then awaits a refresh. The server resolves the administrator from the `admin_session` COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and stays up instead of appearing to succeed, which would leave the operator doing ordinary work under someone else's identity. Admin-only navigation needs no separate change: it is gated on `user.isPlatformAdmin`, which `AppContent` reads from `useAuth()` — so once identity re-resolves to the impersonated user, the admin surfaces follow. Ten locale packs carry the banner's copy. --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 17, 2026
…oded en-US (objectstack-ai#4513) (objectstack-ai#4523) * fix(plugin-timeline): dates follow the active locale instead of hardcoded en-US (objectstack-ai#4513) `renderer.tsx` passed a literal 'en-US' to four Intl calls (the hour, day and month gantt headers, and the `long` item date) and passed nothing at all to a fifth (the `short` item date), so a fully Chinese timeline rendered an English axis and English item dates. The bare call is the same defect spelled as an omission: no tag means the MACHINE's locale, which agreed with the other four only by the accident of an en-US runner. All five now resolve through `useDisplayLocale()` (tenant regional default → active UI language → 'en'), the one channel objectui#4468 / PR objectstack-ai#4512 converged `@object-ui/fields` onto. The two date helpers are module-level functions, so the locale is read once in `TimelineRenderer` and threaded down: `generateTimeScaleHeaders` takes an optional trailing `locale` defaulting to 'en' (it is exported and driven directly by the spec-parity test), and the module-private `formatDate` takes it as a required parameter so no future branch can silently reintroduce either wrong channel. English output is byte-identical at every site. The locale-free header vocabularies (`Week n`, `Qn YYYY`, `YYYY`) are untouched — they need the package's translate channel rather than a locale tag, filed as objectui#4520. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 * chore(changeset): regrade plugin-timeline patch -> minor (objectstack-ai#4513) `generateTimeScaleHeaders` is reachable from the package entry (`index.tsx` does `export * from './renderer'`) and its published declaration grew an optional trailing `locale?: string`. Entry-reachable additive API growth is minor, not patch — patch is for changes with no API-surface movement at all. `dist/index.d.ts` being byte-identical does not argue for patch: the entry re-exports by reference, so the resolved public surface moved even though the entry file's bytes did not. The contrapositive of objectstack-ai#4496, which was graded patch precisely because its .d.ts additions were NOT re-exported from the entry; objectstack-ai#4403 / objectstack-ai#4177 / objectstack-ai#4485 / objectstack-ai#4495-regrade are the line this follows. Additive and back-compatible is what minor means — no consumer breaks, existing three-argument callers keep compiling and keep producing byte-identical output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4408
A list pointed at an object whose
enableblock withholds the API rendered its ordinary empty state, so "this page cannot work, and never could" reached the user as "you have no records". The reported instance —Setup › Advanced › Signing Keys, whosesys_jwksdeclaresenable.apiEnabled: false— could not load for any persona and said so to nobody. That is also why the upstream defect objectstack#7544 survived review for its whole life: a merely unpopulated page invites nobody to click through.Measurement first — the card's mechanism section was a declared assumption
1. Where the 404 is swallowed.
ObjectStackDataSource.find(),packages/data-objectstack/src/index.ts. Its catch ranis404Error(err)and returned{ data: [], total: 0 }, then memoised the resource inmissingResourcesso later calls short-circuited to empty without touching the network. An error state does exist —ListView'slist-error-statepanel, withclassifyLoadErrorbehind it — and this path never reached it: a resolved promise carrying zero rows is indistinguishable from a genuinely empty object. So this was a swallow, not a missing state.2. Does the ADR-0112
codesurvive the transport? YES — on the primary path.@objectstack/client's fetch wrapper stampserror.code(top-levelcode, else the nestederror.code) anderror.httpStatuson every non-2xx, andclient.data.findgoes through it. Both denial spellings are declared members of the spec'sStandardErrorCodein the vendored@objectstack/spec@17.0.0-rc.6. So no heuristic on status was needed — and none was written.But there was a real propagation gap in this repo, on the same call:
find()'s raw$expand/$searchbranch (rawFindWithPopulate) bypasses the client and hand-rolled its own error, stamping onlystatus. That made the branch a list takes whenever it expands a lookup or runs a search the one path where a denial arrived anonymous. Fixed at that producer rather than worked around downstream.3. The 405 sibling — confirmed same class, but it did NOT flow the same path.
is404Errormatches only 404, soOBJECT_API_METHOD_NOT_ALLOWEDwas never swallowed: it already reached the error panel, and was classifiednetwork— "check your connection and try again" for a condition no retry can change. Same class, two different wrong outcomes, one repair.Both denials are pure functions of the object's metadata (
apiAccessDenialFromEnable) — no user, no permission, no context — so neither is transient or per-user, which is exactly the case where a silent empty state is most misleading.The fix
@object-ui/data-objectstack—find()no longer degrades the twoenable-block denials to an empty result, and does not memoise them either (absorbing one would pin the object to "empty" for the rest of the session). Discrimination is on thecode, never the status: a missing collection, a missing record and a disabled object are all 404. New exportsisApiAccessDeniedError()/API_ACCESS_DENIED_CODES. The raw branch now carriescode+httpStatus.@object-ui/plugin-list—classifyLoadErrorgains anapi-disabledkind, checked first and on the code alone. Its panel says the object is not exposed through the API and that this is a setting on the object rather than a permission, and it offers no Retry — every retry re-fetches the identical refusal, which is the same wrong advice as "check your connection", just spelled as a control.enmirrors (the fix(i18n): the drifted detail row mirrors the en pack, and the three defaults maps get a gate (#4401) #4409 map-mirror gate is live), translated in all ten locale packs forall-locales-key-parity.plugin-listis deliberately backend-agnostic and does not depend on any adapter, so neither package's suite can see the composition. It is pinned once inapp-shell, which already depends on both.Red-first — both directions, measured
Taking both halves out (
git checkoutof the sources, nevergit stash) and re-running:Defect pin, verbatim. A real adapter over a stubbed transport answering HTTP 404 +
code: 'OBJECT_API_DISABLED', feeding a realListViewcarrying the Setup page'smanagedByempty-state override:The composition test failed pre-fix with
AssertionError: expected null not to be null— there was no error state in the DOM at all.The 405 failed pre-fix differently, as measured:
AssertionError: expected 'network' to be 'api-disabled'. It reached the panel and was called a network outage.At the adapter, pre-fix:
AssertionError: promise resolved "{ data: [], total: +0 }" instead of rejecting(8 failed / 7 passed of 15).Controls — green on BOTH sides, never red:
OBJECT_NOT_FOUNDstill degrades to empty and is still memoisedRECORD_NOT_FOUNDis not an enable-block denialforbidden, 401unauthorized, 400rejectedThe card's binding control holds: a genuinely empty object still renders the ordinary empty state. Empty is the overwhelmingly common case and the one that state exists for.
Verification
pnpm exec vitest run packages/data-objectstack/ packages/plugin-list/ packages/i18n/→ 110 files, 1779 tests passedpnpm exec vitest run packages/app-shell/→ 361 files, 3466 passed, 1 skipped (hosts the fix(i18n): the drifted detail row mirrors the en pack, and the three defaults maps get a gate (#4401) #4409 map-mirror gate and the composition pin)type-check(bothtsccommands where the package defines them) fordata-objectstack,plugin-list,i18n,app-shell→ all exit 0, after building each one's dependency closure firstcheck:control-bytes,check:i18n-keys,check:i18n-drift,changeset:check,check-changeset-presence→ all greeneslintover the touched trees → 0 errorsNote for review
One assertion of mine was wrong on the first run and was corrected rather than the copy: I had asserted the panel text contains no
/permission/iat all, but the copy deliberately says "not a permission" — that disclaimer is the honest distinction between this state and the 403 panel. The test now asserts the disclaimer and the absence of the forbidden panel's own wording.Generated by Claude Code