Skip to content

Frontend Integration Guide ​

The proxy puts a configuration object into window.__PRODUCTIFY__ for every browser application it serves; @productifyfw/core, @productifyfw/vue and @productifyfw/react read it. The core library guide has the full API and the pilet-verification details.

Table of Contents ​

Overview ​

The Productify frontend integration consists of two main parts:

  1. The proxy injects configuration data into window.__PRODUCTIFY__.
  2. @productifyfw/core reads it through typed accessors; @productifyfw/vue and @productifyfw/react bind it to the framework, and @productifyfw/dev is the Vite dev shim. See Framework Packages.

Installation ​

Configure npm to resolve @productifyfw packages from GitHub Packages by adding to .npmrc (a GitHub token is required to install from that registry):

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

Then install:

bash
npm install @productifyfw/core

Or with other package managers:

bash
yarn add @productifyfw/core
pnpm add @productifyfw/core

Add the framework binding you need; both layer over the same core client:

bash
npm install @productifyfw/vue      # or @productifyfw/react
npm install -D @productifyfw/dev   # Vite dev shim + test helper

Configuration Injection ​

How It Works ​

When your application is served through the Productify proxy, the HTML response is automatically modified to include a <script> tag that defines window.__PRODUCTIFY__:

html
<script id="productify-config" type="application/json">
  {
    "user": {
      "id": "user-uuid",
      "email": "user@example.com",
      "name": "User Name",
      "username": "username"
    },
    "tenant": {
      "id": "tenant-uuid",
      "name": "Tenant Name"
    },
    "available_tenants": [
      { "id": "tenant-uuid", "name": "Tenant Name" },
      { "id": "tenant-uuid-2", "name": "Other Org" }
    ],
    "application": {
      "id": "application-uuid"
    },
    "auth_type": "oauth",
    "logout_url": "/_tenant/logout",
    "config": {
      "enabled_modules": ["example-pilet@1.0.5"],
      "default_theme": "light",
      "default_locale": "en",
      "pilet_signing_keys": { "prod-2026-01": "<base64 raw Ed25519 public key>" },
      "communications": { "enabled": true },
      "key_value_pairs": [{ "key": "maxItemsPerPage", "value": "25" }]
    }
  }
</script>
<script>
  window.__PRODUCTIFY__ = JSON.parse(
    document.getElementById("productify-config").textContent
  );
</script>

The injection is placed before your application JavaScript loads, so the configuration is available immediately.

config is the merged configuration for the tenant named in tenant.id, fetched by the proxy from GET /api/frontend-config/:application_id/:tenant_id. On a dedicated vhost that tenant is the pinned one; on a shared vhost it is the tenant the user selected, so tenant and config always describe the same tenant. When a shared vhost has no tenant resolved yet, config carries project and application defaults only.

Configuration Structure ​

typescript
interface ProductifyFrontendConfig {
  // Current authenticated user (from X-User-* headers)
  user: {
    id: string;
    email: string;
    name: string;
    username: string;
    picture?: string; // opt-in: X-User-Picture (forward_role)
  };

  // Current tenant (from X-Tenant-* headers)
  tenant: {
    id: string;
    name: string;
  };

  // Tenants this user may act in for this app (id + name only).
  // Render your own in-app switcher; POST /_tenant to switch.
  available_tenants?: Array<{ id: string; name: string }>;

  // Opt-in membership context (forward_role, default off).
  // Gate on tenantPermissions (fixed vocabulary), not tenantRole (project-configurable name).
  tenantRole?: string;
  tenantPermissions?: string[];

  // Current application (configured in the proxy)
  application: {
    id: string;
  };

  // Authentication type (from X-Auth-Type header). The proxy emits the
  // snake_case `auth_type`; `authType` is the canonical key. Both are declared,
  // and ProductifyClient.getAuthType() reads authType first, then auth_type.
  // See /known-issues#auth-type-wire-key.
  authType?: string;
  auth_type?: string;

  // Standard "Sign out" target. Read it through pfy.getLogoutUrl() (or the
  // useLogoutUrl() hook/composable), which substitutes the proxy default
  // /_tenant/logout when the key is absent, so logout always clears both the
  // portal session and the tenant cookie.
  logout_url?: string;

  // Frontend configuration fetched from the Manager
  // (/api/frontend-config/:application_id/:tenant_id)
  config?: {
    available_modules?: string[]; // format: name@version
    enabled_modules?: string[]; // format: name@version
    available_themes?: string[];
    enabled_themes?: string[];
    default_theme?: string;
    available_locales?: string[];
    enabled_locales?: string[];
    default_locale?: string;
    // Pilet provenance trust anchor: keyId -> base64 raw Ed25519 public key.
    // Its presence marks a verification-enforcing deployment.
    pilet_signing_keys?: Record<string, string>;
    // Whether communications are on for this (tenant, application).
    communications?: { enabled: boolean };
    // Per-tenant pilet version pins from the config cascade: name -> version.
    pilets?: { pins?: Record<string, string> };
    // Feed URL published by `pfy dev proxy --pilet-bundle`; honoured by
    // loadPilets only when it is same-origin. Absent in production.
    pilet_feed?: string;
    key_value_pairs?: Array<{ key: string; value: string }>;
    [key: string]: unknown;
  };
}

