Skip to content

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

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-Productify-User-Id, X-Productify-User-Email, X-Productify-User-Name
  • X-Productify-Tenant-Id, X-Productify-Tenant-Name

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:

ini
@productifyfw:registry=https://npm.pkg.github.com

Install the package:

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

No Authentication Required

Public packages don't require authentication for installation.

Peer Dependencies:

  • 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" }));

// 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!,
      name: "Order Service",
      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:

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

  /** Require all user/tenant fields (default: true) */
  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",
    strict: true,
    onMissingHeaders: (req) => {
      console.warn("Missing Productify headers:", req.path);
    },
  })
);

Behavior:

  • Strict mode (default): Requires all user and tenant headers to be present
  • Non-strict mode: Allows partial data, missing fields will be empty strings
  • If headers are missing in strict mode, req.productify will be undefined

requireProductify

Middleware that returns 401 if req.productify is not available. 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!,
  name: "My Backend Service",
  callbackUrl: "http://internal-ip:3000/triggers/callback",
  description: "Handles background jobs and scheduled tasks",
  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

Important: 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:

OptionTypeRequiredDescription
managerUrlstringYesURL of ProductifyFW Manager
machineUserTokenstringYesAuthentication token for machine user
namestringYesDisplay name for backend service
callbackUrlstringYesInternal URL where Manager will send trigger callbacks
descriptionstringNoDescription of backend service
heartbeatIntervalnumberNoHeartbeat interval in ms (default: 30000)
onErrorfunctionNoError callback for registration/heartbeat failures

Returns: BackendRegistrationManager

typescript
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

  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

Always unregister on shutdown to prevent orphaned backend entries:

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!,
  name: "My Service",
  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
tokenstringRecommendedToken from backend registration (validates Bearer token in Authorization header)
handlersRecord<string, Handler>YesMap of run_key to handler function
defaultHandlerHandlerNoFallback for unknown run_keys
onErrorErrorHandlerNoError callback for failed executions

Security: When token is provided, the middleware validates the Authorization: Bearer {token} header from Manager before executing handlers. Always use this in production.

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; // ISO timestamp
}

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`);
}

API Reference

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 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

Important: 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-Productify-User-Id: user-123" \
  -H "X-Productify-User-Email: alice@example.com" \
  -H "X-Productify-User-Name: Alice Smith" \
  -H "X-Productify-Tenant-Id: tenant-abc" \
  -H "X-Productify-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"
  }'

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" }));
  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-Productify-User-Id", "user-123")
      .set("X-Productify-User-Email", "test@example.com")
      .set("X-Productify-User-Name", "Test User")
      .set("X-Productify-Tenant-Id", "tenant-123")
      .set("X-Productify-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 to enforce authentication
  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

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:

typescript
// Import types
import type { ProductifyRequest } from "@productifyfw/node-express";

// Use typed request
app.get("/api/data", requireProductify, (req: ProductifyRequest, res) => {
  const { user, tenant } = req.productify; // Now typed correctly
});

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

Repository: node-express-example

  1. Use Strict Mode in Production: Enable strict: true to ensure all required headers are present
  2. Validate Permissions on Backend: Never trust client-side permission checks
  3. Implement Tenant Isolation: Always filter data by tenant ID
  4. Log Authentication Failures: Monitor and alert on missing headers
  5. Error Handling: Implement proper error handling for authentication failures
  6. Type Safety: Use TypeScript for type-safe access to context
  7. Database Queries: Always include tenant ID in database queries
  8. Audit Logging: Log important actions with user and tenant context

Troubleshooting

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

TypeScript Errors

If req.productify shows TypeScript errors:

  1. Ensure @productifyfw/node-express is installed
  2. Check @types/express is installed
  3. The package extends Express types automatically
  4. Restart TypeScript server if needed

Type Reference

typescript
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;

See Also