NNO Docs
Concepts

NNO Shell Feature Config

Documentation for NNO Shell Feature Config

Date: 2026-03-30 Status: Detailed Design Parent: System Architecture Related files:

  • apps/console/src/config/features.config.ts (feature activation config — also covers resolver logic)
  • apps/console/src/config/features.overrides.ts (shell-level overrides)
  • packages/sdk/src/vite/feature-discovery.ts (canonical Vite plugin implementation)
  • apps/console/vite-plugins/feature-discovery.ts (one-line re-export shim — re-exports from @neutrino-io/sdk/vite)
  • packages/sdk/src/types/feature-config.ts (existing types)

Overview

features.config.ts is the single source of truth for which feature packages are active in a platform's console shell. It is:

  • Committed to the platform repo as a regular TypeScript file
  • Read at shell boot by the FeatureRegistry to initialise routes, sidebar, and permission gates
  • The mechanism by which feature activation/deactivation in the NNO Portal is reflected in the running shell

Phase 1 vs Phase 1.5 vs Phase 2 — Authoring model:

  • Phase 1 (original): features.config.ts was manually authored. Developers edited it directly to enable or disable features and had to keep the static import registry in features.config.ts in sync by hand.
  • Phase 1.5 (current): Feature packages are auto-discovered at build time by the Vite neutrino-feature-discovery plugin. Installing an @neutrino-io/feature-* package that declares "neutrino": \{"type": "feature"\} in its package.json is enough — no manual registration required. features.config.ts now merges auto-discovered manifests, console-local features, and shell overrides from features.overrides.ts.
  • Phase 2 (planned): NNO CLI Service will generate and commit features.config.ts automatically when features are activated or deactivated through the NNO Portal or CLI. The file header will include ⚠️ GENERATED FILE — DO NOT EDIT MANUALLY. Auto-discovery from Phase 1.5 remains active; generated config takes precedence.

The schema described below is valid for all phases. The NNO-generated format (§2) shows what Phase 2 will produce.

The existing apps/console/src/config/features.config.ts is the reference implementation — this spec formalises its schema and describes how NNO manages it in a production platform deployment.


Auto-Discovery (Phase 1.5)

As of 2026-02-26, feature packages are auto-discovered at build time by a Vite plugin. This eliminates the need to manually register feature packages in features.config.ts.

How It Works

1. Developer installs:     pnpm add @neutrino-io/feature-analytics
2. Vite plugin scans:      package.json → finds neutrino.type === "feature"
3. Virtual module generated: import * as _f0 from '@neutrino-io/feature-analytics'
4. features.config.ts:      merges manifest + local features + overrides
5. Shell boots normally:    FeatureRegistry loads from virtual module

Three Config Sources

SourceFilePurpose
Auto-discovered manifestsvirtual:feature-registry (generated)Feature self-description from installed packages
Console-local featuresfeatures.config.ts (inline localFeatures)Shell-owned features (home, help, errors)
Shell overridesfeatures.overrides.tsEnable/disable, auth integration, priority tweaks

Feature Package Convention

Feature packages opt in to auto-discovery by:

  1. Exporting featureManifest from their barrel (src/index.ts)
  2. Adding "neutrino": \{"type": "feature"\} to their package.json
  3. Following the @neutrino-io/feature-* naming convention

Vite Plugin

The neutrino-feature-discovery plugin (canonical implementation: packages/sdk/src/vite/feature-discovery.ts, re-exported by apps/console/vite-plugins/feature-discovery.ts):

  • Runs at configResolved (build time)
  • Scans apps/console/package.json dependencies for @neutrino-io/feature-*
  • Checks each dependency's package.json for neutrino.type === "feature"
  • Generates a virtual module with static imports and manifest array
  • Supports HMR: re-discovers when any package.json changes

Route File Generation

