Skip to content

Backend Integration - @productifyfw/node-express ​

Use this library when your backend is an Express app running behind the Productify proxy. It reads the signed-in user and tenant from the headers the proxy adds, registers your backend with the Manager so it can receive scheduled trigger callbacks, and verifies those callbacks.

Table of Contents ​

Overview ​

The @productifyfw/node-express library provides Express middleware and utilities for:

  • Authentication: extract and validate Productify headers from requests
  • Backend registration: register your service with the Manager
  • Trigger execution: handle scheduled jobs and callbacks from the Manager
  • TypeScript types for every surface

How It Works ​

Authentication flow: the Productify proxy adds authentication headers to all requests after validating the authentication token:

  • X-User-ID, X-User-Email, X-User-Name, X-User-Username
  • X-Tenant-ID, X-Tenant-Name
  • X-Auth-Type

Backend registration flow: your service registers with ProductifyFW Manager and sends periodic heartbeat pings to maintain active status. Manager can then trigger scheduled jobs via callbacks.

Backend Service → Manager (registration)
Backend Service → Manager (heartbeat every 30s)
Manager → Backend Service (trigger callback)

Installation ​

From GitHub Packages ​

The packages are published to the GitHub Packages npm registry. Create or update .npmrc in your project root, pointing the @productifyfw scope at it and adding a GitHub token (GitHub Packages requires a token to install, even for public packages). The current release is @productifyfw/node-express 1.0.0:

ini
@productifyfw:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

Install the package:

bash
npm install @productifyfw/node-express
# or
pnpm add @productifyfw/node-express
# or
yarn add @productifyfw/node-express

Peer dependency: Express 4.x or 5.x.

Quick Start ​

Basic Authentication ​

typescript
import express from "express";
import {
  productifyMiddleware,
  requireProductify,
} from "@productifyfw/node-express";

const app = express();

// Add Productify middleware globally
app.use(
  productifyMiddleware({
    applicationId: "my-app-id",
    strict: true,
  })
);

// Access user context in routes
app.get("/api/profile", requireProductify, (req, res) => {
  const { user, tenant } = req.productify!;
  res.json({
    userId: user.id,
    userName: user.name,
    userEmail: user.email,
    tenantId: tenant.id,
    tenantName: tenant.name,
  });
});

app.listen(3000);

With Backend Registration ​

typescript
import express from "express";
import {
  productifyMiddleware,
  registerBackend,
  createTriggerHandler,
  type BackendRegistrationManager,
} from "@productifyfw/node-express";

const app = express();

app.use(express.json());
app.use(productifyMiddleware({ applicationId: "order-service" }));

let backend: BackendRegistrationManager | null = null;

// Trigger callback endpoint. `token` is a function so it always reads the
// current callback token; until registration completes it returns undefined
// and the handler answers 503 instead of running unauthenticated.
app.post(
  "/triggers/callback",
  createTriggerHandler({
    token: () => backend?.token,
    handlers: {
      "process-orders": async (payload) => {
        console.log(`Processing orders at ${payload.executed_at}`);
        // Your job logic here
      },
    },
  })
);

async function start() {
  app.listen(3000, async () => {
    // Register with Manager
    backend = await registerBackend({
      managerUrl: "http://localhost:8080",
      machineUserToken: process.env.MACHINE_USER_TOKEN!,
      applicationId: process.env.APPLICATION_ID!,
      callbackUrl: "http://localhost:3000/triggers/callback",
      heartbeatInterval: 30000,
    });

    console.log(`Backend registered: ${backend.backendId}`);
  });
}

// registerBackend() already installs SIGINT/SIGTERM/beforeExit handlers that
// unregister on shutdown (see registerSignalHandlers below), so nothing else
// is needed here.

start();

Authentication Middleware ​

Middleware ​

productifyMiddleware(options?) ​

Creates the main Productify middleware that extracts headers and populates req.productify.

Options:

typescript
interface ProductifyMiddlewareOptions {
  /** Application ID (added to context if provided) */
  applicationId?: string;

  /** Shared secret the proxy sends as X-Proxy-Auth; when set, requests without it get 401 and strict defaults to true */
  proxySharedSecret?: string;

  /** Require all user/tenant fields; non-strict still builds a partial context from whatever is present (default: false, or true when proxySharedSecret is set) */
  strict?: boolean;

  /** Custom header prefix (default: 'X-') */
  headerPrefix?: string;

  /** Error handler for missing headers */
  onMissingHeaders?: (req: Request) => void;
}

Example:

typescript
app.use(
  productifyMiddleware({
    applicationId: "my-app",
    onMissingHeaders: (req) => {
      console.warn("Missing Productify headers:", req.path);
    },
  })
);

Behavior:

  • Non-strict (default): strict defaults to false. Missing or partial user/tenant headers still produce a req.productify context, with the missing fields as empty strings, so the middleware degrades when it is not running behind the Productify proxy (local dev). Pass strict: true to require all user and tenant headers instead; when any is missing, req.productify stays undefined.
  • proxySharedSecret: prove the request came through the proxy. When set, the middleware requires a matching X-Proxy-Auth header (constant-time compare) before reading any identity header, returns 401 on a missing or wrong secret, and auto-enables strict unless strict is set explicitly. Configure the same secret on the proxy and the backend.
typescript
app.use(
  productifyMiddleware({
    applicationId: "my-app-id",
    proxySharedSecret: process.env.PROXY_SHARED_SECRET, // strict auto-enables
  })
);

Set proxySharedSecret unless the port is proxy-only

