Portal modules

Overview

A portal module is a product UI surface mounted by bus-portal. The host owns module launch, shared assets, security headers, token-aware routing, and metadata. The product module owns DTO projection, view models, page renderers, event hooks, copy, permission display, provider clients, and product routes.

The integration point is Go. Existing modules implement portal.Module; modules that render through shared Bus UI primitives can also implement portal.FrameworkModule. The framework contract lets a product module declare server-rendered pages and matching browser hooks without moving product policy into the host or into a separate descriptor language.

Framework Pages

portal.UIFramework is the compact Go declaration for GX-ready pages. Each UIPage has a stable name, module-relative path, mount ID, deterministic Go renderer, and optional typed browser hooks. The declaration is metadata about Go renderers and Go/WASM event projection; it is not a YAML or JSON UI tree.

func (ReportsModule) UIFramework() portal.UIFramework {
    return portal.UIFramework{
        DefaultRenderPageName: "main",
        PublicRuntimeConfig: map[string]string{
            "api_base": "/api/v1/reports",
        },
        ProviderAPIOrigins: []string{"https://api.example"},
        Pages: []portal.UIPage{
            {
                Name:    "main",
                Path:    "/",
                MountID: "reports-root",
                Render:  renderReportsPage,
                Hooks: []portal.BrowserEffect{
                    {
                        Name:     "save",
                        Kind:     "form",
                        Event:    "submit",
                        Action:   "save-report",
                        TargetID: "reports-form",
                        Fields:   []string{"title", "amount"},
                    },
                },
            },
        },
    }
}

The renderer receives portal.UIRenderContext, including HostContext, the selected page, and a Bus UI render runtime. Its output must be deterministic for the same inputs so module tests can compare server HTML directly.

func renderReportsPage(ctx portal.UIRenderContext) uikit.Node {
    props := ReportsPageProps{
        Host: uiportal.HostContextFromPortal(ctx.HostContext),
        Rows: reportRows(ctx),
        Save: saveReportDraft,
    }
    return ReportsPage(props)
}

When a module still has legacy string HTML during migration, keep that adapter small and feed it only trusted or sanitized fragments. New framework pages should use typed Go components and .gx source over BodyHTML assembly.

GX source can wrap the same shape with typed props when a module page is written as .gx. Data still arrives as ordinary Go values.

func ReportsPage(props ReportsPageProps) gx.Node {
    return (
        <PortalShell title="Reports" hostContext={props.Host}>
            <ReportForm rows={props.Rows} onSubmit={props.Save}></ReportForm>
        </PortalShell>
    )
}

Event Projection

Browser behavior is declared as typed Go hooks and projected through fixture-tested helpers. portal.ProjectFrameworkEvent selects a declared page and hook, checks that the observed browser event and action match the declaration, and returns only the fields named by the hook.

projected, err := portal.ProjectFrameworkEvent(module, portal.BrowserEvent{
    Page:   "main",
    Hook:   "save",
    Event:  "submit",
    Action: "save-report",
    Values: map[string]string{
        "title":      "Q2",
        "amount":     "42",
        "csrf_token": "not-projected",
    },
})
if err != nil {
    return err
}

The projected payload contains title and amount; undeclared fields are not part of the public event payload. This keeps broad browser e2e focused on host startup and mounted module navigation while module unit fixtures prove render output and event projection.

Local And Hosted Apps

Local app-style UIs and portal-mounted modules should share the same Bus UI building blocks where the behavior is reusable. A local app may own its shell and browser startup, while a portal module receives host context and route mounting from bus-portal. Forms, tables, assistant panels, terminal panes, evidence links, event routing, and runtime helpers should stay in shared Bus UI or product module packages according to ownership.

The host does not become a provider facade. Auth, billing, LLM access, container lifecycle, terminal sessions, uploads, accounting workspace reads, report generation, and artifact access stay behind provider APIs and their product modules.