In addition to the virtual feature module, the plugin also generates TanStack Router route files at configResolved via writeRouteFiles(). It resolves route manifests from the installed @neutrino-io/ui-auth and @neutrino-io/ui-core packages (falling back to built-in manifests if those packages are not resolvable) and writes .tsx route files into apps/console/src/routes/:

  • src/routes/(auth)/ — auth pages (sign-in, sign-up, forgot-password, otp, SSO callbacks) using components from @neutrino-io/ui-auth
  • src/routes/(errors)/ — error pages (401, 403, 404, 500, 503) using components from @neutrino-io/ui-core

Each file is stamped with // Auto-generated by neutrino-feature-discovery - do not edit and contains a typed createFileRoute() export. These files are generated on every build; manual edits will be overwritten.

Generated Virtual Module

The plugin generates virtual:feature-registry with this structure:

// virtual:feature-registry (auto-generated, not a real file)
import * as _f0 from "@neutrino-io/feature-billing";
import * as _f1 from "@neutrino-io/feature-settings";

export const featureImports = {
  "@neutrino-io/feature-billing": _f0,
  "@neutrino-io/feature-settings": _f1,
};

export const featureManifests = [
  _f0.featureManifest,
  _f1.featureManifest,
].filter(Boolean);

The featureImports map is consumed by FeatureRegistry.preloadPackages() to register all auto-discovered packages without dynamic import(). The featureManifests array is consumed by features.config.ts to merge auto-discovered manifests with local features and overrides via buildFeaturesConfig().

FeatureManifest Type

interface FeatureManifest {
  id: string; // Feature identifier (kebab-case)
  name: string; // Human-readable name
  package: string; // npm package name
  module?: string; // Module path within package
  enabledByDefault: boolean; // Default enabled state
  loadPriority?: number; // Load order (lower = earlier)
  lazyLoad?: boolean; // Lazy-load flag
  environment?: string; // Environment restriction
  domain?: string; // Business domain grouping
  type?: "system" | "business"; // Feature type
  group?: string; // Sidebar group heading
  defaultComponent?: string; // Default component name
  defaultRedirect?: string; // Default redirect path
  subRoutes?: Record<string, SubRouteConfig>;
  settingsConfig?: Record<string, unknown>;
  navigation?: FeatureNavigationConfig;
}

Note: The shell retains full override authority. A featureManifest.enabledByDefault: true can be overridden to enabled: false via features.overrides.ts.


1. Schema

FeaturesConfig (top-level type)

// packages/sdk/src/types/feature-config.ts

export type FeaturesConfig = Record<string, FeatureConfig>;

A flat map of feature key (camelCase) → FeatureConfig. The key must be the camelCase version of the feature's id slug.


FeatureConfig

export interface FeatureConfig {
  // ── Identity ──────────────────────────────────────────────────
  /** Unique feature slug. Kebab-case. Matches FeatureDefinition.id */
  id: string;

  /**
   * Human-readable display name shown in shell chrome.
   * Legacy optional alias for FeatureDefinition.displayName — omit in new entries.
   */
  name?: string;

  /**
   * Semver of the installed package (from FeatureDefinition.version).
   * Optional — can be inferred from the package at load time.
   */
  version?: string;

  // ── Load Control ─────────────────────────────────────────────
  /**
   * Whether this feature is active. NNO sets false on deactivation.
   * Shell skips loading disabled features.
   */
  enabled: boolean;

  /**
   * Load order priority. Lower = earlier.
   * Core features: 1–9. Domain features: 10–89. Client features: 90–98.
   * System features (404, 500): 0.
   */
  loadPriority?: number;

  /**
   * Whether to defer loading until first navigation to the feature.
   * false = eager (bundled in main chunk). true = lazy (code-split).
   * Core features are eager; domain and client features are lazy.
   */
  lazyLoad?: boolean;

  // ── Package Binding ──────────────────────────────────────────
  /** npm package name, e.g. '@neutrino-io/feature-billing' */
  package: string;

  /** Module path within the package, e.g. 'features/billing' */
  module: string;

