This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/feat/chatbot-p3-namespaces
in repository https://gitbox.apache.org/repos/asf/superset.git

commit bf71eb67122326770cae9fb32ab02337ef224692
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 26 13:52:25 2026 +0200

    feat(extensions): P2 admin guards + P3 context namespaces (dashboard, 
explore, dataset, navigation)
---
 .../packages/superset-core/package.json            |  16 ++++
 .../packages/superset-core/src/dashboard/index.ts  |  84 +++++++++++++++++
 .../packages/superset-core/src/dataset/index.ts    |  73 +++++++++++++++
 .../packages/superset-core/src/explore/index.ts    |  75 +++++++++++++++
 .../packages/superset-core/src/index.ts            |   4 +
 .../packages/superset-core/src/navigation/index.ts |  91 ++++++++++++++++++
 .../src/components/ChatbotMount/index.tsx          |  11 ++-
 superset-frontend/src/core/chatbot/index.ts        |  19 +++-
 superset-frontend/src/core/dashboard/index.ts      |  86 +++++++++++++++++
 superset-frontend/src/core/dataset/index.ts        |  62 +++++++++++++
 superset-frontend/src/core/explore/index.ts        |  82 +++++++++++++++++
 superset-frontend/src/core/index.ts                |   4 +
 superset-frontend/src/core/navigation/index.ts     | 102 +++++++++++++++++++++
 .../src/extensions/ExtensionsStartup.tsx           |  36 +++++++-
 superset/extensions/api.py                         |   5 +
 superset/extensions/settings.py                    |   3 +-
 16 files changed, 741 insertions(+), 12 deletions(-)

diff --git a/superset-frontend/packages/superset-core/package.json 
b/superset-frontend/packages/superset-core/package.json
index cf9d4a02665..0fd06f79a62 100644
--- a/superset-frontend/packages/superset-core/package.json
+++ b/superset-frontend/packages/superset-core/package.json
@@ -18,6 +18,22 @@
       "types": "./lib/authentication/index.d.ts",
       "default": "./lib/authentication/index.js"
     },
