FForm Platform
enzh-CN

Module development

Create maintainable product modules with a manifest, versioned SDK contracts, migrations, endpoints and optional Vue extensions.

FormPlatform Module Development Step-by-Step

中文:MODULE_DEVELOPMENT_STEP_BY_STEP.zh-CN.md

This tutorial creates an independent `Inventory` module from an empty directory. It includes configuration, three-provider migrations, an ORM entity, domain service, API, Server Action, client i18n, and deployment. The module is source-separated from the Host and references only the published SDK.

1. Choose a module when appropriate

Layout, field, and event changes need only a form. Use a module for dedicated tables, server APIs, actions/triggers, reusable controls, routes, or customer-specific logic. A module is trusted in-process code with full server privileges; isolate untrusted code in a separate service.

2. Prerequisites and layout

Build/publish FormPlatform so the SDK directory contains `FormPlatform.Sdk.dll/.xml` and `FormPlatform.Extension.Abstractions.dll/.xml`.

workspace/
  FormPlatform/
  Inventory/
    InventoryModule.csproj
    module.json
    appsettings.inventory.json
    InventoryModuleEntry.cs
    InventoryMigrations.cs
    InventoryService.cs
    InventoryActions.cs
    client/index.js
    client/inventory.css
    deploy.ps1
    deploy.bat
    uninstall.ps1
    uninstall.bat

3. Create the project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Version>1.0.0</Version>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AssemblyName>InventoryModule</AssemblyName>
    <RootNamespace>InventoryModule</RootNamespace>
    <GenerateDependencyFile>true</GenerateDependencyFile>
    <FormPlatformSdkDirectory Condition="'$(FormPlatformSdkDirectory)' == ''">..\FormPlatform\src\FormPlatform.Host\bin\Debug\net10.0</FormPlatformSdkDirectory>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <Reference Include="FormPlatform.Sdk" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Sdk.dll" Private="false" />
    <Reference Include="FormPlatform.Extension.Abstractions" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Extension.Abstractions.dll" Private="false" />
  </ItemGroup>
  <ItemGroup>
    <None Update="module.json" CopyToOutputDirectory="PreserveNewest" />
    <None Update="appsettings.inventory.json" CopyToOutputDirectory="PreserveNewest" />
    <None Update="client\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

`Private=false` ensures the Host supplies the single shared SDK and Abstractions assemblies.

4. Create the manifest

{
  "manifestVersion": 1,
  "name": "Inventory",
  "version": "1.0.0",
  "entryAssembly": "InventoryModule.dll",
  "type": "InventoryModule.InventoryModuleEntry",
  "sdk": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
  "platform": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
  "navigation": [
    { "token": "inventory-management", "titleKey": "menu.inventory.management", "icon": "📦", "isGroup": true },
    { "token": "inventory-items", "titleKey": "menu.inventory.items", "target": "/inventory", "icon": "📋", "parentToken": "inventory-management" }
  ],
  "ownedSystemForms": []
}

Names use only alphanumerics, `.`, `_`, and `-`, with a 100-character maximum. Manifest version must match assembly Major/Minor/Build. Ranges are minimum-inclusive and maximum-exclusive.

`navigation` is the module's complete declarative menu ownership list. Every entry has a stable `token`, `titleKey`, and `icon`; a normal link also has an application-relative `target`. A menu group uses `isGroup: true`; its child links use `parentToken`. The host inserts missing entries and moves a module-owned child below its declared parent. `ownedSystemForms` lists only fixed IDs of system forms that this module can regenerate; retain explicit empty arrays when neither resource is owned.

5. Add configuration and an ORM entity

