feat(craft): show a Business upsell for Craft on Community Edition (#15901)

This commit is contained in:
roshan authored and GitHub committed 2026-10-14 00:16:45 +00:00
1 parent 7283da0b62
commit 777b9bf1f7
30 files changed
+394 -41

No files matched your search

@@ -109,8 +109,8 @@ the coding-agent sandbox).
| Method | Path | Handler | Permission |
|---|---|---|---|
| CRUD | `/admin/api-key*` | `server/api_key/api.py` | `Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS`. Creating or updating a key is **admin-equivalent by design**: `group_ids` is uncapped, so a holder can add a key to the Admin group (`api.py:create_api_key` comment). |
| GET | `/user/pats/scopes` | `server/pat/api.py:list_selectable_scopes` | `Permission.BASIC_ACCESS`. Drops `use:llm_gateway` from the list while the workspace `llm_gateway_enabled` setting is off. |
| GET/POST/DELETE | `/user/pats` | `server/pat/api.py` | Create needs `CREATE_USER_API_KEYS`. The seeded Basic group grants it by default, and a full admin can switch it off or on from the Basic group's page (`auth/permissions.py:DEFAULT_GROUP_TOGGLEABLE_PERMISSIONS`). The same permission gates MCP OAuth consent (`server/oauth_provider/api.py`), so the toggle covers both. List and delete need `BASIC_ACCESS`, and a user only ever sees or revokes their own. Minting a PAT with `use:llm_gateway` while the setting is off is rejected with `INVALID_INPUT` (`server/pat/api.py:_validate_assignable_scopes`). |
| GET | `/user/pats/scopes` | `server/pat/api.py:list_selectable_scopes` | `Permission.BASIC_ACCESS`. Drops scopes whose `min_tier` is above the current tier (`use:llm_gateway` is Business+), and drops `use:llm_gateway` while the workspace `llm_gateway_enabled` setting is off. |
| GET/POST/DELETE | `/user/pats` | `server/pat/api.py` | Create needs `CREATE_USER_API_KEYS`. The seeded Basic group grants it by default, and a full admin can switch it off or on from the Basic group's page (`auth/permissions.py:DEFAULT_GROUP_TOGGLEABLE_PERMISSIONS`). The same permission gates MCP OAuth consent (`server/oauth_provider/api.py`), so the toggle covers both. List and delete need `BASIC_ACCESS`, and a user only ever sees or revokes their own. Minting a PAT with a scope above the current tier is rejected with `FEATURE_NOT_AVAILABLE`, and minting one with `use:llm_gateway` while the setting is off is rejected with `INVALID_INPUT` (`server/pat/api.py:_validate_assignable_scopes`). |
| GET/PUT | `/admin/security`, GET `/admin/security/pinned-fields` | `server/security/api.py` | `FULL_ADMIN_PANEL_ACCESS`. Workspace security overrides: password policy, `password_auth_enabled`, `valid_email_domains`, JWT validation fields, SSRF level, incognito availability, `allow_connector_group_restrictions`, and `allow_connector_check_findings` (on by default; off, capability checks store and return no findings). Env-pinned fields win over stored values. Turning on `allow_connector_group_restrictions` needs the Business tier. |
| CRUD | `/admin/user-group*` | `ee/onyx/server/user_group/api.py` | See [[access-control]] §2; this is where group membership, and thus `PermissionGrant`-derived capability, is actually assigned. |
| GET/POST/PATCH | `/admin/sso-providers*`, `/admin/sso-providers/{id}/domains` | `server/manage/sso/api.py` | `FULL_ADMIN_PANEL_ACCESS` (exact gating per route). Domain-routing SSO providers beyond the first require business tier (`_require_business_tier_for_additional_enabled_provider`). |
@@ -569,8 +569,8 @@ is marked as such.
- [[llm-gateway]]: the `use:llm_gateway` PAT scope (`Permission.
USE_LLM_GATEWAY`, `db/enums.py`) is a `SELECTABLE_PAT_SCOPES` entry
(`server/pat/models.py`) minted and checked entirely by this component's
PAT machinery. The workspace `llm_gateway_enabled` setting hides the scope
in `list_selectable_scopes` and blocks minting it, but only for third-party
PAT machinery. The workspace `llm_gateway_enabled` setting and the scope's
Business `min_tier` both hide it in `list_selectable_scopes` and block minting it, but only for third-party
PATs; a Craft sandbox's `craft_sandbox`-scoped PAT is unaffected.
- [[cc-pairs-and-credentials]]: connector credentials are a separate
encrypted-secret concept from user auth; both use the same
+3 -1
View File
@@ -161,7 +161,9 @@ value the control plane sends.
`onyx/server/settings/tier_order.py:tier_at_least` gives every caller a total
order (`COMMUNITY < BUSINESS < ENTERPRISE`) without depending on EE code, so
CE call sites can gate on tier without importing `ee`.
CE call sites can gate on tier without importing `ee`. `get_current_tier` in the
same module resolves the deployment tier (`ee.onyx.utils.tier.get_tier`, or
`Tier.COMMUNITY` when EE code is not loaded) for those call sites.
### 4.2 License claim (self-hosted)
+10 -5
View File
@@ -132,10 +132,10 @@ require_onyx_craft_enabled server/features/build/api.py
└─ is_craft_enabled_for_user server/features/build/utils.py
├─ User.craft_enabled override (per-user; short-circuits if False)
├─ Settings.craft_default_enabled (workspace default, when override is None)
└─ is_craft_available_for_deployment (PostHog flag or ENABLE_CRAFT)
└─ is_craft_available_for_deployment (PostHog flag or ENABLE_CRAFT, AND tier >= LLM_GATEWAY_MIN_TIER)
```
`is_craft_enabled_for_user` (`build/utils.py:156`) is the single source of
`is_craft_enabled_for_user` (`build/utils.py:177`) is the single source of
truth: `AccountType.ANONYMOUS` users never get Craft (it is identity-bound: a
per-user sandbox, library, and scheduled tasks), then the per-user override
wins if set to `False`, then the workspace default gates `None`, and finally
@@ -321,9 +321,14 @@ not cover:
entirely; per-user OAuth credentials for external apps depend on the acting
user's identity.
- [[editions-and-gating]]: `is_craft_available_for_deployment` reads a
PostHog feature flag (or `ENABLE_CRAFT`), a deployment-level switch distinct
from any billing-tier check; `requiredTier: null` on all three routes
confirms Craft admin visibility is not tier-gated at the nav level.
PostHog feature flag (or `ENABLE_CRAFT`) and also requires the deployment
tier to reach `LLM_GATEWAY_MIN_TIER` (Business), because every Craft turn
goes through the tier-gated `/gateway`; Community never gets Craft.
When the flag is on but the tier is too low, `is_craft_locked_by_tier` sets
`onyx_craft_requires_upgrade` in `/settings`: the sidebar keeps Craft with a
Business tag and `/craft/*` renders `CraftUpgradePage`; `/build` stays 403.
`requiredTier: null` on all three routes confirms Craft admin visibility is
not tier-gated at the nav level.
- [[llm-providers]]: the Preferences default-model picker is the `CRAFT`
`LLMModelFlowType`; see `llm-providers.md` §9 for why it is a pointer, not a
capability check.
@@ -90,7 +90,7 @@ docstring in `backend/ee/onyx/server/license/api.py`.
| Method | Path | Handler | Notes |
|---|---|---|---|
| PATCH | `/admin/settings` | `admin_patch_settings` | Reads `global_version.is_ee_version()` to decide whether to call `ee.onyx.utils.tier.get_tier()` or default to `Tier.COMMUNITY`. Merges only the fields the caller sent (`model_fields_set`), under `settings_write_lock()`. |
| PATCH | `/admin/settings` | `admin_patch_settings` | Resolves the tier via `tier_order.get_current_tier()` (`ee.onyx.utils.tier.get_tier()` when EE code is loaded, else `Tier.COMMUNITY`). Merges only the fields the caller sent (`model_fields_set`), under `settings_write_lock()`. |
### Feature flags
@@ -80,8 +80,9 @@ gates, plus the workspace setting, exist.
`Permission.USE_LLM_GATEWAY` (`"use:llm_gateway"`,
`backend/onyx/db/enums.py`) is a selectable PAT scope
(`backend/onyx/server/pat/models.py`, `SELECTABLE_PAT_SCOPES`), gated
in the UI at `min_tier=LLM_GATEWAY_MIN_TIER` (Business). It is also implied
(`backend/onyx/server/pat/models.py`, `SELECTABLE_PAT_SCOPES`) with
`min_tier=LLM_GATEWAY_MIN_TIER` (Business): below that tier `GET /user/pats/scopes`
omits it and minting it fails with `FEATURE_NOT_AVAILABLE`. It is also implied
by `Permission.BASIC_ACCESS` and by `Permission.CRAFT_SANDBOX`
(`backend/onyx/auth/permissions.py`, `IMPLIED_PERMISSIONS`). GATE 1 therefore
accepts users whose permissions include `BASIC_ACCESS`, and Craft PATs with the
+24 -3
View File
@@ -16,7 +16,9 @@ from onyx.server.features.build.configs import (
MAX_UPLOAD_FILE_SIZE_BYTES,
OPENCODE_DISABLED_TOOLS,
)
from onyx.server.gateway.configs import LLM_GATEWAY_MIN_TIER
from onyx.server.settings.store import load_settings
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import get_current_tenant_id
@@ -118,10 +120,8 @@ OPENCODE_DISABLED_TOOLS_VARIANTS: dict[str, list[str]] = {
BUILD_MODE_FEATURE_ID = "build_mode"
def is_craft_available_for_deployment(user: User) -> bool:
def _craft_flag_enabled(user: User) -> bool:
"""
Check whether Onyx Craft (Build Mode) is available at the deployment level.
Flag logic for "onyx-craft-enabled":
- Flag = True → enabled (Onyx Craft is available)
- Flag = False → disabled (Onyx Craft is not available)
@@ -153,6 +153,27 @@ def is_craft_available_for_deployment(user: User) -> bool:
return False
def is_craft_available_for_deployment(user: User) -> bool:
"""
Check whether Onyx Craft (Build Mode) is available at the deployment level:
the craft flag must be on and the tier must reach LLM_GATEWAY_MIN_TIER,
since every Craft turn goes through the tier-gated LLM gateway.
"""
if not _craft_flag_enabled(user):
return False
return tier_at_least(get_current_tier(), LLM_GATEWAY_MIN_TIER)
def is_craft_locked_by_tier(user: User) -> bool:
"""Craft is switched on but the tier is below LLM_GATEWAY_MIN_TIER, so the
UI shows an upgrade prompt instead of Craft."""
if user.account_type == AccountType.ANONYMOUS:
return False
if not _craft_flag_enabled(user):
return False
return not tier_at_least(get_current_tier(), LLM_GATEWAY_MIN_TIER)
def is_craft_enabled_for_user(
user: User,
deployment_available: bool | None = None,
+17 -1
View File
@@ -18,6 +18,7 @@ from onyx.server.pat.models import (
TokenResponse,
)
from onyx.server.settings.store import load_settings
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -40,6 +41,16 @@ def _validate_assignable_scopes(scopes: list[Permission] | None) -> None:
OnyxErrorCode.INVALID_INPUT,
f"Unsupported token scope(s): {', '.join(s.value for s in unsupported)}",
)
tier = get_current_tier()
below_tier = [
s for s in scopes if not tier_at_least(tier, SELECTABLE_PAT_SCOPES[s].min_tier)
]
if below_tier:
raise OnyxError(
OnyxErrorCode.FEATURE_NOT_AVAILABLE,
f"Token scope(s) not available on the {tier.value.title()} plan: "
f"{', '.join(s.value for s in below_tier)}",
)
if (
Permission.USE_LLM_GATEWAY in scopes
and not load_settings(raise_on_error=True).llm_gateway_enabled
@@ -55,7 +66,12 @@ def list_selectable_scopes(
_: User = Depends(require_permission(Permission.BASIC_ACCESS)),
) -> list[PatScopeOption]:
"""The scopes a user may assign when minting a token, with display metadata."""
options = list(SELECTABLE_PAT_SCOPES.values())
tier = get_current_tier()
options = [
option
for option in SELECTABLE_PAT_SCOPES.values()
if tier_at_least(tier, option.min_tier)
]
if not load_settings(raise_on_error=True).llm_gateway_enabled:
options = [
option for option in options if option.scope != Permission.USE_LLM_GATEWAY
+1 -2
View File
@@ -20,8 +20,7 @@ class PatScopeOption(BaseModel):
group_label: str
label: str
description: str
# Minimum license tier at which the FE offers this scope. Not enforced at
# minting time; the gated API surface itself rejects lower tiers.
# Minimum license tier at which this scope is listed and can be minted.
min_tier: Tier = Tier.COMMUNITY
@computed_field
+7 -7
View File
@@ -36,6 +36,7 @@ from onyx.oauth_provider.config import OAUTH_PROVIDER_SETTINGS
from onyx.server.features.build.utils import (
is_craft_available_for_deployment,
is_craft_enabled_for_user,
is_craft_locked_by_tier,
)
from onyx.server.features.notifications.models import NotificationResponse
from onyx.server.manage.llm.provider_cache import invalidate_provider_listing_cache
@@ -51,7 +52,7 @@ from onyx.server.settings.store import (
settings_write_lock,
store_settings,
)
from onyx.server.settings.tier_order import tier_at_least
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
from onyx.utils.audit import (
AuditAction,
AuditOutcome,
@@ -80,12 +81,7 @@ def admin_patch_settings(
),
db_session: Session = Depends(get_session),
) -> Settings:
if global_version.is_ee_version():
from ee.onyx.utils.tier import get_tier
current_tier = get_tier()
else:
current_tier = Tier.COMMUNITY
current_tier = get_current_tier()
# Serialize the read-modify-write so two concurrent partial patches cannot
# each merge onto a stale snapshot and drop the other's field.
@@ -229,6 +225,9 @@ def fetch_settings(
if user
else False
)
onyx_craft_requires_upgrade = (
user is not None and not onyx_craft_available and is_craft_locked_by_tier(user)
)
# Dev/debug flag: tail-the-pod-logs button gated by an env var. Same
# check happens on the SSE endpoint so flipping the env var off
@@ -242,6 +241,7 @@ def fetch_settings(
needs_reindexing=needs_reindexing,
onyx_craft_enabled=onyx_craft_enabled_for_user,
onyx_craft_available=onyx_craft_available,
onyx_craft_requires_upgrade=onyx_craft_requires_upgrade,
opencode_debugging_enabled=ENABLE_OPENCODE_DEBUGGING,
vector_db_enabled=not DISABLE_VECTOR_DB,
hide_onyx_branding=HIDE_ONYX_BRANDING
+2
View File
@@ -131,6 +131,8 @@ class UserSettings(Settings):
# Deployment-level Craft availability, ignoring the per-user admin toggle.
# Gates visibility of the admin per-user Craft controls.
onyx_craft_available: bool = False
# Craft is switched on but the tier is too low: show the upgrade prompt.
onyx_craft_requires_upgrade: bool = False
# Dev/debug flag: when true, the FE renders a button that streams the
# user's sandbox pod's opencode-serve logs. Gated by the
# ENABLE_OPENCODE_DEBUGGING env var; never set in prod.
@@ -4,6 +4,7 @@ Lives in CE so any module can compare tiers without depending on EE code.
"""
from onyx.server.settings.models import Tier
from onyx.utils.variable_functionality import global_version
TIER_RANK: dict[Tier, int] = {
Tier.COMMUNITY: 0,
@@ -14,3 +15,12 @@ TIER_RANK: dict[Tier, int] = {
def tier_at_least(current: Tier, required: Tier) -> bool:
return TIER_RANK[current] >= TIER_RANK[required]
def get_current_tier() -> Tier:
"""The deployment's tier; Community when EE code is not loaded."""
if not global_version.is_ee_version():
return Tier.COMMUNITY
from ee.onyx.utils.tier import get_tier
return get_tier()
@@ -15,6 +15,7 @@ from onyx.server.features.build.api import require_onyx_craft_enabled
from onyx.server.features.build.utils import is_craft_enabled_for_user
from onyx.server.manage.models import UserCraftAccessUpdateRequest
from onyx.server.manage.users import set_user_craft_access
from onyx.server.settings.models import Tier
from tests.external_dependency_unit.craft.db_helpers import make_user
@@ -26,6 +27,7 @@ def _enable_craft_deployment(monkeypatch: pytest.MonkeyPatch) -> None:
"get_default_feature_flag_provider",
lambda: NoOpFeatureFlagProvider(),
)
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.BUSINESS)
def test_admin_batch_disable_and_reset_to_default(
@@ -19,7 +19,7 @@ from onyx.db.enums import AccountType
from onyx.feature_flags.interface import FeatureFlagProvider, NoOpFeatureFlagProvider
from onyx.server.features.build import utils as build_utils
from onyx.server.features.build.utils import is_craft_enabled_for_user
from onyx.server.settings.models import Settings
from onyx.server.settings.models import Settings, Tier
class _StubPostHogProvider(FeatureFlagProvider):
@@ -44,6 +44,11 @@ class _StubPostHogProvider(FeatureFlagProvider):
return self._enabled
@pytest.fixture(autouse=True)
def _business_tier(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.BUSINESS)
def _make_user() -> MagicMock:
"""Build a minimal stand-in for `User` - only `.id`, `.email`,
`.account_type`, and `.craft_enabled` are read."""
@@ -225,3 +230,69 @@ def test_posthog_flag_disables_when_false(monkeypatch: pytest.MonkeyPatch) -> No
{"tenant_id": "tenant_other", "email": "user@tenant-dev.example"},
)
]
@pytest.mark.parametrize(
"tier,expected",
[(Tier.COMMUNITY, False), (Tier.BUSINESS, True), (Tier.ENTERPRISE, True)],
)
def test_craft_requires_gateway_tier(
monkeypatch: pytest.MonkeyPatch, tier: Tier, expected: bool
) -> None:
"""Craft turns go through the tier-gated LLM gateway, so an unlicensed
(Community) deployment never gets Craft even with ENABLE_CRAFT=True."""
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", True)
monkeypatch.setattr(
build_utils,
"get_default_feature_flag_provider",
lambda: NoOpFeatureFlagProvider(),
)
monkeypatch.setattr(build_utils, "get_current_tier", lambda: tier)
assert is_craft_enabled_for_user(_make_user()) is expected
def test_tier_not_resolved_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", False)
monkeypatch.setattr(
build_utils,
"get_default_feature_flag_provider",
lambda: NoOpFeatureFlagProvider(),
)
monkeypatch.setattr(
build_utils,
"get_current_tier",
lambda: pytest.fail("tier should not be resolved when Craft is off"),
)
assert is_craft_enabled_for_user(_make_user()) is False
@pytest.mark.parametrize(
"tier,expected",
[(Tier.COMMUNITY, True), (Tier.BUSINESS, False), (Tier.ENTERPRISE, False)],
)
def test_craft_locked_by_tier(
monkeypatch: pytest.MonkeyPatch, tier: Tier, expected: bool
) -> None:
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", True)
monkeypatch.setattr(
build_utils,
"get_default_feature_flag_provider",
lambda: NoOpFeatureFlagProvider(),
)
monkeypatch.setattr(build_utils, "get_current_tier", lambda: tier)
assert build_utils.is_craft_locked_by_tier(_make_user()) is expected
def test_craft_not_locked_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", False)
monkeypatch.setattr(
build_utils,
"get_default_feature_flag_provider",
lambda: NoOpFeatureFlagProvider(),
)
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.COMMUNITY)
assert build_utils.is_craft_locked_by_tier(_make_user()) is False
@@ -1,5 +1,6 @@
"""Unit tests for the PAT scopes listing and scope validation."""
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
import pytest
@@ -8,10 +9,16 @@ from onyx.db.enums import Permission
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.pat import api as pat_api
from onyx.server.settings.models import Settings
from onyx.server.settings.models import Settings, Tier
def _listed_scopes(gateway_enabled: bool) -> list[Permission]:
@pytest.fixture(autouse=True)
def business_tier() -> Iterator[None]:
with patch.object(pat_api, "get_current_tier", return_value=Tier.BUSINESS):
yield
def _listed_scopes(gateway_enabled: bool = True) -> list[Permission]:
with patch.object(
pat_api,
"load_settings",
@@ -49,3 +56,42 @@ def test_validate_scopes_allows_gateway_scope_when_enabled() -> None:
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
):
pat_api._validate_assignable_scopes([Permission.USE_LLM_GATEWAY])
@pytest.mark.parametrize("tier", [Tier.BUSINESS, Tier.ENTERPRISE])
def test_gateway_scope_listed_and_mintable_on_business_plus(tier: Tier) -> None:
with (
patch.object(pat_api, "get_current_tier", return_value=tier),
patch.object(
pat_api,
"load_settings",
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
),
):
assert Permission.USE_LLM_GATEWAY in [
option.scope for option in pat_api.list_selectable_scopes(MagicMock())
]
pat_api._validate_assignable_scopes([Permission.USE_LLM_GATEWAY])
def test_gateway_scope_hidden_and_rejected_on_community() -> None:
with (
patch.object(pat_api, "get_current_tier", return_value=Tier.COMMUNITY),
patch.object(
pat_api,
"load_settings",
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
),
):
scopes = [
option.scope for option in pat_api.list_selectable_scopes(MagicMock())
]
assert Permission.USE_LLM_GATEWAY not in scopes
assert Permission.READ_SEARCH in scopes
with pytest.raises(OnyxError) as exc_info:
pat_api._validate_assignable_scopes(
[Permission.READ_SEARCH, Permission.USE_LLM_GATEWAY]
)
assert exc_info.value.error_code == OnyxErrorCode.FEATURE_NOT_AVAILABLE
pat_api._validate_assignable_scopes([Permission.READ_SEARCH])
pat_api._validate_assignable_scopes(None)
@@ -0,0 +1,17 @@
import pytest
from onyx.server.settings import tier_order
from onyx.server.settings.models import Tier
def test_current_tier_is_community_without_ee(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(tier_order.global_version, "is_ee_version", lambda: False)
assert tier_order.get_current_tier() is Tier.COMMUNITY
def test_current_tier_uses_ee_tier(monkeypatch: pytest.MonkeyPatch) -> None:
from ee.onyx.utils import tier as ee_tier
monkeypatch.setattr(tier_order.global_version, "is_ee_version", lambda: True)
monkeypatch.setattr(ee_tier, "get_tier", lambda: Tier.ENTERPRISE)
assert tier_order.get_current_tier() is Tier.ENTERPRISE
@@ -21,6 +21,8 @@ IMAGE_TAG=latest
# CODE_INTERPRETER_IMAGE_TAG=
## Onyx Craft Configuration (off by default).
## Craft requires a Business or Enterprise license. The Community Edition shows an
## upgrade page instead, until Craft merges into the main chat.
## When enabling Craft through the installer's --include-craft or by manually adding
## docker-compose.craft.yml, the sandbox image follows IMAGE_TAG above.
# ENABLE_CRAFT=true
+2
View File
@@ -21,6 +21,8 @@ IMAGE_TAG=latest
# CODE_INTERPRETER_IMAGE_TAG=
## Onyx Craft Configuration (off by default).
## Craft requires a Business or Enterprise license. The Community Edition shows an
## upgrade page instead, until Craft merges into the main chat.
## When enabling Craft through the installer's --include-craft or by manually adding
## docker-compose.craft.yml, the sandbox image follows IMAGE_TAG above.
# ENABLE_CRAFT=true
@@ -0,0 +1,30 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import SvgNoAccess from "@opal/illustrations/no-access";
import { useUser } from "@/providers/UserProvider";
export default function CraftUpgradePage() {
const t = useTranslations("craft.upgrade");
const { isAdmin } = useUser();
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 p-8">
<IllustrationContent
illustration={SvgNoAccess}
title={t("title")}
description={isAdmin ? t("adminDescription") : t("userDescription")}
/>
<div className="flex flex-row gap-2">
<Button prominence="secondary" href="/app">
{t("backToChat.label")}
</Button>
{isAdmin && (
<Button href="/admin/billing">{t("viewPlans.label")}</Button>
)}
</div>
</div>
);
}
+6 -1
View File
@@ -3,6 +3,7 @@ import type { Route } from "next";
import { unstable_noStore as noStore } from "next/cache";
import { requireAuth } from "@/lib/auth/svcSS";
import { fetchSettingsSS } from "@/lib/settings/svcSS";
import CraftUpgradePage from "@/app/craft/components/CraftUpgradePage";
export interface LayoutProps {
children: React.ReactNode;
@@ -12,7 +13,8 @@ export interface LayoutProps {
* Build Layout - Minimal wrapper that handles authentication and feature flag check
*
* Child routes (/craft and /craft/v1) handle their own UI structure.
* Redirects to /app if Onyx Craft is disabled via feature flag.
* Shows the upgrade page when Craft is on but the tier is too low, and
* redirects to /app if Onyx Craft is otherwise disabled.
*/
export default async function Layout({ children }: LayoutProps) {
noStore();
@@ -28,6 +30,9 @@ export default async function Layout({ children }: LayoutProps) {
// Only explicit true enables the feature; false or undefined = disabled
const settings = await fetchSettingsSS();
if (settings?.settings?.onyx_craft_enabled !== true) {
if (settings?.settings?.onyx_craft_requires_upgrade === true) {
return <CraftUpgradePage />;
}
redirect("/app");
}
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "تطبيق",
"unavailable": "لم يعد متاحًا",
"appTitle": "معاينة تطبيقك"
},
"upgrade": {
"title": "يتطلب Craft خطة الأعمال",
"adminDescription": "يتوفر Craft في خطتي الأعمال والمؤسسات. لا يتم دعمه في إصدار المجتمع حتى يتم دمج Craft في الدردشة الرئيسية.",
"userDescription": "يتوفر Craft في خطتي الأعمال والمؤسسات. اطلب من المسؤول الترقية لاستخدام Craft.",
"backToChat": {
"label": "العودة إلى الدردشة"
},
"viewPlans": {
"label": "عرض الخطط"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "الوكلاء"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "الأعمال"
},
"exploreAgents": {
"label": "استكشاف الوكلاء"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "App",
"unavailable": "Nicht mehr verfügbar",
"appTitle": "Vorschau deiner App"
},
"upgrade": {
"title": "Craft erfordert einen Business-Plan",
"adminDescription": "Craft ist in den Business- und Enterprise-Plänen verfügbar. In der Community Edition wird Craft erst unterstützt, wenn es in den Hauptchat integriert ist.",
"userDescription": "Craft ist in den Business- und Enterprise-Plänen verfügbar. Bitten Sie Ihren Administrator um ein Upgrade, um Craft zu nutzen.",
"backToChat": {
"label": "Zurück zum Chat"
},
"viewPlans": {
"label": "Pläne anzeigen"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "Agenten"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "Agenten erkunden"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "App",
"unavailable": "No longer available",
"appTitle": "Preview your app"
},
"upgrade": {
"title": "Craft requires a Business plan",
"adminDescription": "Craft is available on the Business and Enterprise plans. It is not supported on the Community Edition until Craft merges into the main chat.",
"userDescription": "Craft is available on the Business and Enterprise plans. Ask your admin to upgrade to use Craft.",
"backToChat": {
"label": "Back to chat"
},
"viewPlans": {
"label": "View plans"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "Agents"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "Explore Agents"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "Aplicación",
"unavailable": "Ya no está disponible",
"appTitle": "Vista previa de tu aplicación"
},
"upgrade": {
"title": "Craft requiere un plan Business",
"adminDescription": "Craft está disponible en los planes Business y Enterprise. No es compatible con Community Edition hasta que Craft se integre en el chat principal.",
"userDescription": "Craft está disponible en los planes Business y Enterprise. Pide a tu administrador que actualice el plan para usar Craft.",
"backToChat": {
"label": "Volver al chat"
},
"viewPlans": {
"label": "Ver planes"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "Agentes"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "Explorar agentes"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "Application",
"unavailable": "N’est plus disponible",
"appTitle": "Aperçu de votre application"
},
"upgrade": {
"title": "Craft nécessite un forfait Business",
"adminDescription": "Craft est disponible avec les forfaits Business et Enterprise. Il n’est pas pris en charge dans la Community Edition tant que Craft n’est pas intégré au chat principal.",
"userDescription": "Craft est disponible avec les forfaits Business et Enterprise. Demandez à votre administrateur de passer à un forfait supérieur pour utiliser Craft.",
"backToChat": {
"label": "Retour au chat"
},
"viewPlans": {
"label": "Voir les forfaits"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "Agents"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "Explorer les agents"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "アプリ",
"unavailable": "利用できなくなりました",
"appTitle": "アプリをプレビュー"
},
"upgrade": {
"title": "Craft には Business プランが必要です",
"adminDescription": "Craft は Business プランと Enterprise プランで利用できます。Craft がメインチャットに統合されるまで、Community Edition ではサポートされません。",
"userDescription": "Craft は Business プランと Enterprise プランで利用できます。Craft を使用するには、管理者にアップグレードを依頼してください。",
"backToChat": {
"label": "チャットに戻る"
},
"viewPlans": {
"label": "プランを表示"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "エージェント"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "エージェントを探す"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "앱",
"unavailable": "더 이상 사용할 수 없음",
"appTitle": "앱 미리보기"
},
"upgrade": {
"title": "Craft를 사용하려면 Business 플랜이 필요합니다",
"adminDescription": "Craft는 Business 및 Enterprise 플랜에서 사용할 수 있습니다. Craft가 메인 채팅에 통합될 때까지 Community Edition에서는 지원되지 않습니다.",
"userDescription": "Craft는 Business 및 Enterprise 플랜에서 사용할 수 있습니다. Craft를 사용하려면 관리자에게 업그레이드를 요청하세요.",
"backToChat": {
"label": "채팅으로 돌아가기"
},
"viewPlans": {
"label": "플랜 보기"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "에이전트"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "에이전트 둘러보기"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "Aplicativo",
"unavailable": "Não está mais disponível",
"appTitle": "Prévia do seu aplicativo"
},
"upgrade": {
"title": "O Craft requer um plano Business",
"adminDescription": "O Craft está disponível nos planos Business e Enterprise. Ele não é compatível com a Community Edition até que o Craft seja integrado ao chat principal.",
"userDescription": "O Craft está disponível nos planos Business e Enterprise. Peça ao seu administrador para fazer o upgrade e usar o Craft.",
"backToChat": {
"label": "Voltar ao chat"
},
"viewPlans": {
"label": "Ver planos"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "Agentes"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "Business"
},
"exploreAgents": {
"label": "Explorar agentes"
+13 -1
View File
@@ -13800,6 +13800,17 @@
"app": "应用",
"unavailable": "已不可用",
"appTitle": "预览你的应用"
},
"upgrade": {
"title": "Craft 需要商业版套餐",
"adminDescription": "Craft 适用于商业版和企业版套餐。在 Craft 并入主聊天之前,社区版不支持 Craft。",
"userDescription": "Craft 适用于商业版和企业版套餐。请联系管理员升级以使用 Craft。",
"backToChat": {
"label": "返回聊天"
},
"viewPlans": {
"label": "查看套餐"
}
}
},
"knowledge": {
@@ -14841,7 +14852,8 @@
"title": "智能体"
},
"craft": {
"label": "Craft"
"label": "Craft",
"businessTag": "商业版"
},
"exploreAgents": {
"label": "探索智能体"
+3
View File
@@ -67,6 +67,9 @@ export interface Settings {
// Gates visibility of the admin Craft-access controls.
onyx_craft_available?: boolean;
// Craft is switched on but the tier is too low: show the upgrade prompt.
onyx_craft_requires_upgrade?: boolean;
// Workspace default for Craft access; per-user overrides win.
craft_default_enabled?: boolean;
+13 -2
View File
@@ -61,7 +61,7 @@ import {
showErrorNotification,
} from "@/lib/sidebar/utils";
import { handleMoveOperation } from "@/lib/sidebar/svc";
import { SidebarTab } from "@opal/components";
import { SidebarTab, Tag } from "@opal/components";
import { ChatSession } from "@/app/app/interfaces";
import { useUser } from "@/providers/UserProvider";
import { getFirstPermittedAdminRoute } from "@/lib/permissions/utils";
@@ -265,6 +265,9 @@ export default function AppSidebar() {
// Check if Onyx Craft is enabled via settings (backed by PostHog feature flag)
// Only explicit true enables the feature; false or undefined = disabled
const isOnyxCraftEnabled = combinedSettingsData?.onyx_craft_enabled === true;
const isOnyxCraftUpsell =
!isOnyxCraftEnabled &&
combinedSettingsData?.onyx_craft_requires_upgrade === true;
// Fetch notifications for build mode intro
const { notifications, refresh: mutateNotifications } = useNotifications({
@@ -625,12 +628,20 @@ export default function AppSidebar() {
</SidebarTab>
)}
/>
{isOnyxCraftEnabled && (
{(isOnyxCraftEnabled || isOnyxCraftUpsell) && (
<div data-testid="AppSidebar/build">
<SidebarTab
icon={SvgDevKit}
href={CRAFT_PATH}
onClick={() => track(AnalyticsEvent.CLICKED_CRAFT_IN_SIDEBAR)}
rightChildren={
isOnyxCraftUpsell ? (
<Tag
title={t("appSidebar.craft.businessTag")}
color="blue"
/>
) : undefined
}
>
{t("appSidebar.craft.label")}
</SidebarTab>