  /** Default exported component name to mount at the feature root */
  defaultComponent?: string;

  /** Default redirect when landing on the feature root */
  defaultRedirect?: string;

  // ── Environment ──────────────────────────────────────────────
  /** Which environments to enable this feature in */
  environment?: "development" | "staging" | "production" | "all";

  // ── Domain / Type Classification ────────────────────────────
  /**
   * Feature domain classification.
   * 'core' = always present (auth, settings)
   * Any other string = client/domain-specific feature domain
   */
  domain: string;

  /**
   * 'business' = appears in sidebar and routing.
   * 'system'   = error pages, auth flows — hidden from sidebar.
   */
  type: "business" | "system";

  // ── Sidebar ─────────────────────────────────────────────────
  /** Optional sidebar group label for this feature's nav entry */
  group?: string;

  // ── Routing ─────────────────────────────────────────────────
  /** Sub-routes within this feature, keyed by path segment */
  subRoutes?: Record<string, SubRouteConfig>;

  /** Whether this feature's route is the app's default landing page */
  isDefault?: boolean;

  // ── Navigation ──────────────────────────────────────────────
  navigation?: FeatureNavigationConfig;

  // ── Permissions ─────────────────────────────────────────────
  /**
   * Permission keys required to access this feature.
   * Matches FeatureDefinition.permissions[].key where required: true.
   * If the current user lacks ALL required permissions, the feature
   * route renders a <Forbidden> page and sidebar entry is hidden.
   */
  permissions?: string[];

  // ── Feature-Specific Config ──────────────────────────────────
  /** Arbitrary config bag passed to the feature's onActivate hook */
  featureConfig?: Record<string, unknown>;

  /** Settings-specific metadata passed to the settings feature */
  settingsConfig?: Record<string, unknown>;
}

SubRouteConfig

export interface SubRouteConfig {
  /** Component name exported by the feature package */
  component: string;

  /** Additional permission keys required for this specific sub-route */
  permissions?: string[];
}

FeatureNavigationConfig

export interface FeatureNavigationConfig {
  mode: "default" | "custom" | "hybrid";
  sidebar: {
    show: boolean;
    /** If true, this feature replaces the default sidebar entirely */
    replace?: boolean;
  };
  header: {
    show: boolean;
    title?: string;
    replace?: boolean;
  };
  layout: {
    fullscreen?: boolean;
    containerClass?: string;
    contentClass?: string;
  };
}

2. NNO-Generated Config Format

The NNO CLI Service generates features.config.ts as a TypeScript file — not JSON — because the shell imports it at build time with full type checking.

Example: Platform with Core + Actual Feature Packages Active

// src/config/features.config.ts
// ⚠️  GENERATED FILE — DO NOT EDIT MANUALLY
// Managed by NNO CLI Service. Changes will be overwritten on next feature activation.
// Last generated: 2026-02-22T10:34:00Z by NNO CLI Service v1.0.0

import type { FeaturesConfig } from "@neutrino-io/sdk";

