Feature Package SDK
Documentation for Feature Package SDK
Date: 2026-03-30
Status: Detailed Design
Parent: System Architecture
Package: @neutrino-io/sdk
Overview
The Feature Package SDK is the contract layer between the NNO console shell and any feature package — Type 1 (core), Type 2 (domain), or Type 3 (client-authored). It lives in packages/sdk and is the only package a feature author needs to import from the shell ecosystem.
Feature Package
imports → @neutrino-io/sdk (types, hooks, utilities)
exports → FeatureDefinition (consumed by console shell at bundle time)The SDK has zero runtime dependencies on the shell itself. This keeps features independently testable and prevents coupling.
1. Core Types
1.1 FeatureDefinition
The primary export every feature package must provide:
// packages/sdk/src/feature/types.ts
export interface FeatureDefinition {
// ── Identity ──────────────────────────────────────────────────────────────
id: string; // Unique slug. Lowercase, hyphens only. e.g. 'analytics'
version: string; // Semver. e.g. '1.0.0'
displayName: string; // Human-readable. e.g. 'Analytics'
description: string; // Short description shown in NNO Portal
icon?: string; // Lucide icon name. e.g. 'bar-chart-2'
// ── Shell Integration ──────────────────────────────────────────────────────
routes: FeatureRoute[]; // Route definitions (see Section 1.2)
navigation: FeatureNavItem[]; // Sidebar entries this feature contributes
permissions: FeaturePermission[]; // Required permissions (see Section 3)
// ── Backend ────────────────────────────────────────────────────────────────
requiresService?: boolean; // true = feature needs a backend Worker provisioned
serviceEnvKey?: string; // Env var name for service URL. e.g. 'VITE_ANALYTICS_API_URL'
// Required when requiresService: true
// ── Providers ─────────────────────────────────────────────────────────────
providers?: FeatureProvider[]; // Context providers injected at shell level (see Section 4)
// ── Lifecycle ─────────────────────────────────────────────────────────────
onRegister?: (shell: ShellContext) => void; // Called once when shell loads feature
onActivate?: (shell: ShellContext) => void; // Called when user navigates into feature
onDeactivate?: (shell: ShellContext) => void; // Called when user navigates away
}1.2 FeatureRoute
Route definitions use a plain config object; the shell translates these into TanStack Router routes at build time:
export interface FeatureRoute {
path: string; // Relative to feature root. e.g. '/', '/detail/$id'
component: ComponentType<Record<string, unknown>> | string; // Page component — pass the exported name as a string (e.g. 'BillingDashboard') or a direct ComponentType reference
layout?: "default" | "full" | "blank" | "auth" | "minimal" | "error"; // Shell layout variant. Default: 'default'
auth?: boolean; // Require authentication. Default: true
permissions?: string[]; // Gate this specific route behind permissions
meta?: {
title?: string; // Browser tab title
description?: string; // Meta description
};
}Path conventions:
/— feature root (e.g./analytics)/detail/$id— dynamic segment (TanStack Router$paramsyntax)/settings— nested sub-page within the feature- Paths are registered exactly as declared — no shell-level prefix is added
1.3 FeatureNavItem
export interface FeatureNavItem {
label: string;
path: string; // Must match one of the declared routes
icon?: string | ComponentType<{ size?: number; className?: string }>; // Icon (overrides feature default)
order?: number; // Sidebar sort order. Lower = higher. Default: 100
group?: string; // Sidebar group key, e.g. 'analytics', 'management'
badge?: () => string | number | null; // Dynamic badge (unread count, status)
children?: FeatureNavItem[]; // Nested sidebar items
permissions?: string[]; // Permissions required to see this nav item
}Sub-Menu Navigation with Children
Features can declare nested sidebar items using children. The shell renders these as collapsible sub-menus:
// features/settings/src/feature.ts
navigation: [
{
label: "Settings",
path: "/settings",
icon: "Settings", // String icon name — resolved to lucide component by the shell
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,
},
],
},
];Icon resolution: The icon field accepts either a string name (e.g. 'User', 'Settings') or a React component reference. String names are resolved to lucide-react components by the shell's sidebar generator. This allows feature packages to specify icons without importing lucide-react directly.
Ordering: Children are sorted by order (ascending). Items without an order default to 99.
Permissions on nav items: Individual children can declare permissions?: string[] to conditionally show/hide specific sub-menu entries based on the user's permission set.
1.4 ShellContext
What the shell makes available to lifecycle hooks and the useShell() hook:
export interface ShellContext {
platform: {
id: string;
name: string;
};
tenant: {
id: string;
name: string;
parentId: string | null;
};
user: {
id: string;
email: string;
name: string;
permissions: string[];
};
features: {
active: string[]; // IDs of all currently active features
isActive: (id: string) => boolean;
};
navigate: (to: string) => void;
env: Record<string, string>; // VITE_* env vars available to the feature
// stack?: StackInstance; — Stack context injection into ShellContext is designed but not yet implemented in the SDK.
}1.5 FeatureManifest (Build-Time Discovery)
In addition to FeatureDefinition, feature packages export a featureManifest for build-time auto-discovery:
// features/analytics/src/manifest.ts
import type { FeatureManifest } from "@neutrino-io/sdk/types";
export const featureManifest: FeatureManifest = {
id: "analytics",
name: "Analytics",
package: "@neutrino-io/feature-analytics",
enabledByDefault: true,
loadPriority: 50,
lazyLoad: true,
domain: "analytics",
type: "business",
group: "Insights",
defaultComponent: "AnalyticsDashboard",
};The manifest is consumed at build time by the Vite feature discovery plugin. It replaces the need to manually register the feature in features.config.ts.
Key difference: FeatureDefinition describes what the feature does (routes, navigation, permissions). FeatureManifest describes how the shell should load it (priority, enabled state, domain).
FeatureResourceRequirements
Status: Designed, not yet implemented.
FeatureResourceRequirementsand aresourcesfield onFeatureManifestare planned but not present in the current SDK (packages/sdk/src/types/feature-config.ts). The design below is retained for reference.
The resources block will declare which Cloudflare infrastructure this feature needs. The provisioning service will read this at activation time and create only the declared resources — nothing more:
// Planned — not yet in packages/sdk/src/types/feature-config.ts
export interface FeatureResourceRequirements {
/** Deploy a CF Worker for this feature's backend API */
worker?: boolean;
/** Create a CF Pages project (SPA or portal) */
pages?: boolean;
/** Create a D1 SQLite database */
d1?: boolean;
/** Create an R2 object storage bucket */
r2?: boolean;
/** Create a KV namespace (cache, config, session) */
kv?: boolean;
/** Create a Cloudflare Queue */
queue?: boolean;
/**
* Minimum billing plan required to activate this feature.
* The registry checks this via the billing quota API before
* enqueueing the activation job. Omit for features available on all plans.
*/
minimumPlan?: "starter" | "growth" | "scale";
}Examples by feature type:
| Feature | resources declaration |
|---|---|
| Auth-only (no backend) | \{\} or omit resources |
| SPA portal | \{ pages: true, minimumPlan: 'starter' \} |
| Analytics (DB + API) | \{ d1: true, worker: true, minimumPlan: 'growth' \} |
| File storage | \{ r2: true, kv: true, minimumPlan: 'growth' \} |
| Full-stack feature | \{ d1: true, r2: true, worker: true, minimumPlan: 'scale' \} |
Omitting resources (or leaving it empty) means the feature has no infrastructure footprint — it runs entirely client-side against existing services (e.g. @neutrino-io/feature-settings).
Feature packages must also declare the discovery marker in their package.json:
{
"neutrino": {
"type": "feature"
}
}The featureManifest must be exported from the package barrel (src/index.ts) so the Vite plugin can import it when generating the virtual:feature-registry module.
Two required exports per feature package:
\{id\}FeatureDefinition— the runtime contract (routes, navigation, permissions); consumed by the shell at runtimefeatureManifest— the build-time discovery metadata; consumed by the Vite plugin during bundle generation
1.6 Stack Types
The SDK exports four stack-related types for use by features operating within a stack context. These are exported from @neutrino-io/sdk/types.
StackFeatureRef
export interface StackFeatureRef {
featureId: string; // NNO feature ID, e.g. 'billing', 'analytics'
required: boolean; // false = stack activates without this feature if unavailable
config?: Record<string, unknown>; // Feature-level overrides within stack context
}StackResourceRequirements
export interface StackResourceRequirements {
sharedD1?: boolean; // Provision one D1 shared across all stack features
sharedR2?: boolean; // Provision one R2 bucket shared across stack features
sharedKV?: boolean; // Provision one KV namespace shared across stack features
worker?: boolean; // Deploy a stack-level orchestration Worker
pages?: boolean; // CF Pages project for the stack's front-end
queue?: boolean; // Message queue for the stack
minimumPlan?: "starter" | "growth" | "scale";
}StackDefinition
NNO operators use this type to define stack templates published to services/stack-registry:
export interface StackDefinition {
id: string; // kebab-case, e.g. 'saas-starter'
version: string; // semver
displayName: string; // e.g. 'SaaS Starter Stack'
description: string;
icon?: string; // Lucide icon name
features: StackFeatureRef[]; // Ordered list of features in this stack
resources: StackResourceRequirements;
permissions: string[]; // Stack-level gates, e.g. ['saas-starter:access']
requiresService?: boolean; // Deploy a stack-level orchestration Worker
serviceEnvKey?: string; // Env var for the orchestration Worker URL
}StackManifest
Build-time discovery contract for stacks (used by the shell's Vite plugin):
export interface StackManifest {
id: string;
name: string; // Display name
version: string; // Semver
description: string;
icon?: string;
features: StackFeatureRef[];
resources: StackResourceRequirements;
domain: string; // Business domain grouping
type: "system" | "business";
group: string; // Sidebar group heading
}StackInstance
Runtime context injected into ShellContext when a feature is activated within a stack:
export interface StackInstance {
id: string; // stack instance ID (from registry)
stackId: string; // template ID or 'local' for platform-local
name: string;
version: string;
isLocal: boolean;
sharedResources: {
d1Id?: string;
r2Name?: string;
kvId?: string;
workerName?: string;
};
}Feature components can access the stack context via useShell().stack:
import { useShell } from '@neutrino-io/sdk/feature';
function MyPage() {
const shell = useShell();
if (shell?.stack) {
// Feature is running in a stack context
const stackId = shell.stack.id;
const sharedD1 = shell.stack.sharedResources.d1Id;
}
return <div>...</div>;
}See stacks.md for the full Stack architecture specification.
2. SDK Hooks
Features consume shell context through these hooks. All hooks are provided by the SDK and backed by context injected by the shell.
2.1 useShell()
Access the full shell context. Returns ShellContext | null — null when called outside a shell-provided context (e.g. in unit tests without a ShellContextProvider). Always guard before destructuring:
import { useShell } from '@neutrino-io/sdk/feature';
function MyPage() {
const shell = useShell();
if (!shell) return null; // outside shell context — e.g. standalone test
const { platform, tenant, user, navigate } = shell;
return <div>Platform: {platform.name}, Tenant: {tenant.name}</div>;
}2.2 useFeaturePermission(permission)
Check if the current user holds a specific permission. Use this hook to conditionally render UI elements within a feature component:
import { useFeaturePermission } from '@neutrino-io/sdk/feature';
function AnalyticsExportButton() {
const canExport = useFeaturePermission('analytics:export');
if (!canExport) return null;
return <button>Export</button>;
}This hook reads from the session's
permissionsarray (injected by the shell viaShellContextProvider). Returnstrueif the user holds the permission OR holds'*'(platform-admin wildcard). Returnsfalseotherwise — it never throws.
2.3a useHasPermission(permission) — settings/auth context
@neutrino-io/sdk/hooks/auth also exports useHasPermission() for use in settings and auth-adjacent feature components. It reads from SettingsAuthContext rather than ShellContext and is used by the settings feature internally:
import { useHasPermission } from '@neutrino-io/sdk/hooks/auth';
function AdminSection() {
const canAdmin = useHasPermission('settings:admin');
if (!canAdmin) return null;
return <AdminPanel />;
}Use useFeaturePermission() for feature components. Use useHasPermission() only when building settings-adjacent UI that integrates with the settings auth context.
2.3 useFeatureEnv()
Resolve environment variables injected by the shell:
import { useFeatureEnv } from "@neutrino-io/sdk/feature";
function useAnalyticsClient() {
const env = useFeatureEnv();
const baseUrl = env["VITE_ANALYTICS_API_URL"];
return createApiClient(baseUrl);
}3. Permission Model
3.1 Declaration
Every feature declares its permissions in FeatureDefinition.permissions. The shell will not activate the feature for a user who lacks all required permissions.
export type FeaturePermission = string; // format: '{feature-id}:{action}', e.g. 'analytics:read'Standard Permission Format: All permission keys follow
\{feature-id\}:\{action\}— the feature's ownidas the namespace, colon, then a lowercase-hyphenated action name. Examples:analytics:read,billing:manage,zero:platform-manage. This format is enforced bynno feature validate(rule V06: must match^\{id\}:[a-z-]+$).
Example — analytics feature:
permissions: [
'analytics:read', // gates entire feature — user must have this to access
'analytics:export', // optional — checked per UI element via useFeaturePermission()
'analytics:admin', // optional — conditional access to admin UI
],3.2 Permission Hierarchy
Permissions are assigned at the NNO IAM layer and flow down:
Platform Admin
→ can grant/revoke all permissions for all tenants under this platform
Tenant Admin
→ can grant/revoke permissions for users within their tenant only
User
→ holds an explicit set of permission keys
→ evaluated at shell boot (embedded in JWT claims or fetched from auth service)3.3 Shell Gate Behaviour
| Scenario | Shell Behaviour |
|---|---|
| User missing a required permission | Feature routes return 403, feature removed from sidebar |
| User missing an optional permission | Feature loads normally; feature uses useFeaturePermission() to conditionally render UI |
| Feature not in tenant's active feature list | Feature is not bundled into the shell (build-time exclusion) |
4. Provider Injection
Clarification — two distinct provider patterns:
- Shell providers (e.g.
ShellContextProvider,SettingsAuthContextProvider) — provided by the console shell at app root. Features consume these via SDK hooks (useShell(),useHasPermission()). Feature packages should never create their own shell-level providers.- Feature providers (this section) — provided by feature packages for their own internal state. These are injected by the shell into the router outlet tree so a feature's nested routes can share state without prop-drilling.
Features use approach 1 (hooks) to read shell data. Features declare approach 2 (providers) only when they need their own global state shared across their own sub-routes.
Features can inject React context providers at the shell level — useful for feature-internal global state that needs to be accessible across nested routes within a feature:
export interface FeatureProvider {
key: string; // unique identifier for this provider
component: ComponentType<{ children: React.ReactNode }>;
}Example:
// analytics feature — provides a global date range context (feature-internal state)
import { DateRangeProvider } from "./providers/date-range";
const feature: FeatureDefinition = {
id: "analytics",
// ...
providers: [{ key: "date-range", component: DateRangeProvider }],
};The shell wraps the router outlet with all active feature providers on startup:
ShellProviders (shell-owned — features consume via hooks)
└── FeatureProvider (analytics/DateRangeProvider — feature-owned internal state)
└── FeatureProvider (billing/BillingContextProvider — feature-owned internal state)
└── RouterOutlet5. nno.config.ts — Feature Manifest File
Designed, not yet available.
Every feature package includes an nno.config.ts at the root. This is the source of truth for the NNO CLI and Marketplace:
// nno.config.ts
import { defineFeatureConfig } from "@neutrino-io/sdk/config";
export default defineFeatureConfig({
// ── Marketplace Identity ──────────────────────────────────────────────────
id: "analytics", // Must match FeatureDefinition.id
version: "1.0.0", // Must match package.json version
author: "@acme", // npm scope of the publishing client
license: "MIT",
// ── Service Configuration ─────────────────────────────────────────────────
service: {
name: "analytics", // Worker service name (used in wrangler.toml template)
framework: "hono", // 'hono' | 'none'
hasDatabase: true, // Whether a D1 database should be provisioned
hasMigrations: true, // Whether to run migrations on activation
migrationsDir: "service/migrations",
},
// ── Compatibility ─────────────────────────────────────────────────────────
compatibility: {
shellVersion: ">=1.0.0", // Minimum shell version required
requires: [], // Other feature IDs this feature depends on
conflicts: [], // Feature IDs that cannot be active at the same time
},
// ── Local Dev ─────────────────────────────────────────────────────────────
dev: {
port: 5180, // Vite dev port for feature UI (must not conflict)
servicePort: 8790, // Wrangler dev port for feature Worker
mockShellPort: 5100, // Port of the NNO mock shell
},
});6. Scaffold Template (nno init)
When nno init my-feature is run, the CLI generates this structure:
my-feature/
├── src/
│ ├── feature.ts ← FeatureDefinition export (main SDK entry)
│ ├── index.tsx ← Package entry point (re-exports feature.ts)
│ ├── routes/
│ │ ├── index.tsx ← Feature home page (/my-feature)
│ │ └── detail.$id.tsx ← Example detail page (/my-feature/detail/:id)
│ ├── components/ ← Feature-local UI components
│ ├── hooks/ ← Feature-local hooks (data fetching, etc.)
│ ├── providers/ ← Optional: context providers declared in feature.ts
│ └── types.ts ← Feature-local TypeScript types
│
├── service/ ← Backend Worker (only if requiresService: true)
│ ├── src/
│ │ ├── index.ts ← Hono app entry
│ │ ├── routes/
│ │ │ └── my-feature.ts ← Route handlers
│ │ └── db/
│ │ ├── schema.ts ← Drizzle schema
│ │ └── index.ts ← DB client export
│ ├── migrations/
│ │ └── 0001_init.sql ← Initial schema migration
│ └── wrangler.toml ← Template — IDs filled by NNO Provisioning on activation
│
├── __tests__/
│ ├── feature.test.ts ← FeatureDefinition contract tests
│ ├── routes.test.tsx ← Route component tests
│ └── service.test.ts ← Worker handler tests (if service)
│
├── nno.config.ts ← NNO feature manifest
├── package.json
├── tsconfig.json
└── README.mdGenerated src/feature.ts
import type { FeatureDefinition } from "@neutrino-io/sdk";
import IndexPage from "./routes/index";
import DetailPage from "./routes/detail.$id";
export const feature: FeatureDefinition = {
id: "my-feature",
version: "1.0.0",
displayName: "My Feature",
description: "A short description of what this feature does.",
icon: "layout-dashboard",
routes: [
{
path: "/",
component: IndexPage,
meta: { title: "My Feature" },
},
{
path: "/detail/$id",
component: DetailPage,
permissions: ["my-feature:read"],
},
],
navigation: [
{
label: "My Feature",
path: "/",
icon: "layout-dashboard",
order: 50,
},
],
permissions: ["my-feature:read"],
requiresService: true,
serviceEnvKey: "VITE_MY_FEATURE_API_URL",
};
export default feature;7. Testing Utilities
Designed, not yet available.
The SDK ships test helpers so features can be tested in isolation without a real shell:
import {
renderWithShell,
createMockShellContext,
mockPermissions,
} from '@neutrino-io/sdk/testing';
// Basic render with default mock shell context
test('renders feature home page', () => {
const { getByText } = renderWithShell(<IndexPage />);
expect(getByText('My Feature')).toBeInTheDocument();
});
// Custom shell context
test('hides export button without permission', () => {
const ctx = createMockShellContext({
user: {
permissions: ['my-feature:read'],
// 'my-feature:export' NOT included
},
});
const { queryByText } = renderWithShell(<IndexPage />, { context: ctx });
expect(queryByText('Export')).not.toBeInTheDocument();
});
// Mock service URL
test('calls analytics API with correct base URL', async () => {
const ctx = createMockShellContext({
env: { VITE_MY_FEATURE_API_URL: 'http://localhost:8790' },
});
// ... test API calls
});8. Contract Validation (compile-time + runtime)
Designed, not yet available.
The SDK exports a Zod schema for FeatureDefinition so validation can be run both at development time (via nno validate) and at shell load time:
import { featureDefinitionSchema } from "@neutrino-io/sdk/validation";
// Runtime guard in shell
const result = featureDefinitionSchema.safeParse(feature);
if (!result.success) {
console.error(
`Invalid FeatureDefinition for feature '${feature?.id}':`,
result.error,
);
// Feature is skipped, not loaded into shell
}Validated rules:
idmatches^[a-z][a-z0-9-]*$(lowercase, no leading digit)versionis valid semverpermissionsentries match^\{id\}:[a-z-]+$(scoped to feature ID)serviceEnvKeyis present and matches^VITE_[A-Z_]+_API_URL$whenrequiresService: true- No duplicate route paths
- No route
pathstarting with/\{id\}(shell prefixes automatically) navigation[].pathvalues all exist inroutes[].path
9. Package Structure (packages/sdk)
packages/sdk/
├── src/
│ ├── index.ts ← Main entry: types + hooks
│ ├── feature/ ← FeatureDefinition, hooks, context
│ │ ├── index.ts
│ │ ├── types.ts ← FeatureDefinition, FeatureRoute, etc.
│ │ ├── hooks.ts ← useShell, useFeaturePermission, useFeatureEnv
│ │ └── context.tsx ← ShellContextProvider (used by shell, not features)
│ ├── utils/ ← Shared utility functions
│ │ └── index.ts ← cn, formatDate, formatDateTime, etc.
│ ├── hooks/ ← General hooks
│ │ └── index.ts
│ ├── hooks/auth/ ← Auth-related hooks
│ │ └── index.ts ← useSettingsAuth, useHasPermission, etc.
│ ├── types/ ← Shared TypeScript types
│ │ └── index.ts
│ └── constants/ ← Shared constants (reserved for future use)
│ └── index.ts
│
├── package.json
└── tsconfig.jsonpackage.json exports:
{
"exports": {
".": { "types": "...", "import": "...", "require": "..." },
"./feature": { "types": "...", "import": "...", "require": "..." },
"./utils": { "types": "...", "import": "...", "require": "..." },
"./hooks": { "types": "...", "import": "...", "require": "..." },
"./hooks/auth": { "types": "...", "import": "...", "require": "..." },
"./types": { "types": "...", "import": "...", "require": "..." },
"./constants": { "types": "...", "import": "...", "require": "..." }
}
}Note: ./config and ./testing are not implemented yet. Stack types (StackDefinition, StackManifest, StackFeatureRef, StackResourceRequirements, StackInstance) are exported from ./types.
9a. Utility Exports (@neutrino-io/sdk/utils)
The ./utils subpath exports general-purpose utility functions available to all feature packages:
import {
cn,
formatDate,
formatDateTime,
formatCategory,
truncateText,
debounce,
} from "@neutrino-io/sdk/utils";
import { generateId } from "@neutrino-io/core/naming"; // ← generateId lives in @neutrino-io/core, not sdk
// cn — merge Tailwind class names (wraps clsx + tailwind-merge)
const className = cn("base-class", isActive && "active-class", props.className);
// formatDate — format a date value to a locale string
const label = formatDate(new Date());
// formatDateTime — format a date with time component
const label = formatDateTime(new Date());
// formatCategory — format a category string for display
const display = formatCategory("some_category_key");
// truncateText — truncate a string to a max length with ellipsis
const short = truncateText(longString, 80);
// debounce — debounce a function call
const debouncedSearch = debounce(handleSearch, 300);
// generateId is NOT exported from @neutrino-io/sdk/utils.
// Always import directly from @neutrino-io/core/naming:
// import { generateId } from '@neutrino-io/core/naming'
const id = generateId();10. Type Boundary: FeatureDefinition vs FeatureConfig
The Two-Type Model
There are two separate type concerns in the feature loading system. Understanding their boundary is essential for both feature authors and shell maintainers.
| Type | Lives in | Perspective | Purpose |
|---|---|---|---|
FeatureDefinition | packages/sdk | Feature package | What a feature declares about itself — its identity, routes, permissions, providers, lifecycle hooks |
FeatureConfig | packages/sdk (shell types) | Shell orchestration | How the shell loads and manages a feature — loading strategy, environment gating, domain classification |
FeatureDefinition — The Feature's Contract
FeatureDefinition contains only what a feature knows about itself. It has no awareness of shell loading mechanics:
// packages/sdk — owned by the feature package author
interface FeatureDefinition {
id;
version;
displayName;
description;
icon; // identity
routes;
navigation;
permissions; // shell contributions
requiresService;
serviceEnvKey; // backend binding
providers?; // context injection
onRegister?;
onActivate?;
onDeactivate?; // lifecycle
// NO: package, module, enabled, domain, lazyLoad, loadPriority, environment
}FeatureConfig — The Shell's Orchestration Wrapper
FeatureConfig wraps FeatureDefinition with the shell's own loading and management fields:
// packages/sdk (shell types) — owned by the shell
// Source: packages/sdk/src/types/feature-config.ts
interface FeatureConfig
extends Partial<Omit<FeatureDefinition, "id" | "navigation">>,
ShellOrchestrationFields {
// Required identity (not optional even though FeatureDefinition fields are Partial here)
id: string;
}
// ShellOrchestrationFields — the shell-only fields added on top of FeatureDefinition.
// Feature packages must NOT include these; they are shell concerns only.
interface ShellOrchestrationFields {
package: string; // npm package name. e.g. '@neutrino-io/feature-analytics'
module: string; // Export path within the package. e.g. 'features/analytics'
enabled: boolean; // Whether the shell should activate this feature
domain: string; // Shell categorisation. e.g. 'core' | 'billing' | 'zero'
lazyLoad?: boolean; // Whether to code-split the feature
loadPriority?: number; // Relative load order (lower = earlier)
environment?: "development" | "production" | "staging" | "all";
}Composition in features.config.ts
The shell's features.config.ts composes FeatureConfig by taking each package's exported FeatureDefinition and extending it with shell-specific fields:
// apps/console/src/config/features.config.ts
import type { FeatureConfig } from "@neutrino-io/sdk/types";
import { analyticsFeatureDefinition } from "@neutrino-io/ui-analytics";
export const features: FeaturesConfig = {
analytics: {
...analyticsFeatureDefinition, // ← spreads FeatureDefinition from the package
package: "@neutrino-io/ui-analytics",
module: "AnalyticsFeature",
enabled: true,
domain: "analytics",
lazyLoad: true,
loadPriority: 30,
},
};Impact on Feature Package Authors
Feature packages (ui-auth, feature-settings, feature-billing, client packages) should:
- Import
FeatureDefinitionfrom@neutrino-io/sdk - Export a named
*FeatureDefinitionconstant (e.g.analyticsFeatureDefinition) - Import
FeatureManifestfrom@neutrino-io/sdk/typesand export afeatureManifestconstant for build-time auto-discovery (see Section 1.5) - Add
"neutrino": \{"type": "feature"\}to theirpackage.jsonto opt in to auto-discovery - Never import
FeatureConfigfrom shell-internal types — that type is shell-internal
// packages/ui-analytics/src/feature.ts — correct pattern
import type { FeatureDefinition } from '@neutrino-io/sdk'
export const analyticsFeatureDefinition: FeatureDefinition = {
id: 'analytics',
version: '1.0.0',
displayName: 'Analytics',
description: 'Usage analytics and reporting',
icon: 'bar-chart-2',
routes: [...],
navigation: [...],
permissions: ['analytics:read'],
requiresService: true,
serviceEnvKey: 'VITE_ANALYTICS_API_URL',
}Migration from FeatureConfig Omit Pattern
All known packages that previously used Omit<FeatureConfig, 'package' | 'module'> as a workaround have been migrated to FeatureDefinition:
| Package | Status |
|---|---|
features/settings | ✅ Migrated — imports FeatureDefinition from @neutrino-io/sdk/feature |
features/* | ✅ Migrated — feature configs typed as FeatureDefinition |
packages/ui-auth | ✅ Migrated — authFeatureDefinition in src/features/index.ts typed as FeatureDefinition; AuthFeatureConfig in src/types/feature-config.ts is a separate internal auth-form config type, not a shell registry type |
11. Versioning & Compatibility
The SDK follows semver. The shell declares a minimum SDK version it supports; features declare the minimum shell version they require (via nno.config.ts compatibility.shellVersion).
| Change Type | Version Bump | Impact |
|---|---|---|
New optional field on FeatureDefinition | Minor | Backward-compatible |
New required field on FeatureDefinition | Major | Features need update |
| New hook added to SDK | Minor | Backward-compatible |
| Hook signature change | Major | Features need update |
| New validation rule | Minor (with deprecation warning first) | Features may need update |
Breaking SDK changes will include a migration guide and a compatibility shim for one major version.
13. Stack Integration
Features run in two modes: standalone (each feature provisions its own Cloudflare resources) and stack (a group of features shares a single set of Cloudflare resources provisioned once for the stack). This section covers how a feature detects and uses stack context.
Cross-reference: stacks.md for the full Stack architecture,
StackDefinition, and lifecycle.
13.1 Stack Context in ShellContext
Not yet implemented. Stack context injection into
ShellContextis designed but not yet present in the SDK. Thestackfield does not currently exist onShellContext. The design below documents the intended future behaviour once this is implemented.
When implemented, useShell().stack will return a StackInstance when a feature is activated as part of a stack instance, and will be undefined when the feature is activated standalone.
// packages/sdk/src/types/stack.ts (from @neutrino-io/sdk/types)
// Target type definition — not yet wired into ShellContext
export interface StackInstance {
id: string; // Stack instance ID (from Registry)
stackId: string; // Template ID (e.g. 'saas-starter') or 'local' for platform-local stacks
name: string;
version: string;
isLocal: boolean;
sharedResources: {
d1Id?: string; // CF D1 database ID
r2Name?: string; // CF R2 bucket name
kvId?: string; // CF KV namespace ID
workerName?: string;
};
}13.2 Detecting Stack Context in the UI (Planned)
Not yet implemented. The following pattern will work once
ShellContext.stackis added to the SDK.
import { useShell } from "@neutrino-io/sdk/hooks";
export function BillingDashboard() {
const shell = useShell();
if (shell.stack) {
// Feature is running inside a stack instance
console.log("Stack:", shell.stack.stackId, "Instance:", shell.stack.id);
} else {
// Feature is activated standalone
}
}13.3 Detecting Stack Context in a Feature Worker
A feature Worker detects stack context by checking for the STACK_DB binding injected by the NNO Provisioning service at activation time:
// In your feature Worker (e.g. services/billing or a feature Worker)
export default {
async fetch(request: Request, env: Env) {
if (env.STACK_DB) {
// Running in stack context — use shared D1
const db = env.STACK_DB;
// Tables are namespaced by feature: billing__subscriptions, billing__invoices
} else {
// Standalone activation — use feature-specific D1
const db = env.DB;
}
},
};13.4 Resource Binding Names
The following bindings are injected by NNO Provisioning when activating a feature within a stack. They are in addition to the feature's own bindings (e.g. DB, STORAGE):
| Binding | Available when | Description |
|---|---|---|
STACK_DB | StackResourceRequirements.sharedD1: true | Shared D1 database for the entire stack |
STACK_STORAGE | StackResourceRequirements.sharedR2: true | Shared R2 bucket for the entire stack |
STACK_KV | StackResourceRequirements.sharedKV: true | Shared KV namespace for the entire stack |
STACK_ID | Always, when in stack | Stack instance ID string |
STACK_TEMPLATE_ID | Always, when in stack | Stack template ID (e.g. saas-starter) |
When STACK_DB is present, all tables in that D1 must be prefixed with \{featureId\}__ to avoid collisions between features sharing the same database:
-- billing feature tables in the shared stack D1
CREATE TABLE billing__subscriptions ( ... );
CREATE TABLE billing__invoices ( ... );
-- analytics feature tables in the same D1
CREATE TABLE analytics__events ( ... );13.5 Per-Feature Config Overrides via StackFeatureRef
Stack templates can pass per-feature config overrides through StackFeatureRef.config. These are surfaced in the feature Worker as env vars (prefixed with STACK_CONFIG_) at activation time:
// In the stack template (published to services/stack-registry)
const saasStarterStack: StackDefinition = {
features: [
{
featureId: "billing",
required: true,
config: {
trialDays: 14,
defaultPlan: "growth",
},
},
],
// ...
};
// In the billing Worker
const trialDays = Number(env.STACK_CONFIG_TRIAL_DAYS ?? 0);
const defaultPlan = env.STACK_CONFIG_DEFAULT_PLAN ?? "starter";13.6 Stack-Awareness Declaration
Features do not need to declare stack-awareness in their FeatureDefinition or FeatureManifest. Stack bindings (STACK_DB, STACK_KV, etc.) are injected transparently by the provisioning service. A feature Worker should be written to handle both the presence and absence of stack bindings — this makes it independently activatable (standalone) and stack-compatible without code changes.
Status: Detailed design — FeatureManifest auto-discovery pattern implemented (2026-02-26); Stack types added 2026-02-28
Implementation target: packages/sdk/
12. UI Component Standards
Date added: 2026-03-01
Applies to: All feature packages in features/* and packages/ui-*
Feature packages are responsible for their own UI. This section defines the mandatory UI component standards that all NNO feature packages must follow to ensure visual consistency, dark mode compatibility, and maintainability.
12.1 Component Source
All UI components come from a single source: @neutrino-io/ui-core.
// ✅ Correct — single barrel import
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Badge,
Button,
Separator,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@neutrino-io/ui-core";
// Layout + feedback primitives — also from @neutrino-io/ui-core
import {
PageHeader, // Page-level header (title, description, action, breadcrumbs)
EmptyState, // Empty data placeholder with icon + CTA
PageSkeleton, // Full-page loading skeleton
CardGridSkeleton, // Grid of card skeletons
ConfirmDialog, // Pre-built confirmation dialog
} from "@neutrino-io/ui-core";@neutrino-io/ui-core re-exports every shadcn/ui component plus NNO-specific layout primitives (PageHeader, EmptyState, ConfirmDialog, etc.). Feature packages must not install shadcn directly.
12.2 Styling — No Inline Styles
Feature packages must never use inline style=\{\{...\}\} objects or hardcoded hex/rgb values.
All styling uses Tailwind CSS utility classes with the project's semantic CSS variable tokens:
| ❌ Banned | ✅ Correct |
|---|---|
style=\{\{ color: '#dc2626' \}\} | className="text-destructive" |
style=\{\{ color: '#64748b' \}\} | className="text-muted-foreground" |
style=\{\{ background: '#f8fafc' \}\} | className="bg-muted" |
style=\{\{ padding: '1.5rem' \}\} | className="p-6" |
style=\{\{ border: '1px solid #e2e8f0' \}\} | className="border" |
style=\{\{ borderRadius: '8px' \}\} | className="rounded-lg" |
style=\{\{ display: 'flex', gap: '1rem' \}\} | className="flex gap-4" |
style=\{\{ fontFamily: 'monospace' \}\} | className="font-mono" |
Semantic color tokens (dark mode compatible — always use these):
| Token | Purpose |
|---|---|
text-foreground | Primary text |
text-muted-foreground | Secondary / subdued text |
text-destructive | Error / danger text |
bg-background | Page background |
bg-card | Card surface |
bg-muted | Subtle surface (code blocks, placeholders) |
border | Default border |
border-destructive | Error / danger border |
12.3 Page Layout Anatomy
Every page component in a feature package must follow this structure:
<div className="space-y-6">
<PageHeader /> ← always first
[optional search/filter row]
[main content: Card(s), Table(s), or EmptyState]
[optional dialogs]
</div>List pages:
export function ItemList() {
const { data, isLoading, isError, error } = useItems(gatewayUrl)
const [createOpen, setCreateOpen] = useState(false)
if (isLoading) return <div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
if (isError) return <Card><CardContent className="pt-6">
<p className="text-sm text-destructive">{error?.message}</p>
</CardContent></Card>
return (
<div className="space-y-6">
<PageHeader
title="Items"
description="Manage your items."
action={{ label: 'New Item', icon: PlusIcon, onClick: () => setCreateOpen(true) }}
/>
<Card>
<CardContent className="p-0">
<Table>...</Table>
</CardContent>
</Card>
<CreateItemDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
)
}Detail pages add breadcrumbs:
<PageHeader
title={item.name}
description={item.id}
breadcrumbs={[
{ label: 'Items', onClick: () => window.location.pathname = '/feature/items' },
{ label: item.name },
]}
/>12.4 State Handling
Loading State
Always use Skeleton — never plain text:
// ❌ Banned
if (isLoading) return <div>Loading...</div>
if (isLoading) return <div style={{ padding: '1rem' }}>Loading items...</div>
// ✅ Correct — table load
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)
}
// ✅ Correct — card grid load
if (isLoading) return <CardGridSkeleton count={4} />Error State
// ❌ Banned
if (isError) return <div style={{ color: 'red' }}>Error: {msg}</div>
// ✅ Correct
if (isError) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : 'Something went wrong'}
</p>
</CardContent>
</Card>
)
}Empty State
// ❌ Banned
{items.length === 0 && <p style={{ textAlign: 'center', color: '#666' }}>No items.</p>}
// ✅ Correct
{items.length === 0 && (
<EmptyState
icon={<InboxIcon className="size-10 text-muted-foreground" />}
title="No items yet"
description="Create your first item to get started."
action={<Button onClick={onCreate}>Create Item</Button>}
/>
)}12.5 Table Pattern
All data tables are wrapped in Card > CardContent className="p-0":
<Card>
<CardHeader>
<CardTitle>Items</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map(item => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigate(item.id)}
>
<TableCell>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{item.id}
</code>
</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>ID rendering — always use a <code> element with monospace styling:
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{item.id}</code>12.6 Status Badge Variants
Use a consistent variant mapping for Badge across all pages:
function statusVariant(
status: string,
): "default" | "destructive" | "secondary" | "outline" {
switch (status) {
case "active":
case "approved":
case "completed":
return "default"; // theme primary (green-ish)
case "suspended":
case "rejected":
case "failed":
case "deactivated":
return "destructive"; // red
case "pending":
case "inactive":
case "draft":
return "secondary"; // neutral grey
default:
return "outline"; // plan tiers, misc categories
}
}Plan / tier badges always use variant="outline":
<Badge variant="outline">{platform.tier}</Badge> // e.g. "starter", "growth"12.7 Detail Page — Info Grid
Use <dl> with CSS grid for structured metadata (avoid raw key-value <div> stacks):
<Card>
<CardContent className="pt-6">
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
ID
</dt>
<dd className="mt-1">
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{item.id}
</code>
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
Status
</dt>
<dd className="mt-1">
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
Created
</dt>
<dd className="mt-1 text-sm font-medium">
{new Date(item.createdAt).toLocaleDateString()}
</dd>
</div>
</dl>
</CardContent>
</Card>12.8 Dialogs
Never build custom fixed-position overlay modals. Use Dialog from @neutrino-io/ui-core:
// ❌ Banned
<div style={{ position: 'fixed', top: 0, left: 0, zIndex: 1000, backgroundColor: 'rgba(0,0,0,0.5)' }}>
// ✅ Correct
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Item</DialogTitle>
<DialogDescription>Fill in the details below.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
<SelectItem value="b">Option B</SelectItem>
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>For pre-built confirmations, use ConfirmDialog directly:
import { ConfirmDialog } from '@neutrino-io/ui-core'
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Confirm Action"
description="This action cannot be undone."
confirmText="Confirm"
destructive
onConfirm={handleConfirm}
/>12.9 Destructive Actions — No window.confirm()
window.confirm() is banned. Use AlertDialog with a typed confirmation input for irreversible operations:
// ❌ Banned
if (window.confirm(`Delete ${item.name}?`)) { handleDelete() }
// ✅ Correct
const [deleteOpen, setDeleteOpen] = useState(false)
const [typedId, setTypedId] = useState('')
<AlertDialog open={deleteOpen} onOpenChange={open => { setDeleteOpen(open); setTypedId('') }}>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {item.name}?</AlertDialogTitle>
<AlertDialogDescription>
This cannot be undone. Type <strong>{item.id}</strong> to confirm.
</AlertDialogDescription>
</AlertDialogHeader>
<Input
value={typedId}
onChange={e => setTypedId(e.target.value)}
placeholder={item.id}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={typedId !== item.id}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>12.10 Reference: @neutrino-io/ui-core Exports
Full component catalogue available from @neutrino-io/ui-core:
shadcn/ui components:
AlertDialog, Avatar, Badge, Button, Calendar, Card, Checkbox, Collapsible, Command, Dialog, DropdownMenu, Form, Input, InputOtp, Label, Pagination, Popover, Progress, RadioGroup, ScrollArea, Select, Separator, Sheet, Sidebar, Skeleton, Sonner, Switch, Table, Tabs, Textarea, Tooltip
Layout primitives:
Header, Main, TopNav
Navigation:
NavGroup, CommandPalette, NavigationProgress, SearchTrigger, UserDropdown
Forms:
PasswordInput, SelectDropdown, ConfirmDialog
Feedback:
Alert, FeatureLoading, ComingSoon, LongText, PageSkeleton, CardGridSkeleton, EmptyState
Accessibility:
SkipToMain
Theme:
ThemeSwitch
12.11 Checklist — Pre-Commit Feature UI Review
Before committing any feature page or component, verify:
- MUST: All components imported from
@neutrino-io/ui-core— no raw HTML elements for interactive controls - MUST: Zero
style=\{\{...\}\}props — only Tailwind class strings - MUST: Zero hardcoded hex/rgb/hsl values — only semantic tokens (
text-muted-foreground,bg-muted, etc.) - MUST: Every page starts with
<PageHeader> - MUST: Loading state uses
Skeleton, not plain text - MUST: Error state uses
Card+text-destructive - MUST: Empty state uses
EmptyStatecomponent - MUST: Tables wrapped in
Card > CardContent className="p-0" - MUST: IDs rendered as
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded"> - MUST: Status badges use the standard variant mapping
- MUST: Dialogs use
Dialog(not fixed-position divs) - MUST: Destructive actions use
AlertDialog(notwindow.confirm()) - MUST: Dark mode tested — no light-only hardcoded colors
Section 12 added 2026-03-01. Reflects patterns established in feature package redesign work. Related: NNO CLI · System Architecture · Shell Feature Config · Stacks