Skip to content

Backend Integration - Go (planned)

Not yet implemented — this library does not exist

There is no Go integration library. be-integrations/ ships only the Node/Express package (@productifyfw/node-express); it contains no Go module at all, so go get github.com/ProductifyFW/be-integrations/go will fail and every symbol below (productify.Middleware, FromContext, Register, TriggerHandler, ManagerClient, managertest, the Gin adapter) is unimplemented.

This page records the intended design, written in the conditional. Nothing on it can be used today. To integrate a backend now, use @productifyfw/node-express — the only shipped backend library — or call the Manager's REST endpoints directly.

See Known Issues.

The planned Go library would provide a dependency-free net/http core with a Gin adapter module — the same wire protocol and behavior as @productifyfw/node-express, intended to be verified by a shared parity test suite.

Overview

The library would cover the four backend integration surfaces:

  • Identity middleware — would extract the proxy-injected X-User-* / X-Tenant-* / X-Auth-Type headers into a typed request context, with optional proxy shared-secret verification
  • Registration client — would register the backend with the Manager, heartbeat, re-register on 404, and unregister on shutdown
  • Trigger handler — would receive Manager cron callbacks with timing-safe token checks and optional replay dedupe
  • Config client — would fetch per-tenant frontend configuration and language packs

How It Works

Proxy ──(X-User-* / X-Tenant-* / X-Proxy-Auth)──▶ your handler

                              productify.FromContext(r.Context())

                                        Context{User, Tenant, AuthType}

Backend ──register / heartbeat / config──▶ Manager
Manager ──trigger callbacks (Bearer token)──▶ Backend

Installation

bash
go get github.com/ProductifyFW/be-integrations/go        # core (net/http only)
go get github.com/ProductifyFW/be-integrations/go/gin    # optional Gin adapter

The core module has no dependencies beyond the standard library; the Gin adapter is a separate module so gin never enters your go.sum unless you use it.

Quick Start

go
package main

import (
    "context"
    "encoding/json"
    "net/http"
    "os"

    productify "github.com/ProductifyFW/be-integrations/go"
)

func main() {
    mw := productify.Middleware(productify.MiddlewareConfig{
        // strict auto-enables when the secret is set;
        // override with Strict: &trueVal / &falseVal if needed
        ProxySharedSecret: os.Getenv("PROXY_SHARED_SECRET"),
    })

    mux := http.NewServeMux()
    mux.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
        ctx, ok := productify.FromContext(r.Context())
        if !ok {
            http.Error(w, "unauthenticated", http.StatusUnauthorized)
            return
        }
        json.NewEncoder(w).Encode(map[string]any{
            "user":   ctx.User,   // ID, Email, Name, Username
            "tenant": ctx.Tenant, // ID, Name
        })
    })

    http.ListenAndServe(":8080", mw(mux))
}

Identity Middleware

go
func Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler

type MiddlewareConfig struct {
    ProxySharedSecret string            // verify X-Proxy-Auth (constant-time); "" disables
    Strict            *bool             // default: false, auto-true when secret is set
    OnMissingHeaders  func(*http.Request)
}

func FromContext(ctx context.Context) (Context, bool)

type Context struct {
    User     User   // from X-User-ID / -Email / -Name / -Username
    Tenant   Tenant // from X-Tenant-ID / -Name
    AuthType string // from X-Auth-Type: "oauth" | "api-key" | ...
    // TenantRole / TenantPermissions are set when the application
    // opts into role forwarding in the Manager
    TenantRole        *string
    TenantPermissions []string
}

Behavior:

  • When ProxySharedSecret is set, X-Proxy-Auth is verified with a constant-time compare before any identity header is read — 401 on mismatch
  • Strict mode (production): missing or partial identity headers → 401 with a typed JSON error
  • Non-strict mode (bare local development): requests without headers pass through with no context — FromContext returns ok == false
  • Header values are never logged by the library

Backend Registration

go
backend, err := productify.Register(ctx, productify.RegisterConfig{
    ManagerURL:       os.Getenv("MANAGER_URL"),
    MachineUserToken: os.Getenv("MACHINE_USER_TOKEN"),
    ApplicationID:    os.Getenv("APPLICATION_ID"),
    CallbackURL:      os.Getenv("CALLBACK_URL"), // internal URL the Manager POSTs triggers to
    Version:          buildVersion,              // stored by the Manager
    HeartbeatEvery:   30 * time.Second,          // default
    RequestTimeout:   10 * time.Second,          // default
    OnHeartbeatLost: func(consecutive int) {
        slog.Warn("manager unreachable", "failures", consecutive)
    },
})
  • backend.ID() / backend.Token() return the live registration id and callback token (they can change after an automatic re-registration — always read them through these getters)
  • Heartbeats run in the background with jittered backoff; a 404 (Manager restarted or registration reaped) triggers transparent re-registration
  • backend.Close(ctx) stops the heartbeat and unregisters — call it from your shutdown path; the library installs no signal handlers and never calls os.Exit
  • Registration failure is yours to decide: the recommended pattern is non-fatal — serve traffic without triggers and retry in the background