export const featuresConfig: FeaturesConfig = {
  // ── Core Features (always present) ──────────────────────────────────────────

  auth: {
    id: "auth",
    name: "Authentication",
    version: "^2.1.0", // version is optional; can be inferred from package at load time
    enabled: true,
    loadPriority: 1,
    lazyLoad: false,
    package: "@neutrino-io/ui-auth",
    module: "features/auth",
    environment: "all",
    domain: "core",
    type: "system",
    navigation: {
      mode: "custom",
      sidebar: { show: false },
      header: { show: false },
      layout: { fullscreen: true },
    },
  },

  settings: {
    id: "settings",
    name: "Settings",
    // version is optional — omitted here; inferred from package at load time
    enabled: true,
    loadPriority: 2,
    lazyLoad: true,
    package: "@neutrino-io/feature-settings",
    module: "features/settings",
    defaultComponent: "ProfileSettings",
    defaultRedirect: "/settings/profile",
    environment: "all",
    domain: "core",
    type: "business",
    group: "Account",
    subRoutes: {
      profile: { component: "ProfileSettings" },
      account: { component: "AccountSettings" },
      organization: { component: "OrganizationSettings" },
      appearance: { component: "AppearanceSettings" },
      notifications: { component: "NotificationSettings" },
      display: { component: "DisplaySettings" },
    },
    navigation: {
      mode: "default",
      sidebar: { show: true },
      header: { show: true, title: "Settings" },
      layout: { fullscreen: false },
    },
  },

  // ── Domain Features (activated per tenant) ──────────────────────────────────

  billing: {
    id: "billing",
    name: "Billing",
    // version is optional — omitted here; inferred from package at load time
    enabled: true, // ← set to false on deactivation
    // Note: starter template defaults to enabled: false (billing not yet wired).
    // This example shows the config after billing is activated on a live platform.
    loadPriority: 80,
    lazyLoad: true,
    package: "@neutrino-io/feature-billing",
    module: "features/billing",
    defaultComponent: "BillingDashboard",
    environment: "all",
    domain: "billing", // billing has its own domain (not 'core')
    type: "business",
    group: "Account",
    permissions: ["billing:read"],
    navigation: {
      mode: "default",
      sidebar: { show: true },
      header: { show: true, title: "Billing" },
      layout: { fullscreen: false },
    },
  },

  // ── Console-local Features (from @neutrino-io/ui-core, platform template) ────────

  helpCenter: {
    id: "help-center",
    name: "Help Center",
    enabled: true,
    loadPriority: 99,
    lazyLoad: true,
    package: "@neutrino-io/ui-core",
    module: "features/help",
    defaultComponent: "Help",
    environment: "all",
    domain: "core",
    type: "business",
    group: "Overview",
    navigation: {
      mode: "default",
      sidebar: { show: true },
      header: { show: true, title: "Help Center" },
      layout: { fullscreen: false },
    },
  },

  // ── System Features ──────────────────────────────────────────────────────────

  notFound: {
    id: "404",
    name: "Not Found",
    enabled: true,
    loadPriority: 0,
    lazyLoad: false,
    package: "@neutrino-io/ui-core",
    module: "features/errors/not-found",
    environment: "all",
    domain: "core",
    type: "system",
  },

  serverError: {
    id: "500",
    name: "Server Error",
    enabled: true,
    loadPriority: 0,
    lazyLoad: false,
    package: "@neutrino-io/ui-core",
    module: "features/errors/server-error",
    environment: "all",
    domain: "core",
    type: "system",
  },

  forbidden: {
    id: "403",
    name: "Forbidden",
    enabled: true,
    loadPriority: 0,
    lazyLoad: false,
    package: "@neutrino-io/ui-core",
    module: "features/errors/forbidden",
    environment: "all",
    domain: "core",
    type: "system",
  },
};

export default featuresConfig;

Generation Rules

RuleDetail
Key formatcamelCase version of id slug. help-centerhelpCenter
OrderingCore features first (by loadPriority), then domain, then client, then system
System featuresAlways included, hardcoded from the template, never removed
Package versionTaken from Marketplace record at activation time, pinned as semver range (^major.minor.0)
Deactivated featuresSet enabled: false, preserved in file. Hard-removed only on platform deprovision
Header commentAlways includes generation timestamp and service version

3. Shell Boot Sequence

At shell startup, the following sequence runs synchronously before the app renders:

Browser loads shell (CF Pages → CDN → static bundle)


index.html executes
  → React application initialises
  → TanStack Router creates router instance


AppProviders mounts
  → AuthProvider: initialises Better Auth session check
  → FeatureRegistryProvider: reads featuresConfig (static import, zero network)