+    "./dashboard": {
+      "types": "./lib/dashboard/index.d.ts",
+      "default": "./lib/dashboard/index.js"
+    },
+    "./dataset": {
+      "types": "./lib/dataset/index.d.ts",
+      "default": "./lib/dataset/index.js"
+    },
+    "./explore": {
+      "types": "./lib/explore/index.d.ts",
+      "default": "./lib/explore/index.js"
+    },
+    "./navigation": {
+      "types": "./lib/navigation/index.d.ts",
+      "default": "./lib/navigation/index.js"
+    },
     "./commands": {
       "types": "./lib/commands/index.d.ts",
       "default": "./lib/commands/index.js"
diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts 
b/superset-frontend/packages/superset-core/src/dashboard/index.ts
new file mode 100644
index 00000000000..4e78c7eef6c
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/dashboard/index.ts
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview Dashboard namespace for Superset extensions (P3).
+ *
+ * Exposes dashboard identity and filter state as a stable semantic API.
+ * Extensions must not depend on the Redux dashboard slice structure directly.
+ */
+
+import { Event } from '../common';
+
+/**
+ * A single native filter's current selected value(s).
+ * The value type is intentionally kept as `unknown` because filter values
+ * are heterogeneous (date ranges, string lists, numbers, etc.).
+ */
+export interface FilterValue {
+  /** The filter's stable id. */
+  filterId: string;
+  /** Display label of the filter. */
+  label: string;
+  /** Currently applied value, or `null` when the filter is cleared. */
+  value: unknown;
+}
+
+/**
+ * Normalized dashboard context exposed to extensions on the Dashboard page.
+ */
+export interface DashboardContext {
+  /** Numeric dashboard id. */
+  dashboardId: number;
+  /** Display title of the dashboard. */
+  title: string;
+  /**
+   * Active native filter values keyed by filter id.
+   * Only includes filters that have a value applied.
+   */
+  filters: FilterValue[];
+}
+
+/**
+ * Returns the normalized dashboard context for the page currently being 
viewed,
+ * or `undefined` when the user is not on a Dashboard page.
+ *
+ * @example
+ * ```typescript
+ * const dash = dashboard.getCurrentDashboard();
+ * if (dash) {
+ *   console.log(dash.title, dash.filters);
+ * }
+ * ```
+ */
+export declare function getCurrentDashboard(): DashboardContext | undefined;
+
+/**
+ * Event fired when the dashboard identity or its active filter values change.
+ * Fired on native filter value changes and on navigation to a different 
dashboard.
+ *
+ * @example
+ * ```typescript
+ * const sub = dashboard.onDidChangeDashboard(dash => {
+ *   chatbot.updateContext({ dashboard: dash });
+ * });
+ * sub.dispose();
+ * ```
+ */
+export declare const onDidChangeDashboard: Event<DashboardContext>;
diff --git a/superset-frontend/packages/superset-core/src/dataset/index.ts 
b/superset-frontend/packages/superset-core/src/dataset/index.ts
new file mode 100644
index 00000000000..ea3fafa4fdb
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/dataset/index.ts
@@ -0,0 +1,73 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview Dataset namespace for Superset extensions (P3).
+ *
+ * Exposes the dataset currently being viewed as a stable semantic API.
+ * Aligned with backend-enforced dataset visibility and column-access 
semantics.
+ */
+
+import { Event } from '../common';
+
+/**
+ * Normalized dataset context exposed to extensions on the Dataset page.
+ */
+export interface DatasetContext {
+  /** Numeric dataset id. */
+  datasetId: number;
+  /** Display name (table name or virtual dataset name). */
+  datasetName: string;
+  /** Schema the dataset belongs to, if applicable. */
+  schema: string | null;
+  /** Catalog the dataset belongs to, if applicable. */
+  catalog: string | null;
+  /** Database name backing this dataset. */
+  databaseName: string | null;
+  /** Whether this is a virtual (SQL-defined) dataset. */
+  isVirtual: boolean;
+}
+
+/**
+ * Returns the normalized dataset context for the page currently being viewed,
+ * or `undefined` when the user is not on a Dataset page.
+ *
+ * @example
+ * ```typescript
+ * const ds = dataset.getCurrentDataset();
+ * if (ds) {
+ *   console.log(ds.datasetName, ds.schema);
+ * }
+ * ```
+ */
+export declare function getCurrentDataset(): DatasetContext | undefined;
+
+/**
+ * Event fired when the focused dataset changes (e.g. the user navigates to a
+ * different dataset detail page).
+ *
+ * @example
+ * ```typescript
+ * const sub = dataset.onDidChangeDataset(ds => {
+ *   chatbot.updateContext({ dataset: ds });
+ * });
+ * sub.dispose();
+ * ```
+ */
+export declare const onDidChangeDataset: Event<DatasetContext>;
diff --git a/superset-frontend/packages/superset-core/src/explore/index.ts 
b/superset-frontend/packages/superset-core/src/explore/index.ts
new file mode 100644
index 00000000000..162d1b2e6f7
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/explore/index.ts
@@ -0,0 +1,75 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview Explore namespace for Superset extensions (P3).
+ *
+ * Exposes the current chart/explore context as a stable semantic API.
+ * Normalized over Explore Redux state — extensions must not depend on
+ * the Redux slice structure directly.
+ */
+
+import { Event } from '../common';
+
+/**
+ * Normalized chart context exposed to extensions during an Explore session.
+ * Covers saved chart identity and transient editing context; excludes raw
+ * form-data internals and datasource-implementation details.
+ */
+export interface ChartContext {
+  /** The saved chart id, or `null` when the chart has not been persisted. */
+  chartId: number | null;
+  /** Display name of the saved chart, or `null` for a new/unsaved chart. */
+  chartName: string | null;
+  /** The visualization type currently selected in the editor. */
+  vizType: string;
+  /** Id of the datasource backing the chart (physical or virtual dataset). */
+  datasourceId: number | null;
+  /** Human-readable datasource name. */
+  datasourceName: string | null;
+}
+
+/**
+ * Returns the normalized chart context for the active Explore session, or
+ * `undefined` when the user is not on the Explore page.
+ *
+ * @example
+ * ```typescript
+ * const chart = explore.getCurrentChart();
+ * if (chart) {
+ *   console.log(chart.vizType, chart.chartName);
+ * }
+ * ```
+ */
+export declare function getCurrentChart(): ChartContext | undefined;
+
+/**
+ * Event fired when the chart context changes within the active Explore session
+ * (e.g. when the viz type, datasource, or saved name changes).
+ * Not fired during route changes — subscribe to `navigation.onDidChangePage` 
for those.
+ *
+ * @example
+ * ```typescript
+ * const sub = explore.onDidChangeChart(chart => {
+ *   chatbot.updateContext({ chart });
+ * });
+ * sub.dispose();
+ * ```
+ */
+export declare const onDidChangeChart: Event<ChartContext>;
diff --git a/superset-frontend/packages/superset-core/src/index.ts 
b/superset-frontend/packages/superset-core/src/index.ts
index 75863372409..79c699caff4 100644
--- a/superset-frontend/packages/superset-core/src/index.ts
+++ b/superset-frontend/packages/superset-core/src/index.ts
@@ -19,9 +19,13 @@
 export * as common from './common';
 export * as authentication from './authentication';
 export * as commands from './commands';
+export * as dashboard from './dashboard';
+export * as dataset from './dataset';
 export * as editors from './editors';
+export * as explore from './explore';
 export * as extensions from './extensions';
 export * as menus from './menus';
+export * as navigation from './navigation';
 export * as sqlLab from './sqlLab';
 export * as views from './views';
 export * as contributions from './contributions';
diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts 
b/superset-frontend/packages/superset-core/src/navigation/index.ts
new file mode 100644
index 00000000000..a26766db1f3
--- /dev/null
+++ b/superset-frontend/packages/superset-core/src/navigation/index.ts
@@ -0,0 +1,91 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview Navigation namespace for Superset extensions (P3).
+ *
+ * Exposes stable routing and page-surface context to extensions.
+ * Extensions use this namespace to react to page changes without polling.
+ */
+
+import { Event } from '../common';
+
+/**
+ * The set of top-level application surfaces the chatbot is aware of.
+ * `'other'` covers any route not explicitly enumerated.
+ */
+export type PageType =
+  | 'dashboard'
+  | 'explore'
+  | 'sqllab'
+  | 'dataset'
+  | 'home'
+  | 'other';
+
+/**
+ * Lightweight page descriptor: the current surface and the focused entity id,
+ * if the surface has one (dashboard id, chart id, dataset id). Does not embed
+ * full entity payloads — use the surface-specific namespace for those.
+ */
+export interface PageContext {
+  pageType: PageType;
+  /**
+   * The numeric id of the primary entity on this page, if applicable.
+   * `null` when the surface has no focused entity, or when the entity is
+   * addressed by a non-numeric slug (e.g. dashboard accessed via slug URL).
+   */
+  entityId: number | null;
+}
+
+/**
+ * Returns the current page surface type.
+ *
+ * @example
+ * ```typescript
+ * const pageType = navigation.getPageType();
+ * if (pageType === 'dashboard') { ... }
+ * ```
+ */
+export declare function getPageType(): PageType;
+
+/**
+ * Returns lightweight context about the current page: surface type and focused
+ * entity id. Does not embed entity payloads; use the surface namespace for 
those.
+ *
+ * @example
+ * ```typescript
+ * const { pageType, entityId } = navigation.getCurrentPage();
+ * ```
+ */
+export declare function getCurrentPage(): PageContext;
+
+/**
+ * Event fired whenever the user navigates to a different page or entity.
+ * Provides the new `PageContext` as the event payload.
+ *
+ * @example
+ * ```typescript
+ * const sub = navigation.onDidChangePage(({ pageType, entityId }) => {
+ *   chatbot.updateContext({ pageType, entityId });
+ * });
+ * // later:
+ * sub.dispose();
+ * ```
+ */
+export declare const onDidChangePage: Event<PageContext>;
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx 
b/superset-frontend/src/components/ChatbotMount/index.tsx
index a88291c56fc..5dd8f9f01cd 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -29,16 +29,19 @@ const CHATBOT_EDGE_MARGIN = 24;
 const ChatbotMount = () => {
   const theme = useTheme();
   const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
+  const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
   const [activeChatbot, setActiveChatbot] = useState(() =>
-    getActiveChatbot(null),
+    getActiveChatbot(null, {}),
   );
 
   useEffect(() => {
     SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
       .then(({ json }) => {
         const id = json.result?.active_chatbot_id ?? null;
+        const enabled: Record<string, boolean> = json.result?.enabled ?? {};
         setAdminSelectedId(id);
-        setActiveChatbot(getActiveChatbot(id));
+        setEnabledMap(enabled);
+        setActiveChatbot(getActiveChatbot(id, enabled));
       })
       .catch(() => {
         // Settings fetch failure is non-fatal — fall back to 
first-to-register.
@@ -48,9 +51,9 @@ const ChatbotMount = () => {
   useEffect(
     () =>
       subscribeToLocation(CHATBOT_LOCATION, () =>
-        setActiveChatbot(getActiveChatbot(adminSelectedId)),
+        setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
       ),
-    [adminSelectedId],
+    [adminSelectedId, enabledMap],
   );
 
   if (!activeChatbot) {
diff --git a/superset-frontend/src/core/chatbot/index.ts 
b/superset-frontend/src/core/chatbot/index.ts
index 4b1d1bb00a7..4cb92b4bc9a 100644
--- a/superset-frontend/src/core/chatbot/index.ts
+++ b/superset-frontend/src/core/chatbot/index.ts
@@ -48,24 +48,35 @@ export interface ActiveChatbot {
  *
  * Selection policy:
  *  - If no chatbot is registered, returns `undefined` — the corner stays 
empty.
- *  - If `adminSelectedId` is provided and matches a registered chatbot, that 
one wins.
- *  - Otherwise the first-to-register chatbot is used as a fallback.
+ *  - Disabled chatbots (per `enabledMap`) are excluded before selection.
+ *  - If `adminSelectedId` matches an enabled registered chatbot, that one 
wins.
+ *  - Otherwise the first enabled chatbot in registration order is used as a 
fallback.
  *
  * @param adminSelectedId The id stored in the admin "Default chatbot" 
setting, if any.
+ * @param enabledMap Per-extension enabled flags from the admin settings API.
  * @returns The active chatbot's id and provider, or `undefined` if none.
  */
 export const getActiveChatbot = (
   adminSelectedId?: string | null,
+  enabledMap?: Record<string, boolean>,
 ): ActiveChatbot | undefined => {
   const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
   if (registeredIds.length === 0) {
     return undefined;
   }
 
+  const candidates = enabledMap
+    ? registeredIds.filter(id => enabledMap[id] !== false)
+    : registeredIds;
+
+  if (candidates.length === 0) {
+    return undefined;
+  }
+
   const selectedId =
-    adminSelectedId && registeredIds.includes(adminSelectedId)
+    adminSelectedId && candidates.includes(adminSelectedId)
       ? adminSelectedId
-      : registeredIds[0];
+      : candidates[0];
 
   const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
   if (!provider) {
diff --git a/superset-frontend/src/core/dashboard/index.ts 
b/superset-frontend/src/core/dashboard/index.ts
new file mode 100644
index 00000000000..1ae57066683
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/index.ts
@@ -0,0 +1,86 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Host-internal implementation of the `dashboard` namespace.
+ *
+ * Wraps Redux dashboardInfo and dataMask state and normalizes them into the
+ * stable `DashboardContext` contract. Extensions must not depend on the Redux
+ * slice structure directly.
+ */
+
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
+import {
+  UPDATE_DATA_MASK,
+  SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
+} from 'src/dataMask/actions';
+import { store, RootState } from 'src/views/store';
+import { AnyListenerPredicate } from '@reduxjs/toolkit';
+import { createActionListener } from '../utils';
+
+type DashboardContext = dashboardApi.DashboardContext;
+type FilterValue = dashboardApi.FilterValue;
+
+function buildDashboardContext(): DashboardContext | undefined {
+  const state = store.getState();
+  const info = (state as any).dashboardInfo;
+  if (!info?.id) return undefined;
+
+  const nativeFilters = (state as any).nativeFilters?.filters ?? {};
+  const dataMask = (state as any).dataMask ?? {};
+
+  const filters: FilterValue[] = Object.entries(dataMask)
+    .filter(([id]) => id in nativeFilters)
+    .map(([id, mask]: [string, any]) => ({
+      filterId: id,
+      label: nativeFilters[id]?.name ?? id,
+      value: mask?.filterState?.value ?? null,
+    }));
+
+  return {
+    dashboardId: info.id as number,
+    title: info.dashboard_title ?? info.slug ?? String(info.id),
+    filters,
+  };
+}
+
+const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
+  action.type === HYDRATE_DASHBOARD ||
+  action.type === UPDATE_DATA_MASK ||
+  action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
+
+const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
+  buildDashboardContext();
+
+const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
+  listener: (ctx: DashboardContext) => void,
+  thisArgs?: any,
+) =>
+  createActionListener<DashboardContext>(
+    dashboardChangePredicate,
+    listener,
+    () => buildDashboardContext() ?? null,
+    thisArgs,
+  );
+
+export const dashboard: typeof dashboardApi = {
+  getCurrentDashboard,
+  onDidChangeDashboard,
+};
diff --git a/superset-frontend/src/core/dataset/index.ts 
b/superset-frontend/src/core/dataset/index.ts
new file mode 100644
index 00000000000..3c98d79454b
--- /dev/null
+++ b/superset-frontend/src/core/dataset/index.ts
@@ -0,0 +1,62 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Host-internal implementation of the `dataset` namespace.
+ *
+ * Dataset page components call `setCurrentDataset` to publish context as they
+ * load. Extensions consume the stable `DatasetContext` contract; they are
+ * isolated from the page's internal data-fetching implementation.
+ */
+
+import type { dataset as datasetApi } from '@apache-superset/core';
+import { Disposable } from '../models';
+
+type DatasetContext = datasetApi.DatasetContext;
+
+let currentDataset: DatasetContext | undefined;
+const listeners = new Set<(ctx: DatasetContext) => void>();
+
+/**
+ * Host-internal: called by the Dataset page when its entity loads or changes.
+ * Not part of the public `@apache-superset/core` API.
+ */
+export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
+  currentDataset = ctx;
+  if (ctx) {
+    listeners.forEach(fn => fn(ctx));
+  }
+};
+
+const getCurrentDataset: typeof datasetApi.getCurrentDataset = () =>
+  currentDataset ? { ...currentDataset } : undefined;
+
+const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
+  listener: (ctx: DatasetContext) => void,
+  thisArgs?: any,
+): Disposable => {
+  const bound = thisArgs ? listener.bind(thisArgs) : listener;
+  listeners.add(bound);
+  return new Disposable(() => listeners.delete(bound));
+};
+
+export const dataset: typeof datasetApi = {
+  getCurrentDataset,
+  onDidChangeDataset,
+};
diff --git a/superset-frontend/src/core/explore/index.ts 
b/superset-frontend/src/core/explore/index.ts
new file mode 100644
index 00000000000..4dc3fdb8276
--- /dev/null
+++ b/superset-frontend/src/core/explore/index.ts
@@ -0,0 +1,82 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Host-internal implementation of the `explore` namespace.
+ *
+ * Wraps Redux explore state and normalizes it into the stable `ChartContext`
+ * contract. Extensions must not depend on the Redux slice structure directly.
+ */
+
+import type { explore as exploreApi } from '@apache-superset/core';
+import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
+import {
+  SET_FORM_DATA,
+  UPDATE_CHART_TITLE,
+} from 'src/explore/actions/exploreActions';
+import { store, RootState } from 'src/views/store';
+import { AnyListenerPredicate } from '@reduxjs/toolkit';
+import { createActionListener } from '../utils';
+
+type ChartContext = exploreApi.ChartContext;
+
+function buildChartContext(): ChartContext | undefined {
+  const state = store.getState();
+  const exploreState = (state as any).explore;
+  if (!exploreState) return undefined;
+
+  const { slice, datasource, controls } = exploreState;
+  const vizType: string =
+    (controls?.viz_type?.value as string) ??
+    exploreState.form_data?.viz_type ??
+    '';
+
+  return {
+    chartId: slice?.slice_id ?? null,
+    chartName: slice?.slice_name ?? null,
+    vizType,
+    datasourceId: datasource?.id ?? null,
+    datasourceName:
+      datasource?.table_name ?? datasource?.datasource_name ?? null,
+  };
+}
+
+const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
+  action.type === HYDRATE_EXPLORE ||
+  action.type === SET_FORM_DATA ||
+  action.type === UPDATE_CHART_TITLE;
+
+const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
+  buildChartContext();
+
+const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
+  listener: (ctx: ChartContext) => void,
+  thisArgs?: any,
+) =>
+  createActionListener<ChartContext>(
+    exploreChangePredicate,
+    listener,
+    () => buildChartContext() ?? null,
+    thisArgs,
+  );
+
+export const explore: typeof exploreApi = {
+  getCurrentChart,
+  onDidChangeChart,
+};
diff --git a/superset-frontend/src/core/index.ts 
b/superset-frontend/src/core/index.ts
index 6a106ebe87a..d259597457c 100644
--- a/superset-frontend/src/core/index.ts
+++ b/superset-frontend/src/core/index.ts
@@ -28,10 +28,14 @@ export const core: typeof coreType = {
 
 export * from './authentication';
 export * from './commands';
+export * from './dashboard';
+export * from './dataset';
 export * from './editors';
+export * from './explore';
 export * from './extensions';
 export * from './menus';
 export * from './models';
+export * from './navigation';
 export * from './sqlLab';
 export * from './utils';
 export * from './views';
diff --git a/superset-frontend/src/core/navigation/index.ts 
b/superset-frontend/src/core/navigation/index.ts
new file mode 100644
index 00000000000..12799900280
--- /dev/null
+++ b/superset-frontend/src/core/navigation/index.ts
@@ -0,0 +1,102 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Host-internal implementation of the `navigation` namespace.
+ *
+ * Backed by browser location — no Redux dependency.
+ * The app shell calls `notifyPageChange(pathname)` whenever the route changes.
+ */
+
+import type { navigation as navigationApi } from '@apache-superset/core';
+import { Disposable } from '../models';
+
+type PageType = navigationApi.PageType;
+type PageContext = navigationApi.PageContext;
+
+const listeners = new Set<(ctx: PageContext) => void>();
+
+function derivePageType(pathname: string): PageType {
+  if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
+  if (pathname.startsWith('/explore/')) return 'explore';
+  if (pathname.startsWith('/superset/explore/')) return 'explore';
+  if (pathname.startsWith('/chart/add')) return 'explore';
+  if (pathname.startsWith('/sqllab/')) return 'sqllab';
+  if (pathname.startsWith('/dataset/')) return 'dataset';
+  if (pathname.startsWith('/superset/welcome/')) return 'home';
+  return 'other';
+}
+
+function extractEntityId(pathname: string, pageType: PageType): number | null {
+  if (pageType === 'dashboard') {
+    const m = pathname.match(/\/superset\/dashboard\/(\d+)/);
+    return m ? parseInt(m[1], 10) : null;
+  }
+  if (pageType === 'dataset') {
+    const m = pathname.match(/\/dataset\/(\d+)/);
+    return m ? parseInt(m[1], 10) : null;
+  }
+  return null;
+}
+
+let currentContext: PageContext = {
+  pageType: derivePageType(window.location.pathname),
+  entityId: null,
+};
+currentContext.entityId = extractEntityId(
+  window.location.pathname,
+  currentContext.pageType,
+);
+
+/** Called by ExtensionsStartup whenever the React Router location changes. */
+export const notifyPageChange = (pathname: string): void => {
+  const pageType = derivePageType(pathname);
+  const entityId = extractEntityId(pathname, pageType);
+  const next: PageContext = { pageType, entityId };
+  if (
+    next.pageType === currentContext.pageType &&
+    next.entityId === currentContext.entityId
+  ) {
+    return;
+  }
+  currentContext = next;
+  listeners.forEach(fn => fn(next));
+};
+
+const getPageType: typeof navigationApi.getPageType = () =>
+  currentContext.pageType;
+
+const getCurrentPage: typeof navigationApi.getCurrentPage = () => ({
+  ...currentContext,
+});
+
+const onDidChangePage: typeof navigationApi.onDidChangePage = (
+  listener: (ctx: PageContext) => void,
+  thisArgs?: any,
+): Disposable => {
+  const bound = thisArgs ? listener.bind(thisArgs) : listener;
+  listeners.add(bound);
+  return new Disposable(() => listeners.delete(bound));
+};
+
+export const navigation: typeof navigationApi = {
+  getPageType,
+  getCurrentPage,
+  onDidChangePage,
+};
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx 
b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index 5d523be8a4a..e72478d3229 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -16,7 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { useLocation } from 'react-router-dom';
 // eslint-disable-next-line no-restricted-syntax
 import * as supersetCore from '@apache-superset/core';
 import { logging } from '@apache-superset/core/utils';
@@ -25,12 +26,17 @@ import {
   authentication,
   core,
   commands,
+  dashboard,
+  dataset,
   editors,
+  explore,
   extensions,
   menus,
+  navigation,
   sqlLab,
   views,
 } from 'src/core';
+import { notifyPageChange } from 'src/core/navigation';
 import { useSelector } from 'react-redux';
 import { RootState } from 'src/views/store';
 import ExtensionsLoader from './ExtensionsLoader';
@@ -41,9 +47,13 @@ declare global {
       authentication: typeof authentication;
       core: typeof core;
       commands: typeof commands;
+      dashboard: typeof dashboard;
+      dataset: typeof dataset;
       editors: typeof editors;
+      explore: typeof explore;
       extensions: typeof extensions;
       menus: typeof menus;
+      navigation: typeof navigation;
       sqlLab: typeof sqlLab;
       views: typeof views;
     };
@@ -54,11 +64,21 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
   children,
 }) => {
   const [initialized, setInitialized] = useState(false);
+  const location = useLocation();
+  const prevPathname = useRef<string | null>(null);
 
   const userId = useSelector<RootState, number | undefined>(
     ({ user }) => user.userId,
   );
 
+  // Notify the navigation namespace on every route change.
+  useEffect(() => {
+    if (prevPathname.current !== location.pathname) {
+      prevPathname.current = location.pathname;
+      notifyPageChange(location.pathname);
+    }
+  }, [location.pathname]);
+
   useEffect(() => {
     if (initialized) return;
 
@@ -74,9 +94,13 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
       authentication,
       core,
       commands,
+      dashboard,
+      dataset,
       editors,
+      explore,
       extensions,
       menus,
+      navigation,
       sqlLab,
       views,
     };
@@ -87,7 +111,10 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
     // chains) would otherwise surface as uncaught rejections in the host.
     const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
       // Always log so extension authors can diagnose failures.
-      logging.error('[extensions] Unhandled rejection from extension:', 
event.reason);
+      logging.error(
+        '[extensions] Unhandled rejection from extension:',
+        event.reason,
+      );
       event.preventDefault();
     };
     window.addEventListener('unhandledrejection', handleUnhandledRejection);
@@ -102,7 +129,10 @@ const ExtensionsStartup: React.FC<{ children?: 
React.ReactNode }> = ({
     }
 
     return () => {
-      window.removeEventListener('unhandledrejection', 
handleUnhandledRejection);
+      window.removeEventListener(
+        'unhandledrejection',
+        handleUnhandledRejection,
+      );
     };
   }, [initialized, userId]);
 
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index f39db3686ed..d116c6ca70e 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -22,6 +22,7 @@ from flask import request, send_file
 from flask.wrappers import Response
 from flask_appbuilder.api import BaseApi, expose, protect, safe
 
+from superset.extensions import security_manager
 from superset.extensions.settings import (
     get_extension_settings,
     update_extension_settings,
@@ -209,7 +210,11 @@ class ExtensionsRestApi(BaseApi):
           responses:
             200:
               description: Updated settings
+            403:
+              $ref: '#/components/responses/403'
         """
+        if not security_manager.is_admin():
+            return self.response(403, message="Admin access required.")
         body = request.get_json(silent=True) or {}
         result = update_extension_settings(body)
         return self.response(200, result=result)
diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py
index 30189df8e28..f3a9739a85d 100644
--- a/superset/extensions/settings.py
+++ b/superset/extensions/settings.py
@@ -21,6 +21,7 @@ from typing import Any
 
 from superset import db
 from superset.models.core import ExtensionEnabled, ExtensionSettings
+from superset.utils.decorators import transaction
 
 _SETTINGS_ROW_ID = 1
 
@@ -34,6 +35,7 @@ def get_extension_settings() -> dict[str, Any]:
     }
 
 
+@transaction()
 def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]:
     row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
     if row is None:
@@ -51,5 +53,4 @@ def update_extension_settings(body: dict[str, Any]) -> 
dict[str, Any]:
                 db.session.add(flag)
             flag.enabled = bool(enabled)
 
-    db.session.commit()
     return get_extension_settings()


Reply via email to