Trigger Handling

go
mux.Handle("/productify/triggers", productify.TriggerHandler(productify.TriggerConfig{
    Token: func() string { return backend.Token() }, // live getter — survives re-registration
    Handlers: map[string]productify.TriggerFunc{
        "report-schedules": func(ctx context.Context, p productify.TriggerPayload) error {
            return enqueueReportRuns(ctx, p) // enqueue only — never do the work inline
        },
    },
    Dedupe: true, // skip redelivered (trigger_id, executed_at) pairs
}))
  • Bearer token verified with a constant-time compare
  • The Manager's dispatch timeout is 5 seconds — handlers must acknowledge fast and do real work asynchronously (a jobs table/queue)
  • With Dedupe, a redelivered run returns 200 without re-invoking the handler; failed handlers are not marked seen, so redelivery retries them
  • Multi-tenant fan-out: a trigger fires once per deployment; list the tenants that have your app enabled and enqueue per-tenant work:
go
tenants, err := client.ApplicationTenants(ctx, appID) // GET /api/machine/application/:id/tenants
for _, t := range tenants {
    enqueue(jobFor(t.ID))
}

Config Client

go
client := productify.ManagerClient{
    BaseURL: os.Getenv("MANAGER_URL"),
    Token:   os.Getenv("MACHINE_USER_TOKEN"),
}

cfg, err := client.FrontendConfigForTenant(ctx, appID, tenantID)
// cfg.EnabledModules []ModuleRef{Name, Version}
// cfg.AvailableLocales, cfg.DefaultTheme, cfg.KeyValuePairs

pack, err := client.LanguagePack(ctx, appID, "hu") // for backend-rendered content (emails)

The library does not cache — cache per (application, tenant) with a short TTL in your own store (Redis, in-memory) as fits your app.

Gin Adapter

go
import ginproductify "github.com/ProductifyFW/be-integrations/go/gin"

r := gin.New()
r.Use(ginproductify.Middleware(productify.MiddlewareConfig{
    ProxySharedSecret: os.Getenv("PROXY_SHARED_SECRET"),
}))

r.GET("/api/profile", func(c *gin.Context) {
    ctx, _ := ginproductify.FromContext(c)
    c.JSON(200, gin.H{"user": ctx.User, "tenant": ctx.Tenant})
})

r.POST("/productify/triggers", ginproductify.TriggerHandler(triggerCfg))

Testing with managertest

A fake Manager ships with the library, so your suites never need a live platform:

go
import "github.com/ProductifyFW/be-integrations/go/managertest"

func TestRegistrationLifecycle(t *testing.T) {
    m := managertest.New(t) // httptest server: register/heartbeat/unregister/config

    backend, err := productify.Register(ctx, productify.RegisterConfig{
        ManagerURL: m.URL, MachineUserToken: "dev", ApplicationID: "app-1",
        CallbackURL: "http://127.0.0.1:0/triggers",
    })
    // scriptable failures:
    m.FailHeartbeatsWith(404)     // exercises re-registration
    m.RespondWithHTML()           // exercises the "behind the proxy?" guard

    require.Eventually(t, func() bool { return m.HeartbeatCount() > 0 }, ...)
}

The same scenarios run against @productifyfw/node-express (via @productifyfw/testing) from shared fixtures — the two SDKs are kept behavior-identical.

Best Practices

  • Run strict + PROXY_SHARED_SECRET in production; the backend must be reachable only through the proxy's network
  • Treat header identity as authentication; authorization (what the user may do inside your app) is your job — query Context.TenantPermissions only if your app opted into role forwarding
  • Enqueue trigger work, never execute inline; make jobs idempotent (redelivery is expected)
  • Read backend.Token() lazily (function form) anywhere the callback token is needed

Troubleshooting

401 on every request

Strict mode is on (secret configured) but requests reach the backend without proxy headers — you're bypassing the proxy. For bare local development you would unset PROXY_SHARED_SECRET and send the headers yourself; note there is no pfy dev proxy command (see Known Issues).

"expected JSON, got HTML — are you behind the proxy?"

The MANAGER_URL points at the proxy's public hostname instead of the Manager's internal address; registration calls must go directly to the Manager (machine-user authenticated).

Triggers never arrive

Check backend.ID() registered successfully, the CallbackURL is reachable from the Manager (callbacks bypass the proxy), and the trigger's run_key matches a key in Handlers.

See Also