FeatureRegistry.initialize()
  1. getEnabledFeatures()
       → filters featuresConfig by enabled: true
       → filters by environment match (MODE env var)
       → sorts by loadPriority
  2. preloadPackages(enabledFeatures)
       → resolves known packages via static import registry
         (no dynamic fetch — all bundled at build time)
  3. loadFeatures(enabledFeatures)
       → for each enabled feature:
           reads FeatureDefinition from package (prefers {id}FeatureDefinition
             over legacy {id}FeatureConfig for full navigation hierarchy)
           stores component, routes, sidebarItems, navigation, permissions,
             providers in FeatureModule registry


RouteGenerator.build(registry)
  → creates TanStack Router route tree from all registered feature routes
  → features/ workspace packages (billing, settings) define their own routes
    via FeatureDefinition.routes[] — registered as-is by generateDefinitionRoutes()
  → wraps protected routes in permission checks

  > **Note**: The old "prefix each route with /{featureId}/" pattern applied only to
  > legacy single-component feature entries. All current feature packages in the
  > `features/` workspace use `FeatureDefinition.routes[]` and `generateDefinitionRoutes()`
  > (called inside `getCoreFeatureRoutes()` in `features.config.ts`), which registers
  > routes exactly as defined in each FeatureDefinition — no shell-level prefix is added.


SidebarGenerator.build(registry, userPermissions)
  → for each enabled business feature with sidebar visibility:
      1. Check feature module for FeatureDefinition.navigation[]
         → if navigation[0].children exists → collapsible sub-menu
         → otherwise → flat link
      2. Fall back to legacy settingsConfig or flat link
  → groups items by feature.group into NavGroup[]
  → merges into static navigation groups from navigation.config.ts


App renders with complete route tree and sidebar

The entire FeatureRegistry.initialize() runs in under 50ms for a typical platform (3–10 features) because all packages are static imports bundled at build time — no network round-trips.


4. FeatureRegistry Initialization Detail

class FeatureRegistry {
  async initialize(): Promise<void> {
    const enabledFeatures = this.getEnabledFeatures();
    // Phase 1: load all package modules (static bundles)
    await this.preloadPackages(enabledFeatures);
    // Phase 2: extract FeatureDefinition from each package
    await this.loadFeatures(enabledFeatures);
  }

  getEnabledFeatures(): FeatureConfig[] {
    return Object.values(this.config)
      .filter((f): f is FeatureConfig => f.enabled)
      .filter((f) => this.isEnvironmentMatch(f))
      .sort((a, b) => (a.loadPriority ?? 999) - (b.loadPriority ?? 999));
  }

  private isEnvironmentMatch(feature: FeatureConfig): boolean {
    const currentEnv = import.meta.env.MODE; // 'development' | 'staging' | 'production'
    return feature.environment === "all" || feature.environment === currentEnv;
  }
}

Package Loading Strategy

The registry uses three sources (in priority order) to resolve feature packages:

  1. Console-local features@console/features is imported statically (always available)
  2. Auto-discovered packagesvirtual:feature-registry provides static imports for all @neutrino-io/feature-* packages
  3. Dynamic fallbackimport() for any remaining unresolved packages
// 1. Console-local features (static import, always available)
this.packageRegistry.set("@console/features", consoleFeatures);

// 2. Auto-discovered feature packages (from Vite plugin)
for (const [packageName, packageModule] of Object.entries(featureImports)) {
  this.packageRegistry.set(packageName, packageModule);
}

// 3. Fallback: dynamic import for packages not covered above
const packageModule = await import(/* @vite-ignore */ packageName);

The virtual:feature-registry module (generated by the Vite plugin) replaces the need for a manual KNOWN_PACKAGES map. The Vite plugin generates static imports for all discovered packages, so Vite can code-split them deterministically. Dynamic import() with unknown strings defeats tree-shaking — the generated virtual module ensures all packages are code-split deterministically without manual registry updates.

FeatureDefinition Resolution

When loading a feature, the registry resolves the FeatureDefinition from the package module. It tries two naming conventions and prefers the canonical FeatureDefinition over the legacy FeatureConfig:

// Canonical: {id}FeatureDefinition (e.g. settingsFeatureDefinition)
const definitionName = `${camelCaseId}FeatureDefinition`;
// Legacy:   {id}FeatureConfig     (e.g. settingsFeatureConfig)
const configName = `${camelCaseId}FeatureConfig`;

// Prefer definition — it has the full navigation hierarchy with children
const featureConfig =
  packageModule[definitionName] ?? packageModule[configName];

Why definition-first? Legacy FeatureConfig exports (e.g. settingsFeatureConfig) may have incomplete navigation (flat top-level items without children). The canonical FeatureDefinition (e.g. settingsFeatureDefinition) is the authoritative source with the full navigation hierarchy including sub-menu children.

The resolved definition's routes, navigation, sidebarItems, permissions, and providers are extracted into the FeatureModule stored in the registry.


5. Permission Evaluation

5.1 Feature-Level Permission Gate

Features with permissions declared in their FeatureConfig are wrapped in a <PermissionGate> component at the route level:

User navigates to /billing


TanStack Router resolves route


<PermissionGate permissions={['billing:read']}>

  ├─ User has 'billing:read'? → Render <BillingDashboard />
  └─ User lacks permission?   → Render <Forbidden />
                                 (sidebar entry is also hidden)

The permission gate reads user permissions from useNnoSession():

function PermissionGate({
  permissions,
  children,
}: {
  permissions: string[];
  children: React.ReactNode;
}) {
  const { user } = useNnoSession();

  // All listed permissions must be present (AND logic)
  const hasAccess = permissions.every((p) =>
    user.permissions.includes(p) || user.permissions.includes('*')
  );

  if (!hasAccess) return <Forbidden />;
  return <>{children}</>;
}

Permissions are not evaluated at registry initialization. They are checked at route-render time via the permission gate, which reads the current session's permissions from useNnoSession().

5.2 Permission Source

User permissions come from the Better Auth JWT session, which encodes the user's permission set as a JSON array. The auth Worker returns permissions in the session payload:

{
  "userId": "usr_abc123",
  "tenantId": "r8n4t6y1z5",
  "platformId": "k3m9p2xw7q",
  "permissions": ["billing:read", "settings:write", "zero:access"],
  "exp": 1740230400
}

The shell reads permissions from the session on each route render via useNnoSession(). There is no re-check on every route navigation — permissions are trusted for the duration of the session.

5.3 Permission Format

Permission keys follow the \{featureId\}:\{action\} convention defined in the Feature Package SDK:

Permission KeyMeaning
billing:readView billing information
billing:manageModify billing settings
settings:writeChange settings
zero:accessAccess operator backoffice
*Super-admin: all permissions

5.4 Sidebar Visibility vs. Route Access

These are evaluated independently:

User stateSidebar entryRoute access
Has required permissionShownAllowed
Missing permissionHiddenForbidden (redirects to /403)
Feature disabled (enabled: false)Hidden404
Feature not bundled (wrong environment)Hidden404

Hiding the sidebar entry is cosmetic. Route protection is the authoritative gate — it prevents direct URL access regardless of sidebar visibility.

5.5 Sub-Route Permissions

Sub-routes can have additional permission requirements on top of the parent feature's permissions:

subRoutes: {
  export: {
    component: 'BillingExport',
    permissions: ['billing:manage'],  // ← additional requirement
  },
}

Both the parent feature permission (billing:read) AND the sub-route permission (billing:manage) must be present to access /billing/export.


5a. Sidebar Generation Algorithm

The sidebar generator (apps/console/src/config/navigation.config.ts) converts the feature registry into the SidebarData structure consumed by the AppSidebar component. The algorithm has four strategies for building each feature's nav item, tried in priority order:

For each enabled business feature with isSidebarVisible(config) === true:

Strategy 1: Feature sidebarItems (legacy)
  → If the FeatureModule has sidebarItems[], use the first item directly
  → Supports both flat links and collapsible sub-menus