Without it, the middleware reads X-User-* / X-Tenant-* unconditionally and cannot tell a proxy-forwarded request from one crafted by any client that can reach the port: anyone who reaches your app directly can forge the identity headers. Either set proxySharedSecret (matching the proxy's) or bind the app to a private interface reachable only from the proxy. See Known Issues.

requireProductify ​

Middleware that returns 401 unless the request carries a non-empty user.id, not merely whether req.productify is truthy. Under the non-strict default, a request with zero identity headers still produces a fully-built context with every field '', so req.productify is always truthy; requireProductify checks user.id specifically (via an internal hasAuthenticatedIdentity check) to catch that case. tenant.id and user.email are deliberately not required: a user can be authenticated before selecting a tenant, and machine users authenticate without an email.

This proves only that a non-empty X-User-ID was presented on the request, not that it was cryptographically verified; see the proxy-authentication warning above. In strict mode, behavior is unchanged: req.productify stays undefined when headers are missing/partial, so the plain truthiness check was already sufficient there.

Use this to protect routes that require authentication.

Example:

typescript
app.get("/api/protected", requireProductify, (req, res) => {
  // req.productify is guaranteed here
  res.json(req.productify.user);
});

Response (when not authenticated):

json
{
  "error": "Unauthorized",
  "message": "Productify authentication required"
}

Helper Functions ​

getUser(req) ​

Extract user from request.

typescript
import { getUser } from "@productifyfw/node-express";

app.get("/api/user", (req, res) => {
  const user = getUser(req);
  if (!user) {
    return res.status(401).json({ error: "Not authenticated" });
  }
  res.json(user);
});

Returns: ProductifyUser | null

getTenant(req) ​

Extract tenant from request.

typescript
import { getTenant } from "@productifyfw/node-express";

app.get("/api/tenant", (req, res) => {
  const tenant = getTenant(req);
  res.json(tenant);
});

Returns: ProductifyTenant | null

getContext(req) ​

Get full Productify context.

typescript
import { getContext } from "@productifyfw/node-express";

app.get("/api/context", (req, res) => {
  const context = getContext(req);
  res.json(context);
});

Returns: ProductifyContext | null

Backend Registration ​

Register your backend service with ProductifyFW Manager to enable trigger execution. The registration returns a token that Manager uses to authenticate trigger callbacks sent directly to your service via internal IP (bypassing the proxy).

registerBackend() ​

typescript
const backend = await registerBackend({
  managerUrl: "http://localhost:8080",
  machineUserToken: process.env.MACHINE_USER_TOKEN!,
  applicationId: process.env.APPLICATION_ID!,
  callbackUrl: "http://internal-ip:3000/triggers/callback",
  heartbeatInterval: 30000, // 30 seconds
  onError: (error) => {
    console.error("Registration error:", error);
  },
});

console.log(`Registered with ID: ${backend.backendId}`);
console.log(`Token: ${backend.token}`); // Use for trigger authentication

The callbackUrl must be an internal IP address or hostname reachable from the Manager, not through the proxy. Trigger callbacks bypass the proxy for direct communication.

Parameters:

OptionTypeRequiredDescription
managerUrlstringYesURL of ProductifyFW Manager
machineUserTokenstringYesAuthentication token for machine user
applicationIdstringYesID of the application to register the backend for
callbackUrlstringYesInternal URL where Manager will send trigger callbacks
heartbeatIntervalnumberNoHeartbeat interval in ms (default: 30000)
autoHeartbeatbooleanNoStart heartbeat automatically after registration (default: true)
onErrorfunctionNoError callback for registration/heartbeat failures
versionstringNoYour app's version, stored by the Manager on the registration. Omitted from the request when not set
requestTimeoutMsnumberNoPer-request timeout in ms for register/heartbeat/unregister (default: 10000)
onHeartbeatLostfunctionNoCalled with the running count of consecutive heartbeat failures
registerSignalHandlersbooleanNoInstall SIGINT/SIGTERM/beforeExit cleanup handlers (default: true)

Cleanup handlers and timeouts

registerBackend installs SIGINT, SIGTERM, and beforeExit handlers that unregister the backend on shutdown. This is on by default but can be disabled with registerSignalHandlers: false (e.g. in tests, or when your process owns signal handling).

Every outbound request (register / heartbeat / unregister) has a 10 s default timeout, overridable via requestTimeoutMs. Consecutive heartbeat failures are reported through onHeartbeatLost (with the running failure count) in addition to onError.

Returns: BackendRegistrationManager

typescript
interface BackendRegistrationManager {
  backendId: string; // Backend ID from Manager
  token: string; // Token for authenticating trigger callbacks
  environmentId: string; // Environment this backend registered for (from environment_id)
  listEnabledTenants: (applicationId?: string) => Promise<TenantRef[]>; // tenants the app is enabled for
  stopHeartbeat: () => void; // Stop heartbeat pings
  unregister: () => Promise<void>; // Unregister from Manager
}

Token Exchange Flow ​

  1. Registration: Your service calls registerBackend() with machine user token
  2. Token Issued: Manager returns backendId and token
  3. Store Token: Your service stores the token for validating callbacks
  4. Trigger Callback: Manager sends POST to callbackUrl with Authorization: Bearer {token} header
  5. Validation: Your service validates the token before executing trigger

Graceful Shutdown ​

registerBackend() installs SIGINT, SIGTERM and beforeExit handlers that stop the heartbeat and unregister. If you pass registerSignalHandlers: false because your process owns signal handling, do the same yourself:

typescript
let backend: BackendRegistrationManager | null = null;

async function shutdown() {
  console.log("Shutting down gracefully...");

  if (backend) {
    backend.stopHeartbeat();
    await backend.unregister();
    console.log("Backend unregistered");
  }

  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Trigger Handling ​

Handle scheduled jobs and callbacks from ProductifyFW Manager. Trigger callbacks are sent directly to your internal IP, bypassing the proxy, and authenticated using the token from backend registration.

createTriggerHandler() ​

Creates Express middleware that validates the Bearer token and routes trigger callbacks to handler functions:

typescript
let backend: BackendRegistrationManager | null = null;

// Register backend first
backend = await registerBackend({
  managerUrl: "http://localhost:8080",
  machineUserToken: process.env.MACHINE_USER_TOKEN!,
  applicationId: process.env.APPLICATION_ID!,
  callbackUrl: "http://10.0.1.5:3000/triggers/callback", // Internal IP
});

// Create trigger handler with token validation
app.post(
  "/triggers/callback",
  createTriggerHandler({
    token: backend.token, // Token from registration
    handlers: {
      "daily-report": async (payload) => {
        console.log(`Generating report at ${payload.executed_at}`);
        await generateReport();
      },
      "cleanup-data": async (payload) => {
        console.log(`Cleaning up data for trigger ${payload.trigger_id}`);
        await cleanupOldData();
      },
    },
    defaultHandler: async (payload) => {
      console.warn(`No handler for: ${payload.run_key}`);
    },
    onError: (error, payload) => {
      console.error(`Trigger ${payload.trigger_id} failed:`, error);
    },
  })
);

Parameters:

OptionTypeRequiredDescription
tokenTokenProviderRecommendedToken from backend registration for validating the callback Bearer token. Accepts string | (() => string | undefined | Promise<string | undefined>) | Promise<string>; all forms are resolved per request. Prefer the deferred function form () => backend.token (see below)
handlersRecord<string, TriggerHandler>YesMap of run_key to handler function (sync or async)
defaultHandlerTriggerHandlerNoFallback for unknown run_keys
onErrorErrorHandlerNoError callback for failed executions
dedupebooleanNoSkip re-running triggers already seen (keyed by trigger_id + executed_at). Default: false
dedupeCacheSizenumberNoMax entries in the dedupe cache (default: 1000)
onDuplicatefunctionNoCalled with the payload when a duplicate delivery is skipped

Pass the token as a deferred provider, not a bare string

The token option accepts a provider (string, a () => string | undefined | Promise<string | undefined> function, or a Promise<string>), and every form is resolved per request. The library's own example uses token: () => backend.token.

Prefer the function form. The callback token rotates: when a heartbeat gets a 404, the manager re-registers the backend and issues a new token. A bare string captured at startup goes stale after that rotation and would then 401 every callback. () => backend.token reads the live token on each request and stays correct across re-registration.

When token is provided, the middleware compares the Authorization: Bearer {token} header from Manager against it before executing handlers, and returns 401 on a mismatch. Always use this in production. The comparison uses Node's constant-time timingSafeEqual. When token is omitted, callbacks are not authenticated at all.

Trigger Payload ​

Handler functions receive a payload object:

typescript
interface TriggerPayload {
  trigger_id: string; // Unique trigger ID
  run_key: string; // Handler key (e.g., "daily-report")
  executed_at: string; // RFC 3339 timestamp
  trigger_name?: string; // Human-readable trigger name
  cron_expression?: string; // Cron expression of the schedule
  metadata?: Record<string, unknown>; // Reserved; the current Manager never sends it
}

Responses from the handler middleware: 200 {"success": true, ...} after the handler ran; 200 {"success": true, "duplicate": true} when dedupe skipped a redelivery; 400 for a malformed payload; 401 for a missing or wrong Bearer token; 404 when no handler matches run_key and there is no defaultHandler; 503 "Backend not ready: awaiting registration" when the token provider returns undefined; 500 when the handler threw.

Handler example:

typescript
async function processOrders(payload: TriggerPayload): Promise<void> {
  const { trigger_id, run_key, executed_at } = payload;

  console.log(`[${run_key}] Trigger ${trigger_id} executing at ${executed_at}`);

  // Your business logic
  const orders = await getOrders();
  for (const order of orders) {
    await processOrder(order);
  }

  console.log(`[${run_key}] Completed processing ${orders.length} orders`);
}

Authentication Flow ​

mermaid
sequenceDiagram
    Client->>Proxy: Request + Auth Token
    Proxy->>Proxy: Validate Token
    Proxy->>Proxy: Extract user/tenant data
    Proxy->>Backend: Request + X-User-* headers
    Backend->>Middleware: Extract headers
    Middleware->>Route: req.productify populated
    Route->>Client: Response
  1. Client sends request with authentication token (JWT, OAuth, etc.)
  2. Proxy validates the token
  3. Proxy extracts user and tenant information
  4. Proxy forwards request with authentication headers
  5. Your Express middleware extracts headers into req.productify
  6. Your route handlers access authenticated context

Advanced Usage ​

Custom Error Handling ​

typescript
app.use(
  productifyMiddleware({
    strict: true,
    onMissingHeaders: (req) => {
      // Log missing headers
      console.error("Missing Productify headers for:", {
        path: req.path,
        method: req.method,
        ip: req.ip,
      });

      // Send to monitoring service
      monitoring.trackError("missing_auth_headers", {
        path: req.path,
      });
    },
  })
);

Non-Strict Mode ​

In non-strict mode (the default) the middleware will populate partial context even if some headers are missing:

typescript
app.use(
  productifyMiddleware({
    strict: false,
  })
);

app.get("/api/data", (req, res) => {
  // Some fields might be empty strings
  const userId = req.productify?.user.id || "anonymous";
  const userName = req.productify?.user.name || "Guest";

  res.json({ userId, userName });
});

Per-Route Authentication ​

typescript
// Public route - no authentication required
app.get("/api/public", (req, res) => {
  res.json({ message: "Public data" });
});

// Optional authentication
app.get("/api/optional", (req, res) => {
  if (req.productify) {
    res.json({
      message: "Authenticated",
      user: req.productify.user.name,
    });
  } else {
    res.json({ message: "Anonymous" });
  }
});

// Required authentication
app.get("/api/private", requireProductify, (req, res) => {
  res.json({ user: req.productify.user });
});

Permission Checking ​

Always validate permissions on the backend; never rely on client-side checks alone.

typescript
// Custom permission middleware
function requireRole(role: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (!req.productify) {
      return res.status(401).json({ error: "Unauthorized" });
    }

    // Check user role from database or external service
    const hasRole = await checkUserRole(req.productify.user.id, role);

    if (!hasRole) {
      return res.status(403).json({ error: "Forbidden" });
    }

    next();
  };
}

// Usage
app.get("/api/admin", requireProductify, requireRole("admin"), (req, res) => {
  res.json({ message: "Admin area" });
});

Tenant Isolation ​

Ensure users can only access data from their tenant:

typescript
app.get("/api/documents/:id", requireProductify, async (req, res) => {
  const { tenant } = req.productify!;
  const documentId = req.params.id;

  // Fetch document with tenant check
  const document = await db.documents.findOne({
    id: documentId,
    tenantId: tenant.id, // Ensure document belongs to user's tenant
  });

  if (!document) {
    return res.status(404).json({ error: "Document not found" });
  }

  res.json(document);
});

Testing ​

Testing with Headers ​

bash
# Test protected endpoint with Productify headers
curl http://localhost:3000/api/profile \
  -H "X-User-ID: user-123" \
  -H "X-User-Email: alice@example.com" \
  -H "X-User-Name: Alice Smith" \
  -H "X-User-Username: alice" \
  -H "X-Tenant-ID: tenant-abc" \
  -H "X-Tenant-Name: ACME Corp"

Testing Trigger Callbacks ​

bash
# Simulate a trigger callback from Manager with Bearer token
curl -X POST http://localhost:3000/triggers/callback \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token-from-registration" \
  -d '{
    "trigger_id": "trigger-123",
    "run_key": "daily-report",
    "executed_at": "2025-12-03T10:00:00Z"
  }'

