Backend Integration - @productifyfw/node-express
Complete guide for integrating Express.js backends with ProductifyFW including authentication, backend registration, and trigger handling.
Table of Contents
- Overview
- Installation
- Quick Start
- Authentication Middleware
- Backend Registration
- Trigger Handling
- Authentication Flow
- Advanced Usage
- Testing
- Best Practices
- Troubleshooting
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 ProductifyFW Manager
- Trigger Execution: Handle scheduled jobs and callbacks from Manager
- Type Safety: Full TypeScript support with strict typing
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-UsernameX-Tenant-ID,X-Tenant-NameX-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 (Public)
ProductifyFW packages are published as public packages on GitHub Packages. Configure npm:
Create or update .npmrc in your project root:
@productifyfw:registry=https://npm.pkg.github.comInstall the package:
npm install @productifyfw/node-express
# or
pnpm add @productifyfw/node-express
# or
yarn add @productifyfw/node-expressNo Authentication Required
Public packages don't require authentication for installation.
Peer Dependencies:
- Express 4.x or 5.x
Quick Start
Basic Authentication
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
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" }));
// Trigger callback endpoint
app.post(
"/triggers/callback",
createTriggerHandler({
handlers: {
"process-orders": async (payload) => {
console.log(`Processing orders at ${payload.executed_at}`);
// Your job logic here
},
},
})
);
let backend: BackendRegistrationManager | null = null;
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}`);
});
}
// Graceful shutdown
process.on("SIGINT", async () => {
if (backend) {
backend.stopHeartbeat();
await backend.unregister();
}
process.exit(0);
});
start();Authentication Middleware
Middleware
productifyMiddleware(options?)
Creates the main Productify middleware that extracts headers and populates req.productify.
Options:
interface ProductifyMiddlewareOptions {
/** Application ID (added to context if provided) */
applicationId?: string;
/** Require all user/tenant fields; non-strict still builds a partial context from whatever is present (default: false) */
strict?: boolean;
/** Custom header prefix (default: 'X-') */
headerPrefix?: string;
/** Error handler for missing headers */
onMissingHeaders?: (req: Request) => void;
}Example:
app.use(
productifyMiddleware({
applicationId: "my-app",
onMissingHeaders: (req) => {
console.warn("Missing Productify headers:", req.path);
},
})
);Behavior:
- Non-strict (default):
strictdefaults tofalse. Missing or partial user/tenant headers still produce areq.productifycontext, with the missing fields as empty strings — this lets the middleware degrade gracefully when it isn't running behind the Productify proxy (e.g. local dev). Passstrict: trueto require all user and tenant headers instead; when any is missing,req.productifystaysundefined.
The middleware does not authenticate the proxy
There is no proxySharedSecret option and no X-Proxy-Auth verification. The middleware reads the identity headers unconditionally — it cannot tell a request forwarded by the proxy from one crafted by any client that can reach the port. Anyone who can reach your app directly can forge X-User-Id, X-Tenant-Id, and friends.
Until this is implemented, do not expose the application's port beyond the proxy: bind it to a private interface or restrict it at the network layer. 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.
Note 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:
app.get("/api/protected", requireProductify, (req, res) => {
// req.productify is guaranteed here
res.json(req.productify.user);
});Response (when not authenticated):
{
"error": "Unauthorized",
"message": "Productify authentication required"
}Helper Functions
getUser(req)
Extract user from request.
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.
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.
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()
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 authenticationImportant: The callbackUrl should be an internal IP address or hostname accessible from Manager, NOT through the proxy. Trigger callbacks bypass the proxy for direct communication.
Parameters:
| Option | Type | Required | Description |
|---|---|---|---|
managerUrl | string | Yes | URL of ProductifyFW Manager |
machineUserToken | string | Yes | Authentication token for machine user |
applicationId | string | Yes | ID of the application to register the backend for |
callbackUrl | string | Yes | Internal URL where Manager will send trigger callbacks |
heartbeatInterval | number | No | Heartbeat interval in ms (default: 30000) |
autoHeartbeat | boolean | No | Start heartbeat automatically after registration (default: true) |
onError | function | No | Error callback for registration/heartbeat failures |
Cleanup handlers are always installed
registerBackend unconditionally registers SIGINT, SIGTERM, and beforeExit handlers that unregister the backend — this is not opt-in and cannot be disabled.
There are no version, requestTimeoutMs, registerSignalHandlers, or onHeartbeatLost options; requests have no timeout, and heartbeat failures surface only through onError.
Returns: BackendRegistrationManager
interface BackendRegistrationManager {
backendId: string; // Backend ID from Manager
token: string; // Token for authenticating trigger callbacks
stopHeartbeat: () => void; // Stop heartbeat pings
unregister: () => Promise<void>; // Unregister from Manager
}Token Exchange Flow
- Registration: Your service calls
registerBackend()with machine user token - Token Issued: Manager returns
backendIdandtoken - Store Token: Your service stores the token for validating callbacks
- Trigger Callback: Manager sends POST to
callbackUrlwithAuthorization: Bearer {token}header - Validation: Your service validates the token before executing trigger
Graceful Shutdown
Always unregister on shutdown to prevent orphaned backend entries:
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:
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:
| Option | Type | Required | Description |
|---|---|---|---|
token | string | Recommended | Token from backend registration (validates Bearer token in Authorization header). Must be the token string itself — a function or promise is never resolved and would reject every callback with 401 |
handlers | Record<string, TriggerHandler> | Yes | Map of run_key to handler function (sync or async) |
defaultHandler | TriggerHandler | No | Fallback for unknown run_keys |
onError | ErrorHandler | No | Error callback for failed executions |
Security: 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. Note the comparison is a plain !== string compare, not a constant-time one. When token is omitted, callbacks are not authenticated at all.
Trigger Payload
Handler functions receive a payload object:
interface TriggerPayload {
trigger_id: string; // Unique trigger ID
run_key: string; // Handler key (e.g., "daily-report")
executed_at: string; // ISO timestamp
trigger_name?: string; // Human-readable trigger name
cron_expression?: string; // Cron expression of the schedule
metadata?: Record<string, unknown>; // Optional metadata
}Handler Example:
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
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- Client sends request with authentication token (JWT, OAuth, etc.)
- Proxy validates the token
- Proxy extracts user and tenant information
- Proxy forwards request with authentication headers
- Your Express middleware extracts headers into
req.productify - Your route handlers access authenticated context
Advanced Usage
Custom Error Handling
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, since strict defaults to false — the middleware will populate partial context even if some headers are missing:
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
// 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
Important: Always validate permissions on the backend, never rely on client-side checks alone.
// 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:
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
# 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
# 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 (planned)
Not yet implemented
@productifyfw/testing does not exist — be-integrations/ publishes only @productifyfw/node-express. Stub the Manager's HTTP endpoints yourself (e.g. with nock or a local Express app) until it ships. See Known Issues.
The intended design: for registration, heartbeat, and trigger tests, you would run against an in-process fake Manager instead of a live platform:
import { fakeManager } from "@productifyfw/testing";
import { registerBackend } from "@productifyfw/node-express";
const manager = await fakeManager(); // register/heartbeat/unregister/frontend-config
const backend = await registerBackend({
managerUrl: manager.url,
machineUserToken: "dev",
applicationId: "app-1",
callbackUrl: "http://127.0.0.1:0/triggers",
});
manager.failHeartbeatsWith(404); // exercises re-registration
manager.respondWithHTML(); // exercises the "behind the proxy?" guard
expect(manager.heartbeatCount()).toBeGreaterThan(0);
await manager.close();The Go library's managertest package implements the same fake from shared fixtures, so both SDKs are tested against identical Manager behavior.
Unit Testing
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
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
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
- Always use requireProductify for protected endpoints
- Set strict: true in production if route handlers assume
tenant/user.emailare always populated —requireProductifyitself enforces authentication (a non-emptyuser.id) in both modes - Implement graceful shutdown to clean up backend registrations
- Use environment variables for configuration (MANAGER_URL, MACHINE_USER_TOKEN)
- Log trigger execution for debugging and monitoring
- Handle errors in trigger handlers to prevent crashes
- Use TypeScript for type safety and better developer experience
- 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
strictoption 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:
- Verify proxy configuration in Caddyfile
- Check that authentication middleware is enabled
- Ensure requests go through the proxy
- Verify middleware is added:
app.use(productifyMiddleware()) - Check middleware is added before routes
Headers Missing in Strict Mode
If you're getting undefined context in strict mode:
Check all required headers are present:
- X-User-ID, X-User-Email, X-User-Name, X-User-Username
- X-Tenant-ID, X-Tenant-Name
Use
onMissingHeaderscallback to debug:
app.use(
productifyMiddleware({
strict: true,
onMissingHeaders: (req) => {
console.log("Missing headers for:", req.path);
console.log("Available headers:", req.headers);
},
})
);- Check proxy logs for authentication failures
Backend Registration Fails
Problem: registerBackend() throws error
Solution:
- Verify
managerUrlis correct and accessible - Check
machineUserTokenis 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
callbackUrlis accessible from Manager - Check
run_keymatches handler keys increateTriggerHandler() - Review Manager logs for callback errors
TypeScript Errors
Problem: Type errors with req.productify
Solution:
- Ensure
@productifyfw/node-expressis installed - Check
@types/expressis installed - The package extends the Express
Requesttype automatically (global augmentation), soreq.productifyis typed asProductifyContext | undefined - Restart the TypeScript server if needed
Complete Example
See the full example project with orders, payments, and reports:
cd be-integrations/packages/node-express-example
pnpm install
pnpm devThe 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
Repository: node-express-example
Type Reference
interface ProductifyContext {
user: ProductifyUser;
tenant: ProductifyTenant;
application?: ProductifyApplication;
authType?: AuthType;
}
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;