diff --git a/.changeset/spotty-donkeys-shake.md b/.changeset/spotty-donkeys-shake.md
new file mode 100644
index 000000000..d1312829b
--- /dev/null
+++ b/.changeset/spotty-donkeys-shake.md
@@ -0,0 +1,69 @@
+---
+'@object-ui/data-objectstack': minor
+'@object-ui/plugin-list': patch
+'@object-ui/i18n': patch
+---
+
+fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
+
+A list pointed at an object whose `enable` block withholds the API rendered its ordinary
+empty state, so *"this page cannot work, and never could"* reached the user as *"you have no
+records"* (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
+`sys_jwks` declares `enable.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.
+
+The masking had two halves, in two packages, and neither package could see the other:
+
+- **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
+ would have pinned the object to "empty" for the rest of the session.
+- **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
+ half was never swallowed, so it already reached this panel, but classified as `network`:
+ *"check your connection and try again"* for a condition no retry can change. It now says
+ the object is not exposed through the API, that this is a setting on the object rather than
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
+ identical refusal.
+
+Both denials are pure functions of the object's metadata — 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. Discrimination is on the ADR-0112 `code`, never the status: a
+missing collection, a missing record and a disabled object are all 404.
+
+**A genuinely empty object still renders the ordinary empty state**, and a backend without an
+optional collection still degrades to empty — pinned in both directions, at the adapter, at
+the view, and once end-to-end over a real adapter and a real `ListView`.
+
+Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
+branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
+It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
+branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
+
+New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
+`en` pack and mirrored in the list defaults map.
+
+## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
+
+Two independent reasons, either of which is sufficient under this repo's precedent
+(objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
+
+1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
+ boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
+ public surface. Additive surface growth is minor.
+2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
+ relied on those two codes arriving as a successful empty result now receives a rejected
+ promise carrying `code` + `httpStatus`, and must handle it.
+
+Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
+with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
+is scoped to the two denial codes named above and to nothing else.
+
+Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
+`@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
+described in the body, which is what this note is.
diff --git a/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx b/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx
new file mode 100644
index 000000000..d14c45b79
--- /dev/null
+++ b/packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx
@@ -0,0 +1,156 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#4408 — the masking, pinned end to end.
+ *
+ * The two halves of this defect live in two packages and neither can see the
+ * other: `@object-ui/data-objectstack` turned the server's 404 into a resolved
+ * empty result, and `@object-ui/plugin-list` rendered a resolved empty result
+ * as its ordinary empty state. Each package's own suite can only pin its own
+ * half — plugin-list is deliberately backend-agnostic (AGENTS.md #1) and does
+ * not depend on any adapter. `app-shell` is where both already meet, the same
+ * reason the defaults-map mirror gate lives here, so the composition is
+ * asserted here: a real `ObjectStackAdapter` over a stubbed transport, feeding
+ * the real `ListView`.
+ *
+ * The scenario is the reported one. `sys_jwks` declares
+ * `enable.apiEnabled: false`, so `GET /data/sys_jwks` answers **404** with
+ * `code: 'OBJECT_API_DISABLED'`, and the Setup page carried the `managedBy`
+ * empty-state override ("No identity records"). Every persona saw an ordinary,
+ * unpopulated list. Nobody clicked through, which is also how the upstream
+ * defect objectstack#7544 survived review for its entire life.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { ListView } from '@object-ui/plugin-list';
+import { ObjectStackAdapter } from '@object-ui/data-objectstack';
+import { SchemaRendererProvider } from '@object-ui/react';
+import type { ListViewSchema } from '@object-ui/types';
+
+/** The Setup page's schema: the `managedBy` empty-state override, as shipped. */
+const schema: ListViewSchema = {
+ type: 'list-view',
+ objectName: 'sys_jwks',
+ fields: ['name'],
+ emptyState: {
+ icon: 'ShieldAlert',
+ title: 'No identity records',
+ message:
+ 'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.',
+ },
+} as ListViewSchema;
+
+/**
+ * An adapter whose transport answers the data route with `status` + `body`.
+ * Discovery is answered 200 so `connect()` resolves normally.
+ */
+function makeAdapter(status: number, body: unknown) {
+ const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
+ const href = String(url);
+ if (href.includes('/data/')) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return new Response(
+ JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ });
+ const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
+ return ds;
+}
+
+function renderList(ds: any) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('object list · an OBJECT_API_DISABLED 404 is not an empty list (#4408)', () => {
+ it('renders the honest cannot-work state, NOT the generic empty state', async () => {
+ const ds = makeAdapter(404, {
+ code: 'OBJECT_API_DISABLED',
+ message: 'Object API is disabled for sys_jwks',
+ });
+ const { container } = renderList(ds);
+
+ const panel = await waitFor(() => {
+ const el = container.querySelector('[data-testid="list-error-state"]');
+ expect(el).not.toBeNull();
+ return el as HTMLElement;
+ });
+
+ expect(panel.getAttribute('data-error-kind')).toBe('api-disabled');
+
+ // The masking, gone: the empty state must not be what this page shows, and
+ // the override copy that used to stand in for the failure must be absent.
+ expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
+ expect(container.textContent).not.toContain('No identity records');
+
+ // And the panel says the true thing.
+ expect(panel.textContent).toMatch(/API/);
+ expect(panel.textContent).not.toMatch(/connection/i);
+ });
+
+ it('renders the same honest state for the 405 sibling', async () => {
+ const ds = makeAdapter(405, {
+ error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' },
+ });
+ const { container } = renderList(ds);
+
+ const panel = await waitFor(() => {
+ const el = container.querySelector('[data-testid="list-error-state"]');
+ expect(el).not.toBeNull();
+ return el as HTMLElement;
+ });
+ expect(panel.getAttribute('data-error-kind')).toBe('api-disabled');
+ expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
+ });
+
+ /**
+ * The binding control, in the card's own words: a genuinely empty object must
+ * STILL render the ordinary empty state. Empty is the overwhelmingly common
+ * case and the one the empty state exists for — a fix that turned "no records
+ * yet" into an error surface would be worse than the bug it repairs.
+ */
+ it('CONTROL: a 2xx with zero rows still renders the ordinary empty state', async () => {
+ const ds = makeAdapter(200, { success: true, data: { records: [], total: 0 } });
+ const { container } = renderList(ds);
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull();
+ });
+
+ // The override copy is intact, and no error surface appeared.
+ expect(container.textContent).toContain('No identity records');
+ expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull();
+ });
+
+ /**
+ * The other control: a 404 that is NOT an enable-block denial. A backend
+ * without this optional collection still degrades to empty — the probes
+ * (AppHeader's `sys_presence` / `sys_activity`, …) read empty data as
+ * "feature unavailable", and turning those into error panels would be a
+ * second, louder regression.
+ */
+ it('CONTROL: a bare 404 (collection absent) still renders the empty state', async () => {
+ const ds = makeAdapter(404, { message: 'Not found' });
+ const { container } = renderList(ds);
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull();
+ });
+ expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull();
+ });
+});
diff --git a/packages/data-objectstack/src/apiAccessDenied.test.ts b/packages/data-objectstack/src/apiAccessDenied.test.ts
new file mode 100644
index 000000000..7fe69a8fc
--- /dev/null
+++ b/packages/data-objectstack/src/apiAccessDenied.test.ts
@@ -0,0 +1,233 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#4408 — an `enable`-block denial must not arrive at the surface as
+ * "no data".
+ *
+ * `find()` degraded EVERY 404 into `{ data: [], total: 0 }` and memoised the
+ * resource in `missingResources`, so a list pointed at an object the server
+ * refuses to expose (`enable.apiEnabled: false` → HTTP 404 +
+ * `code: 'OBJECT_API_DISABLED'`) resolved successfully with zero rows. A
+ * resolved promise with zero rows is indistinguishable from a genuinely empty
+ * object, so the list rendered its ordinary empty state: "you have no records"
+ * over a page that can never hold any. The reported instance (Setup › Advanced
+ * › Signing Keys) could not load for any persona and said so to nobody — which
+ * is also how the upstream defect objectstack#7544 survived review for its
+ * whole life.
+ *
+ * The discrimination is on the ADR-0112 `code`, never the status: a 404 from a
+ * missing collection and a 404 from a disabled object are the same status, and
+ * the optional-collection probes below depend on the missing-collection half
+ * still degrading to empty. Both halves are asserted here, in both directions.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { ObjectStackAdapter, isApiAccessDeniedError, API_ACCESS_DENIED_CODES } from './index';
+
+/** An error shaped the way `@objectstack/client`'s fetch wrapper throws one. */
+function clientError(message: string, httpStatus: number, code?: string) {
+ const err = new Error(message) as Error & { httpStatus: number; code?: string };
+ err.httpStatus = httpStatus;
+ if (code) err.code = code;
+ return err;
+}
+
+function makeDS(find: any) {
+ const ds: any = new ObjectStackAdapter({
+ baseUrl: 'http://test.local',
+ fetch: vi.fn(async () =>
+ new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })),
+ });
+ ds.connected = true;
+ ds.connectionState = 'connected';
+ ds.client = { data: { find } };
+ return ds;
+}
+
+describe('isApiAccessDeniedError', () => {
+ it('matches both enable-block denial codes', () => {
+ expect(isApiAccessDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(true);
+ expect(isApiAccessDeniedError({ code: 'OBJECT_API_METHOD_NOT_ALLOWED' })).toBe(true);
+ expect([...API_ACCESS_DENIED_CODES]).toEqual([
+ 'OBJECT_API_DISABLED',
+ 'OBJECT_API_METHOD_NOT_ALLOWED',
+ ]);
+ });
+
+ it('matches case-insensitively (the pre-ADR-0112 spelling still resolves)', () => {
+ expect(isApiAccessDeniedError({ code: 'object_api_disabled' })).toBe(true);
+ });
+
+ it('does NOT match the codes a missing collection or record answers with', () => {
+ expect(isApiAccessDeniedError({ code: 'OBJECT_NOT_FOUND' })).toBe(false);
+ expect(isApiAccessDeniedError({ code: 'RECORD_NOT_FOUND' })).toBe(false);
+ expect(isApiAccessDeniedError({ code: 'PERMISSION_DENIED' })).toBe(false);
+ // A bare 404 carries no code at all — status alone must never qualify.
+ expect(isApiAccessDeniedError({ httpStatus: 404 })).toBe(false);
+ expect(isApiAccessDeniedError(undefined)).toBe(false);
+ });
+});
+
+describe('ObjectStackDataSource.find — enable-block denials are surfaced, not emptied', () => {
+ // ── The defect pin ────────────────────────────────────────────────────────
+ it('REJECTS a 404 OBJECT_API_DISABLED instead of resolving to zero rows', async () => {
+ const find = vi.fn().mockRejectedValue(
+ clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'),
+ );
+ const ds = makeDS(find);
+
+ await expect(ds.find('sys_jwks')).rejects.toMatchObject({
+ code: 'OBJECT_API_DISABLED',
+ httpStatus: 404,
+ });
+ });
+
+ it('REJECTS the 405 sibling and keeps its code intact for the surface', async () => {
+ const find = vi.fn().mockRejectedValue(
+ clientError('Method not allowed', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'),
+ );
+ const ds = makeDS(find);
+
+ await expect(ds.find('sys_jwks')).rejects.toMatchObject({
+ code: 'OBJECT_API_METHOD_NOT_ALLOWED',
+ httpStatus: 405,
+ });
+ });
+
+ it('does not memoise a denial — the second call still asks and still rejects', async () => {
+ // `missingResources` short-circuits later calls to `{ data: [], total: 0 }`
+ // WITHOUT touching the network. Absorbing a denial into it would pin the
+ // object to "empty" for the rest of the session, so the honest state would
+ // appear once and then silently revert to the masking empty state.
+ const find = vi.fn().mockRejectedValue(
+ clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'),
+ );
+ const ds = makeDS(find);
+
+ await expect(ds.find('sys_jwks')).rejects.toThrow();
+ await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED' });
+ expect(find).toHaveBeenCalledTimes(2);
+ });
+
+ // ── The controls: must stay green on BOTH sides of the fix ────────────────
+ it('CONTROL: a bare 404 (collection absent on this backend) still degrades to empty', async () => {
+ // The optional-collection probes (AppHeader's sys_presence/sys_activity …)
+ // read empty data as "feature unavailable". Turning those into thrown
+ // errors would be a worse bug than the one being fixed.
+ const find = vi.fn().mockRejectedValue(clientError('Not found', 404));
+ const ds = makeDS(find);
+
+ await expect(ds.find('sys_presence')).resolves.toMatchObject({ data: [], total: 0 });
+ });
+
+ it('CONTROL: a 404 OBJECT_NOT_FOUND still degrades to empty, and is still memoised', async () => {
+ const find = vi.fn().mockRejectedValue(clientError('No such object', 404, 'OBJECT_NOT_FOUND'));
+ const ds = makeDS(find);
+
+ await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 });
+ // Memoised: the second call short-circuits without asking again.
+ await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 });
+ expect(find).toHaveBeenCalledTimes(1);
+ });
+
+ it('CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial', async () => {
+ const find = vi.fn().mockRejectedValue(clientError('No such record', 404, 'RECORD_NOT_FOUND'));
+ const ds = makeDS(find);
+
+ await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 });
+ });
+
+ it('CONTROL: a 2xx with zero rows resolves as an ordinary empty result', async () => {
+ // The overwhelmingly common case, and the one the empty state exists for.
+ // A fix that turned "no records yet" into an error surface would be worse
+ // than the bug.
+ const find = vi.fn().mockResolvedValue({ records: [], total: 0 });
+ const ds = makeDS(find);
+
+ await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 });
+ });
+
+ it('CONTROL: a 2xx with rows is untouched', async () => {
+ const find = vi.fn().mockResolvedValue({ records: [{ id: '1' }], total: 1 });
+ const ds = makeDS(find);
+
+ await expect(ds.find('account')).resolves.toMatchObject({ data: [{ id: '1' }], total: 1 });
+ });
+
+ it('CONTROL: a 403 still propagates (it was never swallowed)', async () => {
+ const find = vi.fn().mockRejectedValue(clientError('Forbidden', 403, 'PERMISSION_DENIED'));
+ const ds = makeDS(find);
+
+ await expect(ds.find('account')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
+ });
+});
+
+/**
+ * The `$expand` / `$search` branch of `find()` bypasses `@objectstack/client`
+ * and hand-rolls its own fetch, so it also has to stamp the ADR-0112 envelope
+ * itself. It used to set only `status`, which left the surface with a bare 404
+ * or 405 and nothing to discriminate on — the same denial, arriving anonymous,
+ * on the path a list takes whenever it expands a lookup or runs a search.
+ */
+describe('ObjectStackDataSource.find — the raw $expand/$search branch carries the code', () => {
+ function makeRawDS(status: number, body: unknown) {
+ const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
+ if (String(url).includes('/data/')) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+ const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
+ ds.connected = true;
+ ds.connectionState = 'connected';
+ return ds;
+ }
+
+ it('propagates a top-level `code` from the error body', async () => {
+ const ds = makeRawDS(404, { code: 'OBJECT_API_DISABLED', message: 'Object API is disabled' });
+
+ await expect(ds.find('sys_jwks', { $search: 'anything' })).rejects.toMatchObject({
+ code: 'OBJECT_API_DISABLED',
+ status: 404,
+ httpStatus: 404,
+ });
+ });
+
+ it('propagates a nested `error.code` envelope, like the client wrapper does', async () => {
+ const ds = makeRawDS(405, {
+ error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' },
+ });
+
+ await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({
+ code: 'OBJECT_API_METHOD_NOT_ALLOWED',
+ httpStatus: 405,
+ });
+ });
+
+ it('CONTROL: a body with no code leaves `code` undefined rather than inventing one', async () => {
+ const ds = makeRawDS(404, { message: 'Not found' });
+
+ await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({
+ status: 404,
+ });
+ await ds.find('sys_jwks', { $expand: ['owner'] }).catch((e: any) => {
+ expect(e.code).toBeUndefined();
+ expect(isApiAccessDeniedError(e)).toBe(false);
+ });
+ });
+});
diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts
index edfb87c18..862008e58 100644
--- a/packages/data-objectstack/src/index.ts
+++ b/packages/data-objectstack/src/index.ts
@@ -449,6 +449,49 @@ export function is404Error(error: unknown): boolean {
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
}
+/**
+ * The two denials the server derives from an object's `enable` block —
+ * `apiAccessDenialFromEnable` (objectstack `packages/rest/src/rest-server.ts`).
+ *
+ * - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
+ * not exposed over the data API at all.
+ * - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
+ * `enable.apiMethods` whitelist.
+ *
+ * Both are **pure functions of the object's metadata**: no user, no permission,
+ * no context, no request body. So neither is transient and neither is
+ * per-user — when one happens it is a permanent property of that object, and
+ * every retry of every persona gets the identical answer.
+ *
+ * That is exactly why they must not be degraded into "no data". A 404 from a
+ * missing collection means *this backend doesn't have that table*, which the
+ * optional-collection probes below legitimately read as "feature unavailable";
+ * a 404 from `OBJECT_API_DISABLED` means *this page can never work*. Answering
+ * the second with an empty result set renders "you have no records" over a
+ * surface that is not allowed to have any (objectui#4408 — it also hid the
+ * upstream defect objectstack#7544 for its entire life, because a merely
+ * unpopulated page invites nobody to click through).
+ */
+export const API_ACCESS_DENIED_CODES = [
+ 'OBJECT_API_DISABLED',
+ 'OBJECT_API_METHOD_NOT_ALLOWED',
+] as const;
+
+/**
+ * True when `error` is an `enable`-block API denial (see
+ * {@link API_ACCESS_DENIED_CODES}).
+ *
+ * Discriminates on the ADR-0112 `code`, never on the status: 404 alone cannot
+ * separate a disabled object from a missing collection, and 405 alone cannot
+ * separate a withheld method from any other method rejection. The code survives
+ * the transport — `@objectstack/client`'s fetch wrapper stamps `error.code`
+ * from the response envelope, and both spellings are declared members of the
+ * spec's `StandardErrorCode` — so no heuristic on status is needed or wanted.
+ */
+export function isApiAccessDeniedError(error: unknown): boolean {
+ return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES);
+}
+
/**
* Thrown when the deployment has no analytics capability installed
* (framework#3891 / #4019).
@@ -1310,7 +1353,14 @@ export class ObjectStackAdapter implements DataSource {
const result: unknown = await this.client.data.find(resource, queryOptions);
return this.normalizeQueryResult(result, params);
} catch (err) {
- if (is404Error(err)) {
+ // An `enable`-block denial is NOT a missing collection. The object
+ // exists and the server is deliberately refusing to expose it, forever
+ // and for everyone — degrading that to an empty result set tells the
+ // user "you have no records" about a page that can never hold any
+ // (objectui#4408). Rethrow so the surface can say what happened; the
+ // `missingResources` memo must not absorb it either, or the very first
+ // denial would silently pin every later call to empty.
+ if (!isApiAccessDeniedError(err) && is404Error(err)) {
// Mark the resource so subsequent calls don't repeat the 404.
this.missingResources.add(resource);
return { data: [], total: 0 } as QueryResult;
@@ -2523,6 +2573,16 @@ export class ObjectStackAdapter implements DataSource {
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
err.status = res.status;
+ // Carry the ADR-0112 envelope, not just the status. This branch bypasses
+ // `@objectstack/client` — whose fetch wrapper stamps `code`/`httpStatus`
+ // from the error body — so dropping the code here made THIS path (the one
+ // taken whenever the view expands a lookup or runs a search) the only list
+ // fetch on which a semantic denial arrives indistinguishable from any
+ // other 404/405, leaving the surface nothing to discriminate on
+ // (objectui#4408). Same precedence as the client's wrapper: the top-level
+ // `code` first, then the nested envelope's.
+ err.code = errorBody?.code ?? errorBody?.error?.code;
+ err.httpStatus = res.status;
throw err;
}
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 7d295ffd5..dcbc6de40 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -511,6 +511,8 @@ const ar = {
loadErrorUnauthorizedMessage: "انتهت صلاحية جلستك أو تم تسجيل خروجك. سجّل الدخول مرة أخرى لعرض هذه السجلات.",
loadErrorRejectedTitle: "تم رفض استعلام طريقة العرض هذه",
loadErrorRejectedMessage: "تعذّر على الخادم معالجة عامل التصفية أو خيارات الاستعلام لطريقة العرض هذه. مسح عوامل التصفية يحل المشكلة عادةً؛ وإذا كانت طريقة العرض محفوظة بهذا الشكل، فيجب على المسؤول تصحيحها.",
+ loadErrorApiDisabledTitle: "هذا الكائن غير متاح عبر واجهة API",
+ loadErrorApiDisabledMessage: "لا يمكن لهذه الصفحة تحميل سجلاتها لأن الكائن غير معروض عبر واجهة API. هذا إعداد خاص بالكائن نفسه وليس صلاحية — يجب على المسؤول تفعيل الوصول عبر API حتى تعمل هذه الصفحة.",
retry: "إعادة المحاولة",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 075b36b2e..c93f50c4f 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -507,6 +507,8 @@ const de = {
loadErrorUnauthorizedMessage: "Ihre Sitzung ist abgelaufen oder Sie sind abgemeldet. Melden Sie sich erneut an, um diese Datensätze anzuzeigen.",
loadErrorRejectedTitle: "Die Abfrage dieser Ansicht wurde abgelehnt",
loadErrorRejectedMessage: "Der Server konnte den Filter oder die Abfrageoptionen dieser Ansicht nicht verarbeiten. Das Zurücksetzen der Filter behebt das meist; ist die Ansicht so gespeichert, muss ein Administrator sie korrigieren.",
+ loadErrorApiDisabledTitle: "Dieses Objekt ist über die API nicht verfügbar",
+ loadErrorApiDisabledMessage: "Diese Seite kann ihre Datensätze nicht laden, weil das Objekt nicht über die API bereitgestellt wird. Das ist eine Einstellung am Objekt selbst und keine Berechtigung – ein Administrator muss den API-Zugriff dafür aktivieren, damit diese Seite funktioniert.",
retry: "Erneut versuchen",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 324e6c9e4..ad0676a08 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -578,6 +578,8 @@ const en = {
loadErrorUnauthorizedMessage: 'Your session has expired or you are signed out. Sign in again to view these records.',
loadErrorRejectedTitle: 'This view’s query was rejected',
loadErrorRejectedMessage: 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.',
+ loadErrorApiDisabledTitle: 'This object isn’t available through the API',
+ loadErrorApiDisabledMessage: 'This page can’t load its records because the object is not exposed through the API. That is a setting on the object itself, not a permission — an administrator has to enable API access for it before this page can work.',
retry: 'Retry',
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index a0a5439b2..8df0f261d 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -511,6 +511,8 @@ const es = {
loadErrorUnauthorizedMessage: "Su sesión ha expirado o ha cerrado sesión. Inicie sesión de nuevo para ver estos registros.",
loadErrorRejectedTitle: "La consulta de esta vista fue rechazada",
loadErrorRejectedMessage: "El servidor no pudo procesar el filtro ni las opciones de consulta de esta vista. Borrar los filtros suele resolverlo; si la vista está guardada así, un administrador debe corregirla.",
+ loadErrorApiDisabledTitle: "Este objeto no está disponible a través de la API",
+ loadErrorApiDisabledMessage: "Esta página no puede cargar sus registros porque el objeto no está expuesto a través de la API. Es un ajuste del propio objeto, no un permiso: un administrador debe habilitar el acceso por API para que esta página funcione.",
retry: "Reintentar",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 78a3a4939..3901e58e7 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -507,6 +507,8 @@ const fr = {
loadErrorUnauthorizedMessage: "Votre session a expiré ou vous êtes déconnecté. Reconnectez-vous pour consulter ces enregistrements.",
loadErrorRejectedTitle: "La requête de cette vue a été rejetée",
loadErrorRejectedMessage: "Le serveur n’a pas pu traiter le filtre ou les options de requête de cette vue. Effacer les filtres suffit généralement ; si la vue est enregistrée ainsi, un administrateur doit la corriger.",
+ loadErrorApiDisabledTitle: "Cet objet n’est pas disponible via l’API",
+ loadErrorApiDisabledMessage: "Cette page ne peut pas charger ses enregistrements car l’objet n’est pas exposé via l’API. Il s’agit d’un paramètre de l’objet lui-même, pas d’une autorisation : un administrateur doit activer l’accès API pour que cette page fonctionne.",
retry: "Réessayer",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 025e1c895..8ac807019 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -507,6 +507,8 @@ const ja = {
loadErrorUnauthorizedMessage: "セッションの有効期限が切れたか、サインアウトしています。再度サインインしてください。",
loadErrorRejectedTitle: "このビューのクエリは拒否されました",
loadErrorRejectedMessage: "サーバーはこのビューのフィルターまたはクエリ設定を処理できませんでした。フィルターを解除すると解消することがほとんどです。ビューがこの状態で保存されている場合は、管理者による修正が必要です。",
+ loadErrorApiDisabledTitle: "このオブジェクトは API から利用できません",
+ loadErrorApiDisabledMessage: "このオブジェクトが API に公開されていないため、このページはレコードを読み込めません。これは権限ではなくオブジェクト自体の設定です。管理者が API アクセスを有効にするまで、このページは動作しません。",
retry: "再試行",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index a8fd36f0d..3d9291688 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -507,6 +507,8 @@ const ko = {
loadErrorUnauthorizedMessage: "세션이 만료되었거나 로그아웃되었습니다. 다시 로그인한 후 확인하세요.",
loadErrorRejectedTitle: "이 뷰의 쿼리가 거부되었습니다",
loadErrorRejectedMessage: "서버가 이 뷰의 필터 또는 쿼리 옵션을 처리하지 못했습니다. 필터를 지우면 대부분 해결됩니다. 뷰가 이 상태로 저장되어 있다면 관리자가 수정해야 합니다.",
+ loadErrorApiDisabledTitle: "이 객체는 API로 제공되지 않습니다",
+ loadErrorApiDisabledMessage: "객체가 API에 공개되어 있지 않아 이 페이지는 레코드를 불러올 수 없습니다. 이는 권한이 아니라 객체 자체의 설정입니다. 관리자가 API 액세스를 활성화해야 이 페이지가 동작합니다.",
retry: "다시 시도",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 89d149f98..63f239fc7 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -506,6 +506,8 @@ const pt = {
loadErrorUnauthorizedMessage: "Sua sessão expirou ou você saiu. Entre novamente para ver estes registros.",
loadErrorRejectedTitle: "A consulta desta visualização foi rejeitada",
loadErrorRejectedMessage: "O servidor não conseguiu processar o filtro ou as opções de consulta desta visualização. Limpar os filtros costuma resolver; se a visualização estiver salva assim, um administrador precisa corrigi-la.",
+ loadErrorApiDisabledTitle: "Este objeto não está disponível pela API",
+ loadErrorApiDisabledMessage: "Esta página não consegue carregar seus registros porque o objeto não está exposto pela API. Isso é uma configuração do próprio objeto, não uma permissão — um administrador precisa habilitar o acesso via API para que esta página funcione.",
retry: "Tentar novamente",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index b0f77d08c..260d994bf 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -513,6 +513,8 @@ const ru = {
loadErrorUnauthorizedMessage: "Сессия истекла или вы вышли из системы. Войдите снова, чтобы просмотреть эти записи.",
loadErrorRejectedTitle: "Запрос этого представления отклонён",
loadErrorRejectedMessage: "Сервер не смог обработать фильтр или параметры запроса этого представления. Обычно помогает сброс фильтров; если представление сохранено в таком виде, исправить его должен администратор.",
+ loadErrorApiDisabledTitle: "Этот объект недоступен через API",
+ loadErrorApiDisabledMessage: "Страница не может загрузить записи, потому что объект не открыт через API. Это настройка самого объекта, а не права доступа — администратор должен включить доступ по API, чтобы эта страница заработала.",
retry: "Повторить",
managedBy: {
system: {
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 3534c1468..8294ecf06 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -550,6 +550,8 @@ const zh = {
loadErrorUnauthorizedMessage: '登录状态已过期或已退出。请重新登录后查看这些记录。',
loadErrorRejectedTitle: '该视图的查询被拒绝',
loadErrorRejectedMessage: '服务器无法处理该视图的筛选条件或查询参数。清除筛选条件通常即可恢复;如果视图本身就是这样保存的,需要管理员修正。',
+ loadErrorApiDisabledTitle: '该对象未开放 API 访问',
+ loadErrorApiDisabledMessage: '此页面无法加载记录,因为该对象未通过 API 开放。这是对象自身的设置,而不是权限问题 —— 需要管理员为其启用 API 访问,此页面才能正常工作。',
retry: '重试',
managedBy: {
system: {
diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx
index a82790143..f93c54f9b 100644
--- a/packages/plugin-list/src/ListView.tsx
+++ b/packages/plugin-list/src/ListView.tsx
@@ -393,7 +393,9 @@ export function evaluateConditionalFormatting(
* is indistinguishable from a real outage — users were told to debug their
* network when the server had (correctly) denied them access.
*/
-function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'rejected' | 'network' {
+function classifyLoadError(
+ err: unknown,
+): 'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network' {
const e = err as any;
// The ObjectStack client decorates errors with `httpStatus`; raw fetch
// wrappers surface `status` / `statusCode`; some adapters only embed the
@@ -405,6 +407,14 @@ function classifyLoadError(err: unknown): 'forbidden' | 'unauthorized' | 'reject
if (m) status = Number(m[1]);
}
const code = typeof e?.code === 'string' ? e.code.toUpperCase() : '';
+ // The object's `enable` block withholds this operation — checked FIRST, and
+ // on the CODE alone. Status cannot carry this verdict: the denial is a 404,
+ // the same status a missing collection and a missing record answer with, and
+ // its 405 sibling is the same status any other method rejection uses. Unlike
+ // every kind below it this one is not about the request, the session or the
+ // network — it is a permanent property of the object, identical for every
+ // persona and every retry (objectui#4408).
+ if (API_ACCESS_DENIED_CODES.has(code)) return 'api-disabled';
if (status === 403 || code === 'PERMISSION_DENIED' || code === 'FORBIDDEN') return 'forbidden';
if (status === 401 || code === 'UNAUTHORIZED' || code === 'UNAUTHENTICATED') return 'unauthorized';
// The server understood the request and refused it as malformed. Retrying
@@ -423,6 +433,25 @@ const REJECTED_REQUEST_CODES = new Set([
'INVALID_QUERY',
]);
+/**
+ * The denials the server derives from the object's `enable` block —
+ * `apiAccessDenialFromEnable` in objectstack's REST server.
+ *
+ * - `OBJECT_API_DISABLED` (404) — `enable.apiEnabled: false`; the object is
+ * not exposed over the data API at all.
+ * - `OBJECT_API_METHOD_NOT_ALLOWED` (405) — the operation is absent from the
+ * `enable.apiMethods` whitelist.
+ *
+ * Both are pure functions of the object's metadata: no user, no permission, no
+ * request body. So the honest copy for them is neither "try again" (nothing
+ * will change) nor "ask your administrator for access" (this is not a
+ * permission grant — the object is not published to the API at all).
+ */
+const API_ACCESS_DENIED_CODES = new Set([
+ 'OBJECT_API_DISABLED',
+ 'OBJECT_API_METHOD_NOT_ALLOWED',
+]);
+
// Default English translations for fallback when I18nProvider is not available.
//
// Every row whose key the `en` pack also defines must stay byte-identical to it,
@@ -461,6 +490,13 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = {
// request, so the copy points at the filter instead of the network.
'list.loadErrorRejectedTitle': 'This view’s query was rejected',
'list.loadErrorRejectedMessage': 'The server could not process this view’s filter or query options. Clearing the filters usually fixes it; if the view is saved this way, an administrator needs to correct it.',
+ // Load DENIED BY THE OBJECT — the server answered 404 `OBJECT_API_DISABLED`
+ // or 405 `OBJECT_API_METHOD_NOT_ALLOWED`. This is not "no records", not a
+ // permission grant anyone can give, and not something a retry can change:
+ // the object's `enable` block withholds the API, for every user, permanently.
+ // Say that, because the alternative reads as an empty list (objectui#4408).
+ 'list.loadErrorApiDisabledTitle': 'This object isn’t available through the API',
+ 'list.loadErrorApiDisabledMessage': 'This page can’t load its records because the object is not exposed through the API. That is a setting on the object itself, not a permission — an administrator has to enable API access for it before this page can work.',
'list.retry': 'Retry',
// The bare NOUN, for the search button's tooltip. It is deliberately NOT the
// input placeholder: that is `table.search` below (objectui#4375).
@@ -763,7 +799,7 @@ export const ListView = React.forwardRef(({
// failed. Captured here so the render can show a retryable error panel.
const [loadError, setLoadError] = React.useState(null);
// What KIND of failure `loadError` is — drives which error panel copy shows.
- const [loadErrorKind, setLoadErrorKind] = React.useState<'forbidden' | 'unauthorized' | 'rejected' | 'network'>('network');
+ const [loadErrorKind, setLoadErrorKind] = React.useState<'api-disabled' | 'forbidden' | 'unauthorized' | 'rejected' | 'network'>('network');
// Start in loading state when we will fetch from a dataSource so the empty
// state doesn't flash before the first effect runs. Inline data (schema.data
// as an array or a `value` provider) starts as not-loading.
@@ -2966,18 +3002,24 @@ export const ListView = React.forwardRef(({
: }
iconWrapperClassName="mb-3"
title={t(
- loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle'
- : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle'
- : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle'
- : 'list.loadErrorTitle',
+ loadErrorKind === 'api-disabled' ? 'list.loadErrorApiDisabledTitle'
+ : loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenTitle'
+ : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedTitle'
+ : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle'
+ : 'list.loadErrorTitle',
)}
description={t(
- loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage'
- : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage'
- : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedMessage'
- : 'list.loadErrorMessage',
+ loadErrorKind === 'api-disabled' ? 'list.loadErrorApiDisabledMessage'
+ : loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage'
+ : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage'
+ : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedMessage'
+ : 'list.loadErrorMessage',
)}
- action={(
+ action={loadErrorKind === 'api-disabled' ? undefined : (
+ // No Retry for an `enable`-block denial. The verdict is a pure
+ // function of the object's metadata, so every retry re-fetches
+ // the identical refusal — offering the button is the same wrong
+ // advice as "check your connection", just spelled as a control.