Fake Manager: @productifyfw/testing ​

The @productifyfw/testing package ships an in-process fake Manager for registration, heartbeat, and trigger tests, so your suites never need a live platform. It serves the real Manager routes over a stdlib http.Server on an ephemeral port, so the library's actual fetch path is exercised.

typescript
import { FakeManager } from "@productifyfw/testing";
import { registerBackend } from "@productifyfw/node-express";

const manager = await FakeManager.start(); // static async factory (private constructor)
try {
  const backend = await registerBackend({
    managerUrl: manager.url, // getter
    machineUserToken: "dev",
    applicationId: "app-1",
    callbackUrl: "http://127.0.0.1:0/triggers",
    registerSignalHandlers: false, // don't attach process signal handlers in tests
  });

  manager.setHeartbeat404(true);         // exercises re-registration
  manager.setFailure("html");            // exercises the "behind the proxy?" guard
  // manager.setFailure("unauthorized"); // exercises the 401 path
  expect(manager.heartbeatCount).toBeGreaterThan(0); // getter, not a method

  // Deliver a trigger callback exactly as the Manager would (POST to the
  // backend's callback_url, carrying the issued token as a Bearer credential):
  await manager.dispatchTrigger({
    trigger_id: "t1",
    run_key: "daily",
    executed_at: "2025-12-03T10:00:00Z",
  });

  await backend.unregister();
} finally {
  await manager.close();
}