Strategy 2: FeatureDefinition.navigation[] (canonical)
  → If the FeatureModule has navigation[] from its FeatureDefinition:
    → Read navigation[0] as the top-level nav item
    → If navigation[0].children[] exists:
      → Sort children by order (ascending, default 99)
      → Build a collapsible sub-menu with each child as a link
      → Resolve icon strings (e.g. 'User', 'Settings') to lucide components
    → Otherwise: build a flat link

Strategy 3: Settings legacy (settingsConfig)
  → Only for feature id === 'settings' with a settingsConfig object
  → Generates sub-items from enabled sections in settingsConfig

Strategy 4: Flat link fallback
  → title = config.name, url = /{config.id}, icon from getFeatureIcon()

Icon Resolution

Navigation items can specify icons as either:

  • String names (e.g. 'User', 'Settings', 'Bell') — resolved to lucide-react components via the iconByName map
  • Component references — used directly

This allows feature packages to declare icons by name in their FeatureDefinition without importing lucide-react.

Grouping

Features are grouped into NavGroup[] based on their config.group field:

  1. Static groups from navigation.config.ts (e.g. "Overview", "Academic", "Configuration") form the base structure
  2. Each feature's nav item is placed into its matching group (by group field), or into the first group as fallback
  3. If a feature's nav item matches an existing static item (by URL or title), it replaces that item — this allows feature packages to enhance static placeholders

Example: Settings with Sub-Menu

// FeatureDefinition.navigation (from features/settings/src/feature.ts)
navigation: [
  {
    label: "Settings",
    path: "/settings",
    icon: "Settings",
    order: 90,
    children: [
      { label: "Profile", path: "/settings/profile", icon: "User", order: 1 },
      {
        label: "Account",
        path: "/settings/account",
        icon: "UserCog",
        order: 2,
      },
      {
        label: "Appearance",
        path: "/settings/appearance",
        icon: "Palette",
        order: 3,
      },
      {
        label: "Notifications",
        path: "/settings/notifications",
        icon: "Bell",
        order: 4,
      },
      { label: "Display", path: "/settings/display", icon: "Eye", order: 5 },
      {
        label: "Organization",
        path: "/settings/organization",
        icon: "Users",
        order: 6,
      },
    ],
  },
];

// Produces a collapsible sidebar item:
//   Settings ▾
//     Profile
//     Account
//     Appearance
//     Notifications
//     Display
//     Organization

6. Environment Filtering

Features can be scoped to specific deployment environments:

environment valueActive in
'all'All environments (default)
'development'Local dev only (import.meta.env.MODE === 'development')
'staging'Staging deployments only
'production'Production only

The NNO CLI Service sets environment: 'all' for all production-activated features. The development-scoped value is used only for debug features that NNO injects into dev platform builds:

debugPanel: {
  id: 'debug-panel',
  name: 'Debug Panel',
  enabled: true,
  environment: 'development',   // ← stripped out at staging/prod
  ...
}

7. Mapping from FeatureDefinition to FeatureConfig

When a feature package is activated, the NNO CLI Service reads the package's FeatureDefinition (from the feature.ts export) and maps it to a FeatureConfig entry:

function featureDefinitionToConfig(
  def: FeatureDefinition,
  packageName: string,
  packageVersion: string,
  loadPriority: number,
): FeatureConfig {
  return {
    // Identity
    id: def.id,
    name: def.displayName,
    version: packageVersion,

    // Load control
    enabled: true,
    loadPriority,
    lazyLoad: loadPriority > 9, // default: domain + client features are lazy; individual entries can override

    // Package
    package: packageName,
    module: `features/${def.id}`,
    defaultComponent: toPascalCase(def.id),

    // Classification
    environment: "all",
    domain: def.id === "auth" || def.id === "settings" ? "core" : def.id,
    type: "business",
    group: inferGroup(def),

    // Permissions (declared permissions from FeatureDefinition — all are gate candidates)
    permissions: def.permissions, // FeaturePermission = string (e.g. 'analytics:read')

    // Navigation (derived from FeatureDefinition.navigation)
    navigation: {
      mode: "default",
      sidebar: { show: def.navigation.length > 0 },
      header: { show: true, title: def.displayName },
      layout: { fullscreen: false },
    },
  };
}

