Skip to content

Backend Integration - Go ​

Use this library when your backend is written in Go and runs behind the Productify proxy. It reads the user and tenant the proxy puts in request headers, registers your backend with the Manager so it can receive scheduled triggers, verifies those trigger callbacks, and fetches per-tenant configuration.

It ships as a dependency-free net/http core (module github.com/ProductifyFW/be-integrations/go) plus a Gin adapter submodule (github.com/ProductifyFW/be-integrations/go/gin). It speaks the same wire protocol as @productifyfw/node-express; a shared parity test suite (go/parity_test.go) keeps the request and response shapes identical.

Versioning

The current release tags are go/v0.2.0 (core) and go/gin/v0.2.0 (Gin adapter). go get without a version resolves to the latest tag.

Overview ​

The library covers the four backend integration surfaces:

  • Identity middleware: extracts the proxy-injected X-User-* / X-Tenant-* / X-Auth-Type headers into a typed request context, with optional proxy shared-secret verification
  • Registration client: registers the backend with the Manager, heartbeats, re-registers on 404, and unregisters on shutdown
  • Trigger handler: receives Manager cron callbacks with timing-safe token checks and optional replay dedupe
  • Config client: fetches 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@v0.2.0        # core (net/http only)
go get github.com/ProductifyFW/be-integrations/go/gin@v0.2.0    # 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 / Picture are set only when the
    // application opts into role forwarding in the Manager (nil otherwise)
    TenantRole        *string
    TenantPermissions []string
    Picture           *string // from X-User-Picture (Pocket ID picture claim)
}

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 JSON error body ({"error":"Unauthorized","message":"Productify identity headers required"}). The four X-User-* headers are required; the tenant headers may be empty (a user can be signed in before picking a tenant)
  • 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
  • Difference from the Node library: in strict mode Node's productifyMiddleware lets the request through with req.productify unset and relies on requireProductify for the 401; the Go middleware answers 401 itself

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, so 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 own 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, so 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 := backend.ListEnabledTenants(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.

The Manager currently rejects these calls

ManagerClient sends only Authorization: Bearer <machine-user-token>. On the Manager, GET /api/frontend-config/... and GET /language-packs/... are registered in the proxy-only route group, which checks the X-Proxy-Auth shared secret and never looks at a Bearer token. Against a real Manager the config client therefore gets 401 from the proxy trust boundary; it works only against managertest. Until the Manager exposes a machine-user route for configuration, read per-tenant settings another way (for example from your own database) or raise the gap with the platform team.

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.SetHeartbeat404(true)              // exercises re-registration
    m.SetFailure(managertest.FailHTML)   // 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, so the two SDKs are kept behavior-identical.

Best Practices ​

  • Run strict mode with 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: the proxy is being bypassed. For bare local development, unset PROXY_SHARED_SECRET and put pfy dev proxy in front of your backend, which injects the production header contract from a local identity file (see the dev command docs).

"expected JSON but got text/html" ​

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 ​