The Go library's managertest package implements the same fake, so both SDKs are tested against identical Manager behavior.

Unit Testing ​

typescript
import request from "supertest";
import express from "express";
import {
  productifyMiddleware,
  requireProductify,
} from "@productifyfw/node-express";

describe("Productify Authentication", () => {
  const app = express();
  app.use(productifyMiddleware({ applicationId: "test", strict: true }));
  app.get("/api/protected", requireProductify, (req, res) => {
    res.json(req.productify);
  });

  it("should require authentication headers", async () => {
    const response = await request(app).get("/api/protected");
    expect(response.status).toBe(401);
  });

  it("should extract user context", async () => {
    const response = await request(app)
      .get("/api/protected")
      .set("X-User-ID", "user-123")
      .set("X-User-Email", "test@example.com")
      .set("X-User-Name", "Test User")
      .set("X-User-Username", "testuser")
      .set("X-Tenant-ID", "tenant-123")
      .set("X-Tenant-Name", "Test Tenant");

    expect(response.status).toBe(200);
    expect(response.body.user.id).toBe("user-123");
    expect(response.body.tenant.id).toBe("tenant-123");
  });
});

Mock Request Context ​

typescript
import { describe, it, expect } from "vitest";
import { getUser } from "@productifyfw/node-express";
import type { Request } from "express";

