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
FeatureRegistryto 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.tswas manually authored. Developers edited it directly to enable or disable features and had to keep the static import registry infeatures.config.tsin sync by hand.- Phase 1.5 (current): Feature packages are auto-discovered at build time by the Vite
neutrino-feature-discoveryplugin. Installing an@neutrino-io/feature-*package that declares"neutrino": \{"type": "feature"\}in itspackage.jsonis enough — no manual registration required.features.config.tsnow merges auto-discovered manifests, console-local features, and shell overrides fromfeatures.overrides.ts.- Phase 2 (planned): NNO CLI Service will generate and commit
features.config.tsautomatically 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 moduleThree Config Sources
| Source | File | Purpose |
|---|---|---|
| Auto-discovered manifests | virtual:feature-registry (generated) | Feature self-description from installed packages |
| Console-local features | features.config.ts (inline localFeatures) | Shell-owned features (home, help, errors) |
| Shell overrides | features.overrides.ts | Enable/disable, auth integration, priority tweaks |
Feature Package Convention
Feature packages opt in to auto-discovery by:
- Exporting
featureManifestfrom their barrel (src/index.ts) - Adding
"neutrino": \{"type": "feature"\}to theirpackage.json - 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.jsondependencies for@neutrino-io/feature-* - Checks each dependency's
package.jsonforneutrino.type === "feature" - Generates a virtual module with static imports and manifest array
- Supports HMR: re-discovers when any
package.jsonchanges
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-authsrc/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: truecan be overridden toenabled: falseviafeatures.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
| Rule | Detail |
|---|---|
| Key format | camelCase version of id slug. help-center → helpCenter |
| Ordering | Core features first (by loadPriority), then domain, then client, then system |
| System features | Always included, hardcoded from the template, never removed |
| Package version | Taken from Marketplace record at activation time, pinned as semver range (^major.minor.0) |
| Deactivated features | Set enabled: false, preserved in file. Hard-removed only on platform deprovision |
| Header comment | Always 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 sidebarThe 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:
- Console-local features —
@console/featuresis imported statically (always available) - Auto-discovered packages —
virtual:feature-registryprovides static imports for all@neutrino-io/feature-*packages - Dynamic fallback —
import()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
FeatureConfigexports (e.g.settingsFeatureConfig) may have incomplete navigation (flat top-level items without children). The canonicalFeatureDefinition(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 Key | Meaning |
|---|---|
billing:read | View billing information |
billing:manage | Modify billing settings |
settings:write | Change settings |
zero:access | Access operator backoffice |
* | Super-admin: all permissions |
5.4 Sidebar Visibility vs. Route Access
These are evaluated independently:
| User state | Sidebar entry | Route access |
|---|---|---|
| Has required permission | Shown | Allowed |
| Missing permission | Hidden | Forbidden (redirects to /403) |
Feature disabled (enabled: false) | Hidden | 404 |
| Feature not bundled (wrong environment) | Hidden | 404 |
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:
Nav Item Generation Strategies
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 theiconByNamemap - 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:
- Static groups from
navigation.config.ts(e.g. "Overview", "Academic", "Configuration") form the base structure - Each feature's nav item is placed into its matching group (by
groupfield), or into the first group as fallback - 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
// Organization6. Environment Filtering
Features can be scoped to specific deployment environments:
environment value | Active 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 field | FeatureConfig field |
|---|---|
id | id |
displayName | name |
version (from package.json) | version |
permissions[] (string[]) | permissions[] |
navigation[0].label | navigation.header.title |
navigation.length > 0 | navigation.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.tsmay override any field explicitly.
Two navigation Types
There are two distinct navigation fields that serve different purposes:
| Type | Interface | Lives in | Purpose |
|---|---|---|---|
FeatureNavItem[] | FeatureDefinition.navigation | Feature package (src/feature.ts) | Sidebar hierarchy: labels, paths, icons, children for sub-menus |
FeatureNavigationConfig | FeatureConfig.navigation | Shell 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):
| Check | On Failure |
|---|---|
id matches ^[a-z][a-z0-9-]*$ | Feature skipped, error logged |
package is a non-empty string | Feature skipped |
enabled is boolean | Feature treated as disabled |
environment is valid enum value | Treated as 'all' |
permissions are strings matching \{id\}:[a-z-]+ | Invalid permissions stripped |
loadPriority is a positive number | Defaulted 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 keysThey 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