Add-on Development Guide

EduNest LMS ships as a single codebase with 93 built-in add-ons living under src/product/addons/<id>/. There is no ZIP to upload — every add-on is already in the source. Add-ons come in two shapes:

  • Channel integrations — connect a third-party provider for a job like payments, email, SMS, or video. (This guide.)
  • Feature plugins — add whole features such as Certificates or Gamification. (See the Plugin Development Guide.)

A channel add-on is gated by license entitlement + an admin toggle: it is usable only when the license covers it (or its purchase code is activated) and an admin connects it. This guide walks through building one end-to-end, using Payments — Stripe (src/product/addons/payments-stripe/) as the worked example.

Anatomy of a Channel Add-on

A channel add-on is a folder under src/product/addons/<id>/ containing a manifest and the code it references. Stripe's folder:

Code
src/product/addons/payments-stripe/
  addon.json            manifest
  index.ts              exports `channel` (ChannelDef) and `driver`
  stripe.ts             the ProviderDef (form fields + setup steps)
  driver.ts             the PaymentDriver (create / verify)
  installation-guide.md docs shown in admin

The manifest (addon.json)

JSON
{
  "id": "payments-stripe",
  "name": "Payments — Stripe",
  "channel": "payments",
  "provider": "stripe",
  "version": "1.0.0",
  "type": "code",
  "premium": true,
  "serverAdapters": { "payment": "driver" },
  "products": ["*"],
  "vendor": "creative-cape.com"
}
Field Meaning
id Unique add-on id (folder name)
channel / provider The channel it joins and the provider it adds
serverAdapters Runtime adapters to wire in; maps an adapter kind (payment) to the export name in the add-on (driver)
clientAdapters (optional) Same, for browser-side adapters
premium true → gated behind license entitlement / purchase code
products Which products this add-on supports — "*" for all, or a product id like "edunest-lms"

The ChannelDef and ProviderDef

index.ts exports a ChannelDef (the channel "shell" — label, icon, tint) containing one ProviderDef, plus the driver named in serverAdapters:

TS
// index.ts
import type { ChannelDef } from "@/core/channels/types";
import type { PaymentDriver } from "@/core/payments/types";
import { CreditCard } from "lucide-react";
import { stripe } from "./stripe";
import { stripeDriver } from "./driver";

export const channel: ChannelDef = {
  id: "payments",
  label: "Payments",
  desc: "Collect payments at checkout and from invoices.",
  icon: CreditCard,
  tint: "bg-violet-50 text-violet-600",
  providers: [stripe],
};

export const driver: PaymentDriver = stripeDriver;

The ProviderDef (stripe.ts) declares the credential fields an admin fills in (text / secret / select) and optional setup steps. Field types come from src/core/channels/types.ts:

TS
// stripe.ts
import type { ProviderDef } from "@/core/channels/types";

export const stripe: ProviderDef = {
  id: "stripe",
  name: "Stripe",
  desc: "Global card processor.",
  color: "bg-violet-50 text-violet-600",
  fields: [
    { key: "mode", label: "Mode", type: "select",
      options: [{ value: "live", label: "Live" }, { value: "sandbox", label: "Sandbox / Test" }] },
    { key: "publishable_key", label: "Publishable Key", type: "text",   required: true, placeholder: "pk_live_…" },
    { key: "secret_key",      label: "Secret Key",      type: "secret", required: true, placeholder: "sk_live_…" },
    { key: "webhook_secret",  label: "Webhook Secret",  type: "secret" },
  ],
  setup: [
    { title: "Get API keys", desc: "Stripe Dashboard → Developers → API keys." },
    { title: "Add a webhook secret", desc: "Create a webhook endpoint and copy its signing secret." },
  ],
};

secret fields are encrypted at rest and masked in API responses.

The driver / adapter

driver.ts implements the runtime contract for its channel — for payments, PaymentDriver (src/core/payments/types.ts) with create() (start a payment) and verify() (confirm it). Drivers are self-contained and receive their saved config plus request context:

TS
// driver.ts (abridged)
export const stripeDriver: PaymentDriver = {
  provider: "stripe",
  label: "Card (Stripe)",
  kind: "redirect",
  create: async (ctx) => {
    const sk = ctx.config.secret_key;        // the admin's saved (decrypted) secret
    // …create a Stripe Checkout Session…
    return { kind: "redirect", redirectUrl: url, ref: sessionId };
  },
  verify: async (ctx) => {
    // …confirm the session was paid…
    return { paid: true, ref: paymentIntent };
  },
};

Other channels expose their own adapter contracts (email senders, video providers, analytics script injectors, and so on) under src/core/<channel>/ and src/core/adapters/.

Registration & Merging

Add-ons are wired into core through generated aggregator indexes, not manual edits. The channel registry merges base channels with installed add-on channels:

TS
// src/core/channels/registry.ts
export const CHANNELS: ChannelDef[] = mergeChannels([
  emailChannel, paymentsChannel, storageChannel, analyticsChannel, videoChannel,
  ...ADDON_CHANNELS,   // one provider per add-on, from src/product/addons/index.ts
]);

Each add-on contributes a one-provider ChannelDef; mergeChannels() combines defs sharing an id into a single channel card and de-duplicates providers by id. So payments-stripe and payments-razorpay both surface under one Payments card with two providers.

ADDON_CHANNELS, ADDON_FEATURES, and the server/client adapter maps are produced by the bundler — never hand-edited.

Build Step: bundle & catalog

Run after adding, removing, or changing any add-on:

Terminal
npm run addons:bundle    # scripts/bundle-addons.mjs
npm run addons:catalog   # scripts/gen-addon-catalog.mjs
  • addons:bundle scans src/product/addons/*/addon.json on disk and regenerates the four aggregator indexes: src/product/addons/index.tsADDON_CHANNELS, src/product/features/index.tsADDON_FEATURES, src/product/adapters-server/index.tsADDON_SERVER_ADAPTERS, src/product/adapters-client/index.tsADDON_CLIENT_ADAPTERS.
  • addons:catalog writes docs/add-ons/catalog.json (and a README index) — the machine-readable catalog used by creative-cape.com, including each add-on's entitlement key.

Licensing & Gating

Whether an add-on is premium is set by addon.json.premium and confirmed by the free/premium tier set in src/core/channels/tiers.ts. Only a small base set of providers is free:

Code
email:smtp · analytics:ga4 · payments:cod · payments:bank · payments:paypal
storage:r2 · video:youtube · video:vimeo

Every other provider is premium. At request time premiumBlock(headers, channel, provider) (src/lib/premium.ts) decides access:

  1. Free provider → always allowed.
  2. License entitlements include "*"/"all", the channel id (whole-channel unlock), or the exact "<channel>:<provider>" key → allowed.
  3. A per-add-on purchase code has been activated for this domain → allowed.
  4. Otherwise → blocked with an unlock message.

On development/localhost hosts everything is unlocked, so you can build and test freely.

Connecting & purchase codes in admin

Admins manage all of this at Settings → Integrations (/admin/settings/integrations):

  • Connect a provider by filling its fields; credentials are saved encrypted and the connection is stored in integration_connections with an is_active flag (and a per-channel primary).
  • Enter a purchase code to unlock a premium provider not covered by the main license. POST /api/v1/admin/addons/license ({ channel, provider, code }) validates the code and records the entitlement for the domain; GET lists current add-on licenses.

A premium channel add-on is therefore live only when it is entitled (license or purchase code) and an admin has connected and activated it.


© CreativeCape Solutions · creative-cape.com · support@creative-cape.com