describe("User Routes", () => {
  it("should get user from request", () => {
    const mockReq = {
      productify: {
        user: {
          id: "user-123",
          email: "test@example.com",
          name: "Test User",
          username: "testuser",
        },
        tenant: {
          id: "tenant-456",
          name: "Test Tenant",
        },
      },
    } as Request;

    const user = getUser(mockReq);
    expect(user?.id).toBe("user-123");
  });
});

Integration Tests ​

typescript
import request from "supertest";
import express from "express";
import {
  productifyMiddleware,
  requireProductify,
} from "@productifyfw/node-express";

describe("API Integration", () => {
  const app = express();
  app.use(productifyMiddleware({ strict: true }));

  app.get("/api/profile", requireProductify, (req, res) => {
    res.json(req.productify.user);
  });

  it("should return 401 without headers", async () => {
    const response = await request(app).get("/api/profile");
    expect(response.status).toBe(401);
  });

  it("should return user with valid headers", async () => {
    const response = await request(app)
      .get("/api/profile")
      .set("X-User-ID", "user-123")
      .set("X-User-Email", "test@example.com")
      .set("X-User-Name", "Test User")
      .set("X-User-Username", "testuser")
      .set("X-Tenant-ID", "tenant-456")
      .set("X-Tenant-Name", "Test Tenant");

    expect(response.status).toBe(200);
    expect(response.body.id).toBe("user-123");
  });
});