Library Usage ​

Basic Setup ​

typescript
import { ProductifyClient } from "@productifyfw/core";

const pfy = new ProductifyClient();

// Check if configuration is available
if (!pfy.isConfigured()) {
  console.warn("Productify configuration not found");
}

Accessing Configuration ​

typescript
// Application and tenant identifiers
const appId = pfy.getApplicationId();
const tenantId = pfy.getTenantId();
const authType = pfy.getAuthType();

// User information
const user = pfy.getUser();
const userId = pfy.getUserId();
const userEmail = pfy.getUserEmail();

// Sign-out target (falls back to /_tenant/logout when the proxy injected none)
signOutLink.href = pfy.getLogoutUrl();

// Your own settings: key_value_pairs, string values
const kv = Object.fromEntries(pfy.getKeyValuePairs().map((p) => [p.key, p.value]));
const maxItems = Number(kv.maxItemsPerPage ?? 10);
const featureEnabled = kv.newCheckout === "true";

// Top-level config keys (the standard ones, or anything you inject yourself)
const defaultTheme = pfy.getConfig<string>("default_theme", { defaultValue: "light" });
const mode = pfy.getNestedConfig<string>("ui.theme.mode"); // only if `config.ui` exists

getConfig reads top-level config keys, not key_value_pairs

The Manager delivers the settings you define as config.key_value_pairs: [{ key, value }] with string values. getConfig(key), isConfigEnabled(key) and getNestedConfig(path) look up top-level keys of config and therefore return null/false for those settings. Use getKeyValuePairs() for your own settings, as above.

Themes and Locales ​

typescript
// Themes
const enabledThemes = pfy.getEnabledThemes();
const defaultTheme = pfy.getDefaultTheme();

// Locales
const enabledLocales = pfy.getEnabledLocales();
const defaultLocale = pfy.getDefaultLocale();

if (pfy.isLocaleEnabled("hu")) {
  // Offer Hungarian in the language switcher
}

Module Management ​

typescript
// List modules (format: name@version)
const enabled = pfy.getEnabledModules();
const available = pfy.getAvailableModules();

// Check if a module is enabled (matches the name segment exactly)
if (pfy.isModuleEnabled("checkout")) {
  console.log("Checkout module enabled");
}

// Check a specific version
if (pfy.isModuleEnabled("analytics@2.0.0")) {
  // Use new analytics features
}

Framework Integration ​

Vue 3 ​

@productifyfw/vue provides the plugin, the useProductify() composable over the core client, useTenant(), useLogoutUrl(), the i18n composable and the Vue pilet glue.

Plugin Setup ​

The export is productifyPlugin (lowercase p), and it is a plugin object, not a factory: pass it to app.use() directly, with options as the second argument:

typescript
// main.ts
import { createApp } from "vue";
import { productifyPlugin } from "@productifyfw/vue";
import App from "./App.vue";

const app = createApp(App);
app.use(productifyPlugin);
// or: app.use(productifyPlugin, { strict: true });
app.mount("#app");

Composition API Usage ​

vue
<script setup lang="ts">
import { computed } from "vue";
import { useProductify } from "@productifyfw/vue";

// useProductify() returns the ProductifyClient itself.
const pfy = useProductify();

const userName = computed(() => pfy.getUserName() ?? "Guest");
const maxItems = computed(
  () => pfy.getConfig<number>("maxItemsPerPage", { defaultValue: 10 }) ?? 10
);
const showBeta = computed(() => pfy.isConfigEnabled("betaFeatures"));
</script>

<template>
  <div>
    <h1>Welcome, {{ userName }}</h1>
    <BetaPanel v-if="showBeta" />
    <ItemList :max-items="maxItems" />
  </div>
</template>

React ​

@productifyfw/react provides a context provider and hooks over the core client. useProductify() returns the ProductifyClient itself; useUser(), useTenant(), useTenants(), useLogoutUrl(), useProductifyConfig(), useLocale() and usePilets() return values that re-render when the injected config changes.

App Setup ​

tsx
// App.tsx
import { ProductifyProvider } from "@productifyfw/react";
import Dashboard from "./Dashboard";

function App() {
  return (
    <ProductifyProvider>
      <Dashboard />
    </ProductifyProvider>
  );
}

export default App;

Component Usage ​

