How to scaffold, register, and ship a new extension that plugs into the core platform.
- What this guide covers
- Prerequisites
- What an extension is
- The extension layout
- Manifest and feature flags
- Core mode vs. extension mode
- Backend integration
- Frontend integration
- Worker integration
- Submodule mechanics
- Built-in extensions
- Billing and payment specialists
- BaaS API surface
- Related guides
- Materials previously at
Powernode's core platform stays small. Domain functionality — fleet ops, marketing automation, supply chain, billing, trading — lives in extensions: self-contained submodules that mount into the core Rails app, frontend, and worker. This guide is for extension authors: developers building a new vertical on top of the platform.
You will learn the manifest contract, the feature-gate predicate, the directory layout extensions follow, the submodule workflow, and the gotchas that bite first-timers.
- You have a working core platform per
docs/getting-started/01-quickstart.md - You have read
docs/guides/backend.mdanddocs/guides/frontend.md— extensions follow the same conventions - You have an upstream git repo (Gitea private, GitHub mirror for OSS extensions)
An extension is a separate git repository, mounted into powernode-platform as a submodule under extensions/<slug>/. It can contribute any combination of:
- A Rails engine (
server/) that adds models, controllers, services, and migrations - A frontend bundle (
frontend/) that adds pages, components, and nav entries - Worker jobs (
worker/) executed by the shared Sidekiq process
flowchart TB
subgraph Core[powernode-platform CORE]
Server[server/]
Frontend[frontend/]
Worker[worker/]
Registry[Powernode::ExtensionRegistry]
FGS[Shared::FeatureGateService]
end
subgraph Ext[extensions/myext/]
ManifestFile[extension.json]
ExtServer[server/ Rails engine]
ExtFrontend[frontend/]
ExtWorker[worker/ jobs]
end
ManifestFile --> Registry
Registry --> FGS
ExtServer -.autoloaded.-> Server
ExtFrontend -.bundled.-> Frontend
ExtWorker -.merged.-> Worker
The core platform stays runnable with zero extensions loaded — that's "core mode." Extensions are opt-in.
extensions/<slug>/
├── extension.json <- manifest (required)
├── README.md
├── CONTRIBUTING.md
├── server/
│ ├── lib/powernode_<slug>/
│ │ └── engine.rb <- Rails engine
│ ├── app/{models,controllers,services,channels}/
│ ├── config/
│ │ └── routes.rb
│ └── db/
│ ├── migrate/
│ └── seeds/<slug>_seed.rb
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── features/
│ │ └── index.ts <- mount point
│ ├── tsconfig.json
│ └── package.json <- optional
└── worker/
└── app/jobs/<slug>/
The server/, frontend/, and worker/ subtrees mirror the core platform's layout — same directory names, same conventions. The Rails autoloader, the frontend build, and Sidekiq each discover their extension contributions automatically (within the constraints of the manifest).
Every extension MUST have an extension.json at its root:
{
"name": "Marketing",
"slug": "marketing",
"version": "0.1.0",
"description": "Marketing campaigns, content calendar, email lists, social media, campaign analytics.",
"author": "Your Name",
"homepage": "https://example.com",
"feature_flag": "marketing_mode",
"capabilities": ["campaigns", "content_calendar", "email_lists"],
"components": {
"server": true,
"frontend": true,
"worker": true
},
"dependencies": []
}| Field | Purpose |
|---|---|
slug |
Filesystem name; used as namespace prefix (marketing → Marketing::Campaign) |
feature_flag |
Flipper flag (<slug>_mode); runtime enable/disable without restart |
capabilities |
Optional list of feature strings the extension provides; used by Shared::FeatureGateService.available? |
components |
Which subtrees the extension contributes (drop the key if you ship none of that layer) |
dependencies |
Other extension slugs required to be loaded first |
flowchart LR
A[Manifest on disk?] -->|no| Off1[OFF]
A -->|yes| B[Listed in<br/>extensions_state.json<br/>disabled[]?]
B -->|yes| Off2[OFF - load-time gate]
B -->|no| C[Flipper flag<br/>slug_mode enabled?]
C -->|no| Off3[OFF - runtime gate]
C -->|yes| On[ON]
config/extensions_state.json is the load-time gate (requires a restart to flip). The Flipper flag is the runtime gate (no restart). The manifest presence is the install gate.
Shared::FeatureGateService exposes the predicates:
Shared::FeatureGateService.extension_loaded?('marketing') # autoloaded into this process
Shared::FeatureGateService.extension_enabled?('marketing') # loaded + state + flag
Shared::FeatureGateService.available?('campaigns', account: acct) # capability available
Shared::FeatureGateService.business_loaded? # specific predicate for business
Shared::FeatureGateService.core_mode? # zero extensions loadedFrontend gating uses build flags (e.g., __BUSINESS__) injected at bundle time, plus a businessOnly: true marker on nav entries.
| Mode | Description | Use case |
|---|---|---|
| Core mode | Zero extensions loaded; single-user self-hosted | Solo developer running the platform locally; OSS deployment without commercial features |
| Extension mode | One or more extensions loaded | Hosted SaaS, multi-tenant, billing-enabled, fleet-managed |
Core mode is the OSS default. Every feature in the core repo MUST work in core mode (no billing surface, no multi-tenancy). Features that require an extension MUST fail open in core mode — return nil, return an empty collection, return "feature unavailable" gracefully — never raise.
A typical guard:
class Api::V1::AdvancedReportsController < ApplicationController
before_action :require_business_loaded
private
def require_business_loaded
return if Shared::FeatureGateService.business_loaded?
render_error('Advanced reporting requires the business extension', status: :not_found)
end
end# extensions/myext/server/lib/powernode_myext/engine.rb
module PowernodeMyext
class Engine < ::Rails::Engine
isolate_namespace PowernodeMyext
config.generators.api_only = true
initializer 'powernode_myext.assets' do |app|
# any engine-level config
end
end
endThe core platform autoloads engines from extensions/<slug>/server/lib/powernode_<slug>/engine.rb if the extension is enabled.
All models live under your namespace. Use :: in class_name: (see backend guide for the rules):
module Myext
class Campaign < ApplicationRecord
belongs_to :account
belongs_to :owner, class_name: 'User', foreign_key: 'user_id'
# ...
end
endFK prefix follows the namespace: myext_campaign_id, myext_message_id. Migrations go in extensions/<your-extension>/server/db/migrate/ and the platform's db:migrate picks them up.
# extensions/myext/server/config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
namespace :myext do
resources :campaigns
end
end
end
endThe core router mounts each enabled extension's routes automatically.
Hard rule: core never depends on extensions; extensions depend on core, never on each other directly. If two extensions need to coordinate, the integration goes through a core service interface or an event bus.
Each extension defines its own path alias to avoid colliding with the core's @/:
| Extension | Alias |
|---|---|
| business | @business/ |
| trading | @trading/ |
| marketing | @marketing/ |
| supply-chain | @supply-chain/ |
| system | @system/ |
Core imports always use @/. Intra-extension imports use the extension's alias. Cross-extension imports are forbidden.
Vite injects build-time flags for each loaded extension. Use them to dead-code-eliminate extension-only sections:
if (__BUSINESS__) {
// billing UI only rendered when business loaded
}The flag is __<SLUG_UPPER>__ for each extension (e.g., __MARKETING__, __SUPPLY_CHAIN__).
Nav items declared in extension code carry a feature marker:
export const marketingNavItems: NavItem[] = [
{
label: 'Campaigns',
href: '/marketing/campaigns',
icon: MegaphoneIcon,
extensionOnly: 'marketing', // hidden when extension not loaded
},
];Worker jobs follow the same BaseJob pattern as core (see backend guide). Place them under extensions/<your-extension>/worker/app/jobs/<your-extension>/. Sidekiq autoloads them.
Cron schedules from extensions merge into the worker's Sidekiq-cron config at boot. Use a dedicated queue per extension to allow operators to pause extension work independently:
class Myext::CampaignDispatchJob < BaseJob
sidekiq_options queue: 'myext', retry: 3
def execute(args)
# API-only — never load extension models in worker
api_client.post('/api/v1/myext/campaigns/dispatch', args)
end
endExtensions are git submodules. Every committer hits the same workflow:
sequenceDiagram
participant Dev
participant Ext as extensions/myext
participant Parent as powernode-platform
Dev->>Ext: cd extensions/myext
Dev->>Ext: git checkout -b feat/foo
Dev->>Ext: edit, git add, git commit
Dev->>Ext: git push origin feat/foo
Dev->>Parent: cd ../..
Dev->>Parent: git add extensions/myext
Dev->>Parent: git commit -m "Bump myext to <sha>"
Dev->>Parent: git push
Note over Dev: Open TWO PRs:<br/>1. in extension repo<br/>2. in parent repo
Hard rules:
- Always run
git rev-parse --show-toplevelbeforegit add/committo verify which repo you're in. - Commit inside the submodule FIRST, then bump the parent pointer.
- Never run
git add extensions/myext/some/filefrom the parent — it only stages a pointer change. extensions/businessandextensions/tradingare NOT committed to the public parent — their upstream is private. Maintainers add them manually viagit submodule add.extensions/system,extensions/marketing,extensions/supply-chainare dual-remoted (private Gitea origin + public GitHub mirror). Push to both on release.- Do NOT run
git submodule syncon dual-remoted extensions — it overwrites local config and drops the private upstream.
The platform ships with five extensions; you can study any of them as exemplars.
| Slug | Visibility | Purpose |
|---|---|---|
system |
Public (MIT, GitHub) | Node lifecycle, fleet autonomy, modules, SDWAN, container runtimes, on-node Go agent |
marketing |
Public (MIT, GitHub) | Campaigns, content calendar, email lists, social media |
supply-chain |
Public (MIT, GitHub) | Procurement, inventory, supplier management |
business |
Private | Billing, BaaS, reseller, AI publisher |
trading |
Private (currently disabled) | Trading strategies, market data |
The system extension is the most heavily-developed exemplar — see its CONTRIBUTING.md for production-grade extension scaffolding.
The platform's billing engine and payment provider integrations (Stripe, PayPal, dunning, invoicing) live in the extensions/business private submodule. They are not part of the open-source core.
If you have access to the business submodule, see its docs for the historical specialist guides:
BillingEngineDeveloperSpecialist— subscription lifecycle, plan management, invoice generationPaymentIntegrationSpecialist— Stripe/PayPal webhook receivers, payment retries, PCI-aware patterns
Core-mode contributors do not need these guides. The platform runs as single-user self-hosted with all features unlocked and no billing surface when the business extension is absent.
If you are building a third-party billing extension on top of core, you have two integration paths:
- Implement against the BaaS API surface (described below) — the business extension exposes a multi-tenant billing API you can call from your extension or from external services.
- Provide your own billing engine as a separate extension — register your
Billing::namespace, your provider adapters, and your invoicing controllers; gate them behind your extension's feature flag.
When the business extension is loaded, the platform exposes a Billing-as-a-Service API for multi-tenant subscription management. This is the public contract third-party billing consumers integrate against.
/api/v1/baas
All BaaS requests require an API key:
Authorization: Bearer <api_key>
X-Tenant-ID: <tenant_id> # Optional; derived from API keyAPI keys can be scoped: customers, subscriptions, invoices, usage.
| Resource | Endpoints |
|---|---|
| Tenant | GET /tenant, PATCH /tenant, GET /tenant/dashboard, GET /tenant/limits, GET/PATCH /tenant/billing_configuration |
| Customers | GET/POST /customers, GET/PATCH/DELETE /customers/:id |
| Subscriptions | GET/POST /subscriptions, GET/PATCH/DELETE /subscriptions/:id, POST /subscriptions/:id/{cancel,pause,resume} |
| Invoices | GET/POST /invoices, GET/PATCH/DELETE /invoices/:id, POST /invoices/:id/{finalize,pay,void}, POST/DELETE /invoices/:id/line_items |
| Usage | POST /usage_events, POST /usage_events/batch (max 1000), GET /usage, GET /usage/summary, GET /usage/aggregate, GET /usage/analytics |
{
"success": true,
"data": { ... },
"meta": { "pagination": { "current_page": 1, "total_pages": 5, "total_count": 150 } }
}Errors:
{ "success": false, "error": "Customer not found" }
{ "success": false, "errors": ["Field1 is required", "Field2 must be a valid email"] }The BaaS layer emits webhooks for the full billing lifecycle:
| Event | Trigger |
|---|---|
customer.created / customer.updated |
Customer mutations |
subscription.created / subscription.updated / subscription.canceled |
Subscription lifecycle |
invoice.created / invoice.finalized / invoice.paid / invoice.past_due |
Invoice lifecycle |
usage.threshold_reached |
Usage limit warning |
Payload shape:
{
"id": "evt_abc123",
"type": "subscription.created",
"created_at": "2025-01-30T10:30:00Z",
"data": { "object": { ... } }
}Verify the X-Webhook-Signature: sha256=... header against your tenant webhook secret. See docs/guides/backend.md for the standard webhook receiver pattern.
| Tier | Requests/minute | Burst |
|---|---|---|
| Starter | 100 | 20 |
| Growth | 1,000 | 100 |
| Business | 10,000 | 1,000 |
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
Usage event POSTs accept an idempotency_key — re-sending the same key returns the original event without double-counting.
- Backend — Rails patterns extensions follow
- Frontend — React patterns and the alias system
- DevOps — deploying with extensions enabled
- Security — supply chain and signing for extensions
docs/getting-started/03-extensions.md— quick tour for first-time usersdocs/concepts/architecture.md— why the extension model existsdocs/reference/auto/— live capability inventories
This guide consolidates content from these legacy paths (preserved in git history for one release cycle):
docs/backend/BAAS_API_REFERENCE.mddocs/backend/BILLING_ENGINE_DEVELOPER_SPECIALIST.md— content not merged here; lives inextensions/businessdocs/backend/PAYMENT_INTEGRATION_SPECIALIST.md— content not merged here; lives inextensions/business
Last verified: 2026-05-17