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-Typeheaders 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)──▶ BackendInstallation
go get github.com/ProductifyFW/be-integrations/go # core (net/http only)
go get github.com/ProductifyFW/be-integrations/go/gin # optional Gin adapterThe 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
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
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
ProxySharedSecretis set,X-Proxy-Authis 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 —
FromContextreturnsok == false - Header values are never logged by the library
Backend Registration
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 callsos.Exit- Registration failure is yours to decide: the recommended pattern is non-fatal — serve traffic without triggers and retry in the background
Trigger Handling
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:
tenants, err := client.ApplicationTenants(ctx, appID) // GET /api/machine/application/:id/tenants
for _, t := range tenants {
enqueue(jobFor(t.ID))
}Config Client
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
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:
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_SECRETin 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.TenantPermissionsonly 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.