Best Practices ​

  1. Always use requireProductify for protected endpoints
  2. Set strict: true in production if route handlers assume tenant/user.email are always populated; requireProductify itself enforces authentication (a non-empty user.id) in both modes
  3. Implement graceful shutdown to clean up backend registrations
  4. Use environment variables for configuration (MANAGER_URL, MACHINE_USER_TOKEN)
  5. Log trigger execution for debugging and monitoring
  6. Handle errors in trigger handlers to prevent crashes
  7. Use TypeScript for type safety and better developer experience
  8. Test with actual headers during development

Troubleshooting ​

Missing Headers Error ​

Problem: Receiving 401 Unauthorized errors

Solution:

  • Ensure Productify Proxy is injecting headers
  • For testing, manually add all required headers
  • Check strict option in middleware configuration

req.productify is undefined ​

Causes:

  • Application not accessed through Productify proxy
  • Proxy authentication middleware not configured
  • Headers not being forwarded correctly
  • Middleware not added to Express app

Solutions:

  1. Verify proxy configuration in Caddyfile
  2. Check that authentication middleware is enabled
  3. Ensure requests go through the proxy
  4. Verify middleware is added: app.use(productifyMiddleware())
  5. Check middleware is added before routes

Headers Missing in Strict Mode ​

If you're getting undefined context in strict mode:

  1. Check all required headers are present:

    • X-User-ID, X-User-Email, X-User-Name, X-User-Username
    • X-Tenant-ID, X-Tenant-Name
  2. Use onMissingHeaders callback to debug:

typescript
app.use(
  productifyMiddleware({
    strict: true,
    onMissingHeaders: (req) => {
      console.log("Missing headers for:", req.path);
      console.log("Available headers:", req.headers);
    },
  })
);
  1. Check proxy logs for authentication failures

Backend Registration Fails ​

Problem: registerBackend() throws error

Solution:

  • Verify managerUrl is correct and accessible
  • Check machineUserToken is valid
  • Ensure ProductifyFW Manager is running and reachable

Trigger Callbacks Not Working ​

Problem: Triggers not executing

Solution:

  • Verify backend registration succeeded (check console logs)
  • Ensure callbackUrl is accessible from Manager
  • Check run_key matches handler keys in createTriggerHandler()
  • Review Manager logs for callback errors

TypeScript Errors ​

Problem: Type errors with req.productify

Solution:

  1. Ensure @productifyfw/node-express is installed
  2. Check @types/express is installed
  3. The package extends the Express Request type automatically (global augmentation), so req.productify is typed as ProductifyContext | undefined
  4. Restart the TypeScript server if needed

Complete Example ​

See the full example project with orders, payments, and reports:

bash
cd be-integrations/packages/node-express-example
pnpm install
pnpm dev

The example includes:

  • [OK] Multi-tenant authentication
  • [OK] RESTful API (orders, payments, reports)
  • [OK] Backend registration with heartbeat
  • [OK] Trigger handlers for scheduled jobs
  • [OK] Graceful shutdown
  • [OK] TypeScript with strict mode

The example lives at be-integrations/packages/node-express-example in the repository and is published as the public image ghcr.io/productifyfw/example-app:0.1.0. It listens on port 3000 and reads MANAGER_URL, SERVICE_URL (the callback URL the Manager reaches it at), MACHINE_USER_TOKEN and APPLICATION_ID; without a token it serves requests without registering a backend. The Compose bundle starts it with --profile example.

Type Reference ​

typescript
interface ProductifyContext {
  user: ProductifyUser;
  tenant: ProductifyTenant;
  application?: ProductifyApplication;
  authType?: AuthType;
  // Present only when the application opted into role forwarding in the Manager
  tenantRole?: string;
  tenantPermissions?: string[];
  picture?: string;
}

interface ProductifyUser {
  id: string;
  email: string;
  name: string;
  username: string;
}

interface ProductifyTenant {
  id: string;
  name: string;
}

interface ProductifyApplication {
  id: string;
}

type AuthType = "jwt" | "oauth" | "basic" | "api-key" | string;

See Also ​