Mapping Table

FeatureDefinition fieldFeatureConfig field
idid
displayNamename
version (from package.json)version
permissions[] (string[])permissions[]
navigation[0].labelnavigation.header.title
navigation.length > 0navigation.sidebar.show
(package name passed separately)package
(assigned by load order)loadPriority

Note: These derivation rules are guidelines, not enforced by the framework. The actual features.config.ts may override any field explicitly.

Two navigation Types

There are two distinct navigation fields that serve different purposes:

TypeInterfaceLives inPurpose
FeatureNavItem[]FeatureDefinition.navigationFeature package (src/feature.ts)Sidebar hierarchy: labels, paths, icons, children for sub-menus
FeatureNavigationConfigFeatureConfig.navigationShell config (features.config.ts)Layout control: sidebar show/hide, header show/hide, fullscreen mode

At runtime, the FeatureRegistry extracts FeatureDefinition.navigation (the FeatureNavItem[]) into the FeatureModule.navigation field. The sidebar generator reads this to build collapsible sub-menus. The shell's FeatureConfig.navigation (the FeatureNavigationConfig) controls higher-level layout behavior like whether the sidebar is visible at all.


8. Config Validation

On shell boot, FeatureRegistry validates the loaded config against a set of runtime checks. Validation failures are logged to the console and the offending feature is skipped (not crashed):

CheckOn Failure
id matches ^[a-z][a-z0-9-]*$Feature skipped, error logged
package is a non-empty stringFeature skipped
enabled is booleanFeature treated as disabled
environment is valid enum valueTreated as 'all'
permissions are strings matching \{id\}:[a-z-]+Invalid permissions stripped
loadPriority is a positive numberDefaulted to 999

The shell never crashes on a bad feature config — it degrades gracefully by skipping the invalid feature and continuing with the rest.


9. env.config.ts — Service URL Map

Alongside features.config.ts, the NNO CLI Service also manages src/config/env.config.ts, which maps feature service env keys to their provisioned Worker URLs:

// src/config/env.config.ts
// ⚠️  GENERATED FILE — DO NOT EDIT MANUALLY

export const serviceUrls: Record<string, string> = {
  VITE_AUTH_API_URL: "https://k3m9p2xw7q-r8n4t6y1z5-auth-prod.workers.dev",
  VITE_BILLING_API_URL:
    "https://k3m9p2xw7q-r8n4t6y1z5-billing-prod.workers.dev",
};

The useServiceUrl(envKey) hook from the Feature SDK reads from this map at runtime:

function useServiceUrl(envKey: string): string {
  const url = import.meta.env[envKey] ?? serviceUrls[envKey];
  if (!url) throw new Error(`Service URL not configured for ${envKey}`);
  return url;
}

Priority: import.meta.env (CF Pages env vars set in dashboard) overrides env.config.ts (generated defaults). This allows overriding service URLs per environment without a repo commit.


10. Relationship to FeatureDefinition

FeatureDefinition (in package)     features.config.ts (in platform repo)
─────────────────────────────     ─────────────────────────────────────
Source of truth for:               Generated from FeatureDefinition when
  - contract / validation            feature is activated.
  - permissions declared           Controls:
  - routes declared                  - which features boot with the shell
  - service binding config           - sidebar/nav visibility
  - lifecycle hooks                  - load order and lazy-loading
                                     - environment scoping
                                     - permission gate keys

They serve different purposes and live in different places. The FeatureDefinition is the package author's contract. The FeatureConfig is the platform's runtime activation record. NNO CLI Service is the bridge between them.


Status: Detailed design — Phase 1.5 auto-discovery implemented (2026-02-26) Related: System Architecture §14.F · Feature Package SDK · NNO CLI Service

On this page