{
  "Inventory": { "DefaultPageSize": 25, "MaximumPageSize": 200 },
  "DataAccess": {
    "Entities": {
      "InventoryItem": {
        "Schema": "public",
        "TableName": "inventory_item",
        "Attributes": [
          { "PropertyName": "id", "ColumnName": "id", "ValueKind": "Guid", "IsNullable": false, "IsKey": true, "IsGenerated": false },
          { "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
          { "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
          { "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "Guid", "IsNullable": false },
          { "PropertyName": "updatedAt", "ColumnName": "updated_at", "ValueKind": "DateTimeOffset", "IsNullable": false, "CanWrite": false }
        ]
      }
    }
  }
}

Module configuration is added before Core binds DataAccess options. Entity and property names are stable logical contracts, not UI labels.

6. Add three-provider migrations

Implement `IDatabaseMigrationModule` with module name `Inventory`, data source `Management`, and immutable IDs such as `001_create_inventory_item`. Use PostgreSQL `uuid`, MySQL `binary(16)`, and SQL Server `uniqueidentifier` for owned identifiers and references. Do not add a database UUID default: create UUID v7 values in the application or let the ORM populate an empty writable Guid key. See Native UUID v7 identifiers.

Never edit a released `001`; add `002_...`. Review MySQL implicit commits and conditional-index syntax instead of assuming every provider supports `IF NOT EXISTS`.

7. Implement the domain service

Consume SDK ORM interfaces, not Host implementation. Methods accept current user ID and `CancellationToken`; apply owner/authorization filters on the server. Use one unit of work for related reads, validation, and writes, then commit explicitly.

public sealed class InventoryService(IUnitOfWorkFactory workFactory, IDynamicRepository repository)
{
    public async Task<object> CreateAsync(string userId, InventorySaveRequest request, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Name))
            throw new PlatformApiException(400, "inventory.nameRequired", "inventory.nameRequired", "Name is required.");
        await using var work = await workFactory.BeginAsync("Management", ct);
        var row = new DynamicEntity("InventoryItem");
        row["name"] = request.Name.Trim();
        row["quantity"] = request.Quantity;
        row["updatedBy"] = userId;
        var saved = await repository.InsertAsync(work, row, ct);
        await work.CommitAsync(ct);
        return saved;
    }
}

Use the SDK XML documentation and Todo/ResourceBooking source for exact repository/query APIs. Never convert request sort/filter text directly into SQL.

8. Actions and triggers

Implement `IServerActionsProvider` when form events/triggers need server behavior. Register stable names and return structured results. Re-read identity on the server; never trust a client `userId`. Substituted values still require typed validation. Use `PlatformApiException` or SDK trigger results for failure.

9. Implement the entry point

public sealed class InventoryModuleEntry : IFormPlatformSdkModule
{
    public void ConfigureConfiguration(ConfigurationManager configuration, IHostEnvironment environment)
    {
        var directory = Path.GetDirectoryName(typeof(InventoryModuleEntry).Assembly.Location)!;
        configuration.AddJsonFile(Path.Combine(directory, "appsettings.inventory.json"), false, true);
    }

    public void ConfigureServices(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
    {
        services.Configure<InventoryOptions>(configuration.GetSection("Inventory"));
        services.AddSingleton<InventoryService>();
        services.AddSingleton<IServerActionsProvider, InventoryActions>();
        services.AddSingleton<IDatabaseMigrationModule, InventoryMigrations>();
    }

    public void MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        var api = endpoints.MapGroup("/api/inventory/items").RequireAuthorization();
        api.MapGet("/", InventoryEndpoints.QueryAsync);
        api.MapPost("/", InventoryEndpoints.CreateAsync);
        api.MapPut("/{id}", InventoryEndpoints.UpdateAsync);
        api.MapDelete("/{id}", InventoryEndpoints.DeleteAsync);
    }
}

Hook order is Configuration, Services, Application, Endpoints. Implement only what is needed. Middleware affects the entire Host and requires special care.

10. API rules

Namespace APIs under `/api/{module}` and pages under `/{module}`. Apply authorization explicitly. Use 200/201/204 and return resource IDs/Location for creation. Throw `PlatformApiException` for reusable failures; allow Host mapping of `KeyNotFoundException` where appropriate. Forward cancellation throughout.

11. Forms versus a client route

Prefer Data Models, system forms, and DataGrid for CRUD. Use a standalone client route for calendars, boards, or graphics, while editing can still open ordinary forms. ResourceBooking demonstrates this hybrid.

System-form initialization is idempotent. Do not overwrite user-owned forms on every startup. Store system text as `@inventory.key`.

12. Client extension

`client/index.js` exports an installer:

export default {
  install(api) {
    api.registerMessages('Inventory', {
      en: { inventory: { title: 'Inventory' } },
      'zh-CN': { inventory: { title: '库存' } }
    })
    api.registerRoute({
      path: '/inventory',
      component: () => import('/extension-assets/Inventory/inventory-page.js')
    })
    api.extendActionApi('refreshInventory', () =>
      window.FormPlatform.API.fetch('/api/inventory/items'))
  }
}

The extension API registers controls, routes, respondent-dashboard tools, Action APIs, Runtime APIs, messages, and app installers. It exposes `components.FormReader` for trusted module pages and `preloadFormComponents(schema)` for resolving the lazy control chunks that an offline package must cache. A trusted host can pass runtime-only `fileHandler` (`stage`, `preview`, `remove`) and `optionHandler.query` callbacks; controls retain their normal online APIs when a handler is absent, and handlers must never be persisted in form JSON. An option handler must serve the normal `{ items, total, offset, limit, hasMore, nextCursor }` page shape and enforce its snapshot's version and expiry. A route that must bootstrap while disconnected uses `meta.public`; this affects only the browser guard, so its package/data endpoints must still require the appropriate server policy. A custom control uses mock data in `designerMode`, calls APIs through `window.FormPlatform.API.fetch`, and keeps business state in Vue reactivity rather than direct DOM mutations.

13. Enable the client module

{
  "FormPlatformExtensions": {
    "ModulesDirectory": "Modules",
    "ClientModules": ["/extension-assets/Inventory/index.js"]
  }
}

Extensions load before Vue and initial Router navigation, preserving direct module-route navigation.

14. Build and deploy

dotnet build InventoryModule.csproj -c Debug `
  -p:FormPlatformSdkDirectory="C:\path\FormPlatform\bin\Debug\net10.0"

Deploy DLL, deps.json, manifest, module configuration, and client assets under `FormPlatform/Modules/Inventory`. Do not copy SDK/Host DLLs into the package. Restart the Host for a server DLL change; a pure client asset change normally needs only refresh/hard refresh.

Windows deployment launcher standard

Every Windows-targeting module must include both `deploy.ps1` and `deploy.bat`. `deploy.ps1` is the canonical, automation-friendly implementation; `deploy.bat` is a small Command Prompt/Explorer launcher that keeps the working directory stable, forwards all arguments, and returns the PowerShell exit code:

@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
exit /b %ERRORLEVEL%

For example, an administrator can run `deploy.bat -Configuration Release -FormPlatformDirectory C:\inetpub\FormPlatform` without having to compose a PowerShell command. Keep deployment logic in `deploy.ps1`; do not duplicate it in the batch file.

Uninstallation standard

Every module must also provide `uninstall.ps1` and `uninstall.bat`, following the same wrapper rule. A normal uninstall removes only `FormPlatform/Modules/<module-name>` after the Host has stopped. `-RemoveMenuEntries` removes declared leaf tokens and then removes a declared group only when it is empty; it never removes administrator-added links. `-RemoveSystemForms` removes only `ownedSystemForms`. Both options queue a provider-neutral host-side cleanup request that runs through `IFormStore` on the next startup. It must preserve database tables and migration history by default: deleting application data silently makes a later reinstall inconsistent or destructive. A data-purge parameter is allowed only when it is explicit (for example `-RemoveDatabase`), requires an explicit provider and connection string, drops the module's schema, and deletes the corresponding migration history as one deliberate purge operation. PostgreSQL/SQL Server may use a transaction; MySQL DDL implicitly commits, so it must stop and report the first failure. Never infer destructive credentials from a secrets file.

@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall.ps1" %*
exit /b %ERRORLEVEL%

15. Debugging order

Build Host and verify current SDK files; build module against that exact path; compare assembly and manifest versions; deploy the complete package; restart and inspect module/migration logs; call APIs directly; inspect `/api/platform/client-extensions`; verify JS/CSS network responses and route registration; test Designer mock data and Viewer runtime data.

Common failures include old Host/SDK references (`TypeLoadException`), manifest mismatch, unloaded Entity configuration, edited migration checksums, authentication/policy failures, and routes registered after initial navigation.

16. Versioning

Use patch for compatible fixes, minor for compatible additions, and major for breaking contracts. Synchronize project version, manifest, compatibility ranges, changelog, and all-module CI. Increase SDK minimum when needed; do not widen maximum without validation.

17. Completion checklist

  • Only SDK/Abstractions references, both `Private=false`.
  • Manifest, assembly, and ranges match.
  • Immutable migrations cover three providers.
  • Entity metadata matches schema and generated/write flags.
  • APIs authorize, filter ownership, forward cancellation, and use common errors.
  • Actions/triggers never trust client identity.
  • Designer avoids runtime APIs; Preview/Viewer value semantics agree.
  • English and Chinese messages/fallbacks exist.
  • Package contains no private Host/SDK copy.
  • PostgreSQL integration and critical Playwright flows are covered.

For a source-complete Chinese Todo walkthrough, see the dedicated tutorial. ResourceBooking is the hybrid reference module beside the FormPlatform repository.

---

Source layout and assembly boundaries

Purpose

The repository is organised by assembly ownership. A source file is compiled by exactly one project; the host consumes public code through project references rather than linked source files. Assembly names and public namespaces remain unchanged, so deployed modules continue to reference `FormPlatform.Sdk.dll` and `FormPlatform.Extension.Abstractions.dll`.

Layout

PathBuildsResponsibility
`src/FormPlatform.Host/``FormPlatform.dll`ASP.NET Core host, official domain services, endpoints, configuration, Vue application and development module packages
`src/FormPlatform.Sdk/``FormPlatform.Sdk.dll`Public ORM, dynamic entity/query/transaction APIs, trigger/action contracts, migrations, form-store contracts and shared media/error contracts
`src/FormPlatform.Extension.Abstractions/``FormPlatform.Extension.Abstractions.dll`Stable module manifest and lifecycle contracts only
`src/FormPlatform.Licensing/``FormPlatform.Licensing.dll`License document encryption/signature primitives
`samples/FormPlatform.Extension.Sample/`sample moduleMinimal reference module using the SDK and Abstractions
`tests/`test assembliesPostgreSQL and SQL Server integration tests
`src/FormPlatform.Host/Modules/`runtime packagesTrusted modules discovered by the local development host

`FormPlatform.slnx` is the root solution entry point for IDEs and CI.

Common commands

# Run the host from repository root
dotnet run --project src/FormPlatform.Host/FormPlatform.csproj

# Build the host and all official projects
dotnet build FormPlatform.slnx

# Build the browser client manually
cd src/FormPlatform.Host/ClientApp
npm ci
npm run build

Standalone and DNN packaging scripts remain at `deploy/` and use the new host project automatically. Third-party module deployment scripts should pass the host directory, for example `-FormPlatformDirectory C:\src\FormPlatform\src\FormPlatform.Host`.

Rules

1. Put code a third party must compile against in `src/FormPlatform.Sdk` or `src/FormPlatform.Extension.Abstractions`, never in the host.

2. A module references only SDK and Abstractions; it must not reference `FormPlatform.dll`.

3. Keep `Program.cs` a composition root. Host-specific endpoints belong under `src/FormPlatform.Host/Hosting/Endpoints`.

4. Do not add linked `Compile Include=... Link=...` entries to borrow SDK source into the host.

5. `src/FormPlatform.Host/Modules` is a runtime package location, not a place to author module source.

---

Private deployment and trusted secondary development

中文版本:

`PRIVATE_DEPLOYMENT_EXTENSION_ARCHITECTURE.zh-CN.md`

Goal

Customers receive compiled FormPlatform server binaries and compiled Vue assets,

not FormPlatform source. They are trusted secondary developers and may run their

own code inside the application process. They can:

  • edit the deployed `appsettings.json` and add their own configuration sections;
  • use the public ORM and metadata APIs;
  • register server actions, triggers, DI services and hosted services;
  • map arbitrary Minimal APIs for controls and integrations;
  • add form controls to the Designer palette;
  • extend client Action API and per-form Runtime API.

This model protects source distribution; it is deliberately not a plugin

sandbox. A server module has the same process permissions as FormPlatform and

must be treated as trusted application code.

Distribution model

The vendor distributes:

1. the Release output of `FormPlatform`;

2. the compiled Vue production assets under `wwwroot`;

3. `FormPlatform.Sdk.dll` as the server development reference;

4. `FormPlatform.Extension.Abstractions.dll` or its NuGet package;

5. API XML documentation, this guide and the sample extension.

Do not distribute FormPlatform `.cs`/`.vue` files, source maps, Git data, build

caches, private PDBs, signing keys or secrets. .NET IL and browser JavaScript can

still be reverse engineered; optional obfuscation and licensing only increase

the cost. Browser code must never contain secrets.

Server module

Implement `IFormPlatformSdkModule` from the compiled `FormPlatform.Sdk.dll`.

The four hooks run in this order:

For a customer project outside this source tree, use file references to the

vendor SDK directory and do not copy the platform assemblies into the module

package:

<ItemGroup>
  <Reference Include="FormPlatform.Sdk" HintPath="..\sdk\FormPlatform.Sdk.dll" Private="false" />
  <Reference Include="FormPlatform.Extension.Abstractions"
             HintPath="..\sdk\FormPlatform.Extension.Abstractions.dll"
             Private="false" />
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

The in-repository sample uses a project reference only so platform developers

can evolve the example with the source tree.

public sealed class CustomerModule : IFormPlatformSdkModule
{
    public void ConfigureConfiguration(
        ConfigurationManager configuration,
        IHostEnvironment environment)
    {
        configuration.AddJsonFile(
            Path.Combine(environment.ContentRootPath,
                "Modules", "Customer", "appsettings.customer.json"),
            optional: true,
            reloadOnChange: true);
    }

    public void ConfigureServices(
        IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        services.Configure<CustomerOptions>(configuration.GetSection("Customer"));
        services.AddSingleton<IServerActionsProvider, CustomerActions>();
        services.AddSingleton<CustomerService>();
    }

    public void ConfigureApplication(WebApplication application)
    {
        // Optional trusted middleware. FormPlatform exception handling and
        // authentication already run before this middleware.
    }

    public void MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet("/api/customer/items", async (
            CustomerService service, CancellationToken ct) =>
            Results.Ok(await service.LoadAsync(ct)))
            .RequireAuthorization();
    }
}

`ConfigureServices` receives the normal ASP.NET Core service collection. An

extension may inject/use `IDynamicRepository`, `IUnitOfWorkFactory`,

`IEntityModelResolver`, `IJoinQueryService`, `IFormStore` and other public

FormPlatform services. Register `IServerActionsProvider` to add both form Server

Actions and entity Triggers. Use normal authorization on every extension API.

Package and load a server module

Each package is a directory below `Modules`:

Modules/
  Customer/
    module.json
    Customer.Module.dll
    Customer.Module.deps.json
    appsettings.customer.json
    client/
      index.js
      customer.css

`module.json`:

{
  "entryAssembly": "Customer.Module.dll",
  "type": "Customer.Module.CustomerModule"
}

Set the directory and client entry modules in the deployed application

`appsettings.json`:

{
  "FormPlatformExtensions": {
    "ModulesDirectory": "Modules",
    "ClientModules": [
      "/extension-assets/Customer/index.js"
    ]
  },
  "Customer": {
    "ApiBaseUrl": "https://customer.example/api"
  }
}

Restart the application after installing or replacing a server DLL. Module

configuration JSON can use `reloadOnChange`, but service registrations and

endpoint mappings require restart. Packages load deterministically by full

`module.json` path; prefix directory names (`010-Customer`, `020-Reports`) when

one customer module intentionally overrides a registration from another.

ORM from an extension

The ORM is an ordinary injectable public API. A typical query is:

var model = await models.ResolveAsync("Customer", ct);
await using var work = await workFactory.BeginAsync("Business", ct);
var rows = await repository.QueryAsync(
    model,
    new QuerySpec(
        Filter: new FilterCondition("IsActive", FilterOperator.Equal, true),
        OrderBy: [new("Name")],
        Limit: 50),
    work,
    ct);
await work.CommitAsync(ct);

Models and data sources may be declared in the customer's deployed

`appsettings.json` under `DataAccess`, or managed with the existing Data Model

features. The extension uses the same providers, parameterized SQL, transactions

and trigger chain as the built-in application.

Module JSON is loaded before options are bound. Development User Secrets are

then re-applied, and the protected production secret JSON is applied last, so a

module configuration file cannot accidentally override secret values.

Client extension

The client loads each configured ES module before mounting Vue. Its default

export must contain `install(api)`.

export default {
  install(api) {
    const { h } = api.Vue

    api.registerControl({
      type: 'customerPicker',
      component: {
        props: ['value', 'designerMode'],
        emits: ['update:value'],
        setup(props, { emit }) {
          return () => h('button', {
            onClick: () => emit('update:value', 'customer-1')
          }, props.value || 'Choose customer')
        }
      },
      group: 'controls',
      label: 'Customer Picker',
      icon: '◆',
      description: 'Selects a customer',
      dataComponent: true,
      create: () => ({ props: { value: '' } }),
      events: [{ name: 'onChange', label: 'Value changed' }]
    })

    api.extendActionApi('openCustomer', ({ context }, id) =>
      context.api.navigate(`/customers/${id}`))

    api.extendRuntimeApi(runtime => {
      runtime.customerValue = propertyName => runtime.data[propertyName]
    })
  }
}

`registerControl` automatically adds the component to runtime rendering and its

palette group. Optional `propertyEditor` is a Vue component used for the General

tab. The normal Style, Events, Tooltip and Other tabs remain available.

Client extension API:

  • `api.Vue`: the exact Vue runtime instance used by FormPlatform;
  • `api.registerControl(definition)`: control, palette metadata, factory,

property editor, events and data-control status;

  • `api.extendActionApi(name, function)`: adds a method to

`window.FormPlatform.API` and form Action Code context;

  • `api.extendRuntimeApi(function)`: decorates every newly created form runtime;
  • extended runtime methods are available to Action Code through

`context.api.runtime` after the active `FormReader` is ready;

  • `api.registerMessages(owner, messages)`: contributes i18n messages.
  • `api.registerRoute(route)`: adds a Vue Router route supplied by the module;
  • `api.configureApp(callback)`: performs advanced setup against the created Vue

app, router and Pinia instance before mount.

Client assets are served only from `Modules/<name>/client` and only for the

allowed web extensions. A client module can call its own server APIs with

`context.api.fetch` or normal `fetch`.

Building `.vue` controls

For ordinary Vue SFC development, build the customer control as an IIFE and

externalize `vue` to `FormPlatform.Extensions.Vue`; this prevents a second Vue

runtime from entering the page. The IIFE entry calls

`window.FormPlatform.Extensions.registerControl(...)` directly. Such a bundle

does not need an ES-module default export—the loader also accepts a module that

self-registers while executing. A minimal Rollup/Vite output configuration is:

build: {
  lib: { entry: 'src/index.js', name: 'CustomerControls', formats: ['iife'] },
  rollupOptions: {
    external: ['vue'],
    output: { globals: { vue: 'window.FormPlatform.Extensions.Vue' } }
  }
}

The readable source and build project stay with the customer; only the generated

JavaScript and CSS are copied into `Modules/Customer/client`.

Trust and versioning

  • Only administrators should install module DLLs; never provide anonymous DLL

upload.

  • A module can do anything the FormPlatform OS account can do. Review customer

code and use a dedicated application identity with least-privilege database

credentials.

  • Publish a versioned SDK/runtime bundle. Breaking public API changes require a

major version and a compatibility note.

  • Use API/contract tests in customer modules. A runtime update should be tested

against all installed modules before production rollout.

  • Keep secrets in User Secrets, environment variables, systemd credentials or

the existing external production secret JSON—not in module JavaScript.

The complete working examples are under

`samples/FormPlatform.Extension.Sample`.