tsx
// Dashboard.tsx
import {
  useProductify,
  useProductifyConfig,
  useUser,
} from "@productifyfw/react";

function Dashboard() {
  const pfy = useProductify();
  const user = useUser();
  const maxItems = useProductifyConfig<number>("maxItemsPerPage", {
    defaultValue: 10,
  });

  const showAnalytics = pfy.isModuleEnabled("analytics");

  return (
    <div>
      <h1>Welcome, {user?.name ?? "Guest"}</h1>
      {showAnalytics && <AnalyticsDashboard />}
      <ItemList maxItems={maxItems ?? 10} />
    </div>
  );
}

export default Dashboard;

Other Frameworks ​

@productifyfw/core is framework-agnostic: instantiate ProductifyClient (or use the getProductifyClient() singleton) in any framework's dependency-injection mechanism. Dedicated bindings currently exist for Vue 3 and React only.

Module Loading ​

Productify supports dynamic module (pilet) loading. Enabled modules are listed in the frontend configuration and served by the Manager's pilet feed.

Module Configuration ​

Modules are configured in the Manager and included in window.__PRODUCTIFY__.config:

typescript
window.__PRODUCTIFY__.config.enabled_modules = [
  "navigation@1.2.3",
  "checkout@2.0.1",
  "analytics@0.5.0",
];

Loading Pilets ​

typescript
import { loadPilets, type PiletRegistry } from "@productifyfw/core";

const registry: PiletRegistry = { extensions: {}, pages: {} };

await loadPilets("/pilet-feed", registry, {
  onLoad: (pilet) => console.log(`Loaded ${pilet.name}@${pilet.version}`),
  onError: (pilet, error) => console.error(`Failed ${pilet.name}`, error),
});

Each bundle's SHA-256 hash and its detached Ed25519 signature are verified against the injected trust anchor before it is executed; a refusal is reported through onError and nowhere else, so always pass that callback. Vue and React callers should prefer loadVuePilets / loadReactPilets, which inject their framework's registration adapter. See the frontend integration library guide for the full verification model.

When the injected config carries config.pilet_feed (written by pfy dev proxy --pilet-bundle), loadPilets fetches that feed instead of the URL argument, provided it resolves to the page's own origin; a cross-origin value is ignored with a console warning. Pass useInjectedFeed: false to always use the argument. Verification is unaffected: bundles from the injected feed must still be signed by a key from the trust anchor.

Bundle fetches go to the Manager, not to your SPA

A Manager-rendered application vhost serves /pilet-feed, /pilets/* and /language-packs/* from the Manager, ahead of the SPA. If a bundle fetch comes back as index.html with a 200 on a hand-written vhost, the SPA fallback is answering for /pilets/* and the integrity check fails with what looks like a signing error; route those paths to the Manager as the rendered config does.

Security Considerations ​

Trusted Data ​

The data in window.__PRODUCTIFY__ comes from the Productify proxy after authentication and authorization. This data can be trusted for:

  • Displaying user information
  • Controlling UI visibility based on configuration
  • Configuring application behavior

Client-Side Only ​

Never rely solely on client-side checks for security. Always validate permissions on the backend.

typescript
const kv = Object.fromEntries(pfy.getKeyValuePairs().map((p) => [p.key, p.value]));

// [OK] Good: Hide UI elements
if (kv.adminUi !== "true") {
  // Don't show admin button
}

// [NO] Bad: Only client-side protection
if (kv.adminUi === "true") {
  // Make admin API call
  // Backend MUST also check permissions!
}

XSS Protection ​

The library does not execute any configuration values. All data is treated as plain data. However:

  • Never use innerHTML with configuration values
  • Sanitize any user-controlled content before rendering
  • Use framework's built-in XSS protection (Vue templates, React JSX, etc.)

Best Practices ​

  1. Create the ProductifyClient at app startup and share one instance (the getProductifyClient() singleton) across the application.
  2. Provide a default for every configuration value you read.
  3. Cache expensive checks in component state or computed properties.
  4. Handle a missing configuration gracefully; the client does not throw unless constructed with strict: true.
  5. Never modify window.__PRODUCTIFY__ directly.

Troubleshooting ​

Configuration Not Found ​

If window.__PRODUCTIFY__ is undefined:

  1. Verify application is accessed through Productify proxy
  2. Check proxy configuration and middleware
  3. Ensure correct domain/URL is used
  4. Check browser console for injection errors

Type Errors ​

Ensure TypeScript configuration includes:

json
{
  "compilerOptions": {
    "lib": ["ES2020", "DOM"],
    "moduleResolution": "bundler"
  }
}

Stale Configuration ​

If configuration doesn't update:

typescript
// Manually refresh configuration; onChange fires when content changed
const pfy = new ProductifyClient({
  onChange: (config) => console.log("Configuration updated:", config),
});

pfy.refresh();