Skip to content

Frontend Integration - @productifyfw/core ​

@productifyfw/core is the browser library for applications served through the Productify proxy. The proxy writes the signed-in user, the current tenant and the application's configuration into window.__PRODUCTIFY__ before your JavaScript runs; this library reads that object with types, loads and verifies pilets (micro-frontend bundles), and fetches language packs.

Table of Contents ​

Overview ​

The @productifyfw/core library provides:

  • Typed access to the window.__PRODUCTIFY__ configuration injected by the proxy
  • Pilet loading: dynamic loading of ES module pilets from a feed service, with the bundle's SHA-256 hash and Ed25519 provenance signature verified before execution
  • Framework agnostic: works with Vue, React, or vanilla JavaScript; Vue and React bindings ship as their own packages

The types mirror the Go struct the proxy injects.

How It Works ​

The Productify proxy (Caddy middleware) intercepts HTML responses and injects configuration before the closing </head> tag:

  1. User and tenant data are extracted from the HTTP headers the proxy's authentication chain set:

    • X-User-ID, X-User-Email, X-User-Name, X-User-Username
    • X-Tenant-ID, X-Tenant-Name
    • X-Auth-Type
  2. The application configuration is fetched from the Manager (/api/frontend-config/:application_id/:tenant_id, scoped to the resolved tenant)

  3. Both are combined into the window.__PRODUCTIFY__ object before your application loads

  4. The feed service returns the enabled pilets, which are dynamically imported as ES modules

This means the configuration is available immediately when your JavaScript executes, with no additional API calls needed.

Installation ​

From GitHub Packages ​

The packages are published to the GitHub Packages npm registry. Create or update .npmrc in your project root so the @productifyfw scope resolves there, and add a GitHub token; GitHub Packages requires a token to install, even for public packages. The current release is v1.1.2 for every package in the workspace:

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

Then install:

bash
npm install @productifyfw/core
# or
pnpm add @productifyfw/core
# or
yarn add @productifyfw/core

Add the framework binding you need; see Framework Packages:

bash
npm install @productifyfw/vue      # Vue 3 plugin, composables, i18n, pilet glue
npm install @productifyfw/react    # React provider and hooks
npm install -D @productifyfw/dev   # Vite dev shim + test helper

Quick Start ​

Configuration Access ​

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

const pfy = new ProductifyClient();

// Get user information
const user = pfy.getUser();
console.log(`Welcome, ${user?.name}!`);

// Standard configuration
const theme = pfy.getDefaultTheme() ?? "light";
const locale = pfy.getDefaultLocale() ?? "en";

// Your own settings arrive as key/value pairs with string values
const kv = Object.fromEntries(pfy.getKeyValuePairs().map((p) => [p.key, p.value]));
const maxItems = Number(kv.maxItemsPerPage ?? 10);

// Get tenant information
const tenantId = pfy.getTenantId();
const tenantName = pfy.getTenantName();

getConfig(key) does not read key_value_pairs

pfy.getConfig(key), hasConfig, isConfigEnabled and getNestedConfig read top-level keys of config; the keys the Manager emits are enabled_modules, default_theme, key_value_pairs and the others listed under Configuration Structure. Settings you define yourself in the Manager arrive only inside config.key_value_pairs as { key, value } pairs with string values, so getConfig("maxItemsPerPage") returns null. Use getKeyValuePairs() for your own settings and convert the string yourself.

Pilet Loading ​

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

// Create reactive registry
const piletRegistry: PiletRegistry = reactive({
  extensions: {},
  pages: {},
});

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

Configuration Structure ​

The proxy injects configuration based on the FrontendConfig Go struct:

typescript
window.__PRODUCTIFY__ = {
  user: {
    id: "user-uuid",
    email: "user@example.com",
    name: "User Name",
    username: "username",
  },
  tenant: {
    id: "tenant-id",
    name: "Tenant Name",
  },
  application: {
    id: "app-uuid",
  },
  // snake_case on the wire; read it via ProductifyClient.getAuthType()
  auth_type: "oauth",
  // The standard "Sign out" target every SPA should use
  // (proxy default; configurable per vhost). Read it via getLogoutUrl().
  logout_url: "/_tenant/logout",
  config: {
    // Application configuration from Manager
    available_modules: ["example-pilet@1.0.5", "settings-pilet@1.0.5"],
    enabled_modules: ["example-pilet@1.0.5", "settings-pilet@1.0.5"],
    available_themes: ["light", "dark", "auto"],
    enabled_themes: ["light", "dark"],
    default_theme: "light",
    available_locales: ["en", "es", "fr"],
    enabled_locales: ["en", "es"],
    default_locale: "en",
    // Pilet provenance trust anchor: keyId -> base64 raw Ed25519 public key.
    // Its presence marks a verification-enforcing deployment.
    pilet_signing_keys: { "prod-2026-01": "<base64 raw 32-byte public key>" },
    // The only communications state a browser sees
    communications: { enabled: true },
    // Per-tenant pilet version pins, pilet name -> exact version
    pilets: { pins: { "example-pilet": "1.0.5" } },
    key_value_pairs: [{ key: "custom_setting", value: "custom_value" }],
  },
};

config is resolved for the tenant named in tenant, so per-tenant overrides (including pilets.pins) are already applied. logout_url is read through pfy.getLogoutUrl(), which returns the injected value or /_tenant/logout when the key is absent; @productifyfw/vue and @productifyfw/react expose useLogoutUrl(). Point your "Sign out" control at it rather than a path of your own, which would leave the per-application tenant cookie set.

Pilet Loading ​

Overview ​

The @productifyfw/core package includes a pilet loader for dynamically loading microfrontend modules (pilets) from a feed service. Pilets are built as ES modules and loaded at runtime.

Loading Pilets ​

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

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

await loadPilets("/pilet-feed", piletRegistry, {
  onLoad: (pilet) => {
    console.log(`✓ Loaded: ${pilet.name}@${pilet.version}`);
  },
  onError: (pilet, error) => {
    console.error(`✗ Failed to load ${pilet.name}:`, error);
  },
  onComplete: () => {
    console.log("All pilets loaded");
  },
});

Pilets are verified before execution: a feed entry without signature and keyId is refused

loadPilets never executes a bundle it has not checked. For each feed item it fetches the bytes, then verifies both:

  1. the hex SHA-256 of the exact fetched bytes against PiletFeedItem.hash, and
  2. a detached Ed25519 signature (PiletFeedItem.signature, base64-std) over those same bytes, against the public key resolved from PiletFeedItem.keyId.

Only then are the bytes evaluated. Both are on by default (verifyIntegrity, verifySignature), and a pilet missing hash, signature or keyId is refused rather than executed.

If you operate a feed of your own, it must therefore emit hash, signature and keyId on every item; omit them and every pilet is refused in production. The Manager's feed does this for you: it signs the exact stored bytes at upload time.

The verification model ​

PieceWhere it comes from
hash (hex SHA-256), signature (base64 Ed25519), keyIdThe Manager's pilet feed, which signs the bundle at upload
The trust anchor (the public keys)The proxy's injected config: window.__PRODUCTIFY__.config.pilet_signing_keys, a keyId → base64 raw Ed25519 public key map

The public key is never taken from the feed. The default resolver reads only the injected anchor and returns null for an unknown keyId, which makes the loader refuse that pilet. Supply your own with resolvePublicKey if you need a different trusted source; it must not fetch from the feed host.

The anchor is a map, not a single key, because bundles are immutable and carry the key id they were signed with. When an operator rotates the Manager's signing key, the retired key's public half stays published alongside the new active one, so pre-rotation bundles keep verifying. A key that was revoked rather than retired is deliberately absent: its bundles are meant to be refused. Pilet … is signed by unknown key "…" therefore means the anchor has no entry for that id: either a rotation that did not carry the old key forward, or a revocation working as intended.

The presence of pilet_signing_keys also marks a verification-enforcing deployment: when the anchor is injected, verifyIntegrity: false / verifySignature: false are ignored, so a caller cannot switch verification off in production. Those two flags are a dev-only escape hatch for a bare local feed with no anchor injected (for example under the @productifyfw/dev Vite shim, which does not seed signing keys).

Full LoadPiletsOptions:

OptionDefaultMeaning
onLoad(pilet)noneCalled after a pilet's setup() ran
onError(pilet, error)nonePer-pilet failure, including every verification refusal; wire this or refusals are silent
onComplete()noneCalled once after the whole feed is processed
useInjectedFeedtrueLoad from the same-origin feed path injected as config.pilet_feed (written by pfy dev proxy --pilet-bundle) instead of the feedUrl argument. A cross-origin injected value is ignored with a console warning. resolvePiletFeedUrl(fallback) exposes the same resolution
verifyIntegritytrueSHA-256 check; ignored when the trust anchor is injected
verifySignaturetrueEd25519 check; ignored when the trust anchor is injected
resolvePublicKey(keyId)injected anchorResolve a trusted CryptoKey; return null to refuse
requestTimeoutMs10000Per-request timeout for the feed and each bundle
evaluateModule(bytes, item)Blob-URL import()How verified bytes become a module
createApi(registry)createPiletApiThe PiletApi handed to setup(); the framework packages inject their own

Always pass onError

A refused pilet resolves the load normally; nothing is thrown and nothing is logged. Without onError, a tampered or unsigned bundle is indistinguishable from "no pilets configured".

Every variable segment of a feed link is percent-encoded, so a scoped pilet name such as @myapp/dashboard arrives at the Manager as one segment (@myapp%2Fdashboard) and resolves.

Creating a Pilet ​

Each pilet exports a setup function that receives the PiletApi:

typescript
// my-pilet/src/index.ts
import type { PiletApi } from "@productifyfw/core";
import MyWidget from "./components/MyWidget.vue";

export function setup(api: PiletApi) {
  // Register a widget extension
  api.registerExtension("dashboard-widgets", () => ({
    component: MyWidget,
    defaults: { title: "My Widget", refreshInterval: 30000 },
  }));

  // Register a page route
  api.registerPage("/my-page", {
    component: MyPageComponent,
  });
}

Building Pilets ​

Configure Vite to build pilets as ES modules with all dependencies bundled:

typescript
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
  build: {
    lib: {
      entry: "./src/index.ts",
      name: "MyPilet",
      fileName: "index",
      formats: ["es"],
    },
  },
});

Build the pilet:

bash
pnpm build
# Output: dist/index.js (ES module with all dependencies bundled)

Uploading Pilets ​

Use the Productify CLI to upload pilets to the Manager:

bash
pfy pilet upload \
  --project-id <PROJECT_ID> \
  --name my-pilet \
  --version 1.0.0 \
  --file dist/index.js

Configure CLI in ~/.pfy/config.yaml:

yaml
manager_url: http://manager.localhost # Through the Productify proxy
token: pat_your_token_here

The Manager signs the uploaded bytes with its active Ed25519 key and stores the signature and key id alongside the SHA-256 hash, so the feed can serve hash, signature and keyId for the loader to verify. In production a Manager with no configured signing key cannot sign, and the upload is rejected rather than storing a bundle the loader would later refuse. In development the Manager derives a deterministic, visibly-fake dev- prefixed key so the signed-load path is exercisable locally; a dev- prefixed key id is refused outright in production.

Vue 3 Integration Example ​

typescript
import { createApp, reactive } from "vue";
import type { PiletRegistry } from "@productifyfw/core";
// Prefer the Vue wrapper: it applies markRaw to registered components so heavy
// pilet components are not pulled into Vue's reactivity system.
import { loadVuePilets } from "@productifyfw/vue";
import App from "./App.vue";

const piletRegistry: PiletRegistry = reactive({
  extensions: {},
  pages: {},
});

const app = createApp(App);
app.provide("piletRegistry", piletRegistry);
app.mount("#app");

// Load pilets after mounting
loadVuePilets("/pilet-feed", piletRegistry, {
  onError: (pilet, error) => console.error(`Pilet ${pilet.name} refused:`, error),
});

Rendering Pilet Widgets ​

vue
<template>
  <div class="widgets-grid">
    <component
      v-for="(widget, index) in dashboardWidgets"
      :key="index"
      :is="widget.component"
      v-bind="widget.defaults || {}"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, inject } from "vue";
import type { PiletRegistry } from "@productifyfw/core";

const piletRegistry = inject<PiletRegistry>("piletRegistry");

const dashboardWidgets = computed(
  () => piletRegistry.extensions["dashboard-widgets"] || []
);
</script>

Language Pack Loading (i18n) ​

Overview ​

The @productifyfw/core package includes utilities for loading language packs dynamically from the Manager API. Language packs enable multi-language support with translations fetched at runtime.

Quick Start ​

typescript
import { createI18n } from "@byjohann/vue-i18n";
import { loadLanguagePack } from "@productifyfw/core";
// The Vue composable lives in @productifyfw/vue, not in core.
import { createI18nComposable } from "@productifyfw/vue";

// Create i18n instance
const i18n = createI18n({
  defaultLocale: "en",
  locales: ["en", "hu", "de"],
  messages: { en: {}, hu: {}, de: {} },
});

// Load default locale
loadLanguagePack("en").then((messages) => {
  i18n.messages.value.en = messages;
});

// Create composable
const useProductifyI18n = createI18nComposable(i18n);

// In component
const { changeLocale, enabledLocales } = useProductifyI18n();
await changeLocale("hu"); // Loads and switches to Hungarian

@productifyfw/core also exports a createI18nComposable, but it is a deprecated shim whose body throws with a "moved to @productifyfw/vue" message: core stays framework-agnostic and cannot depend on @productifyfw/vue. Import it from @productifyfw/vue.

The composable returns { locale, messages, changeLocale, availableLocales, enabledLocales, defaultLocale, isLocaleEnabled, preloadLanguagePacks }. locale and messages are Vue refs that this composable also drives into the i18n instance you passed, so you can bind either one. Pass a shared I18nLoader as the second argument to createI18nComposable(i18n, loader) to reuse its language-pack cache.

For React, use useLocale() from @productifyfw/react; see Framework Packages.

API Endpoint ​

Language packs are fetched from the Manager route:

GET /language-packs/{applicationId}/{locale}

The client builds that path itself, and it must carry an id segment: a rendered vhost's rewrite only matches a two-segment path, so an id-less request is forwarded as-is and 404s. I18nLoader therefore requests {languagePacksPath}/{applicationId}/{locale}, taking the id from an explicit argument if given and otherwise from the injected window.__PRODUCTIFY__.application.id. Only when no id can be resolved at all does it fall back to {languagePacksPath}/{locale}.

Two entry points, with different second arguments:

typescript
import { I18nLoader, loadLanguagePack } from "@productifyfw/core";

// Standalone helper: the second argument is I18nOptions.
// The app id comes from the injected config.
const messages = await loadLanguagePack("hu", { requestTimeoutMs: 5000 });

// The loader class: the second argument IS an explicit application id,
// which wins over the injected one. On a rendered vhost it changes only
// the path the browser requests, never the pack that comes back.
const loader = new I18nLoader({ languagePacksPath: "/language-packs" });
await loader.loadLanguagePack("hu", "other-app-uuid");

I18nOptions: defaultLocale ("en"), languagePacksPath ("/language-packs"), requestTimeoutMs (10000), and the onLocaleChange / onLanguagePackLoaded / onLanguagePackError callbacks.

A rendered application vhost forwards /language-packs/* to the Manager ahead of the login portal, path unchanged, so the pack you get is the one whose application id you asked for. The route needs no identity: a browser bootstraps its locale before it has one, and a pack carries only UI strings.

Response:

json
{
  "locale": "hu",
  "messages": {
    "app": {
      "title": "Alkalmazásom"
    },
    "common": {
      "save": "Mentés",
      "cancel": "Mégse"
    }
  }
}

Language Pack Storage ​

In the Manager database, translations are stored as key-value pairs with dot notation:

  • Database: {"key": "app.title", "value": "My Application"}
  • API converts to: {"app": {"title": "My Application"}}

Example: Language Switcher Component ​

vue
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "@byjohann/vue-i18n";
import { useProductifyI18n } from "../plugins/i18n";

const { locale } = useI18n();
const { changeLocale, enabledLocales } = useProductifyI18n();

const isChanging = ref(false);
const selectedLocale = ref(locale.value);

watch(locale, (newLocale) => {
  selectedLocale.value = newLocale;
});

const switchLanguage = async (event: Event) => {
  const newLocale = (event.target as HTMLSelectElement).value;
  if (newLocale === locale.value) return;

  isChanging.value = true;
  try {
    await changeLocale(newLocale);
  } catch (error) {
    console.error("Failed to change language:", error);
    selectedLocale.value = locale.value;
  } finally {
    isChanging.value = false;
  }
};
</script>

<template>
  <select
    v-model="selectedLocale"
    @change="switchLanguage"
    :disabled="isChanging"
  >
    <option v-for="loc in enabledLocales" :key="loc" :value="loc">
      {{ loc.toUpperCase() }}
    </option>
  </select>
</template>

Framework Packages ​

@productifyfw/core is framework-agnostic. The Vue and React bindings ship as their own packages, alongside a dev/test shim:

PackageWhat it gives you
@productifyfw/vueproductifyPlugin, useProductify, useTenant, useLogoutUrl, createI18nComposable, createVuePiletApi, loadVuePilets
@productifyfw/reactProductifyProvider, useProductify, useProductifyConfig, useUser, useTenant, useTenants, useLogoutUrl, useLocale, usePilets, createReactPiletApi, loadReactPilets, PiletExtension, getPiletPage
@productifyfw/devproductifyDevShim (Vite), setupProductifyGlobal / resetProductifyGlobal (tests), plus the dev.productify.json reader

All three depend on @productifyfw/core and are published from the same workspace release as core. Add the same .npmrc registry line as for core.

@productifyfw/vue ​

bash
npm install @productifyfw/vue

productifyPlugin 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";

createApp(App).use(productifyPlugin).mount("#app");

// with ProductifyClientOptions, or an existing client:
// createApp(App).use(productifyPlugin, { strict: true }).mount("#app");
// createApp(App).use(productifyPlugin, { client: myClient }).mount("#app");

useProductify() returns the ProductifyClient itself (it throws if the plugin was not installed), so you call the same methods documented above:

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

const pfy = useProductify();
const userName = computed(() => pfy.getUserName() ?? "Guest");
const theme = computed(() => pfy.getConfig<string>("theme", { defaultValue: "light" }));

// The multi-tenancy surface as computed refs:
const { current, availableTenants, tenantRole, tenantPermissions, picture, hasTenantPermission, switchTenant } =
  useTenant();
</script>

The Vue package also owns the Vue-aware code: createI18nComposable (see above) and the pilet markRaw handling. Core's createPiletApi is framework-neutral and does no markRaw; use loadVuePilets(feedUrl, registry, options?), a thin wrapper over core's loadPilets that injects createVuePiletApi, preserving every core option including verification:

typescript
import { loadVuePilets } from "@productifyfw/vue";
import { reactive } from "vue";
import type { PiletRegistry } from "@productifyfw/core";

const piletRegistry: PiletRegistry = reactive({ extensions: {}, pages: {} });
await loadVuePilets("/pilet-feed", piletRegistry, {
  onError: (pilet, error) => console.error(pilet.name, error),
});

@productifyfw/react ​

bash
npm install @productifyfw/react
tsx
import {
  ProductifyProvider,
  useProductify,
  useProductifyConfig,
  useUser,
  useTenant,
  useLocale,
} from "@productifyfw/react";

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

function Profile() {
  // useProductify() returns the ProductifyClient, not a destructurable object.
  const pfy = useProductify();
  const user = useUser();
  const tenant = useTenant();
  const theme = useProductifyConfig<string>("theme", { defaultValue: "light" });
  const { locale, changeLocale, enabledLocales, loading, error } = useLocale();

  return <span>{user?.name} @ {tenant?.name} ({theme})</span>;
}

ProductifyProvider accepts client (an existing ProductifyClient) or options (ProductifyClientOptions). When it constructs the client, it wires the client's onChange into React state, so calling client.refresh() re-renders every config/user/tenant hook. An injected client keeps its own onChange; wire it yourself if you need re-renders.

Hook return shapes:

  • useLocale(loader?) → { locale, messages, error, loading, changeLocale, availableLocales, enabledLocales }. There is no setLocale and no available; the mutator is changeLocale(locale), which loads the language pack. Pass a shared I18nLoader to reuse its cache.
  • useTenants() → { current, availableTenants, tenantRole, tenantPermissions, picture, hasTenantPermission, switchTenant }. useTenant() is the narrow one and returns only the current ProductifyTenant | null.
  • usePilets(feedUrl, options?) → { registry, items, loading, error }. error is the feed-level error; per-pilet verification refusals arrive on your options.onError.

For rendering, <PiletExtension registry={registry} name="dashboard-widgets" params={…} /> renders every component registered in a slot, applying each registration's defaults (caller params win), and getPiletPage(registry, route) resolves a registered page component.

The pilet contract is framework-neutral, the adapter is not

Core's neutral createPiletApi treats a registered function as a lazy factory and calls it, which is why the example pilets write () => ({ component, defaults }). In React a component is a function, so createReactPiletApi probes a function registration once at registration time and stores the function itself unless calling it yields a { component } descriptor. Both forms therefore work under React. Use loadReactPilets (or pass createApi: createReactPiletApi) rather than core's loadPilets directly.

Development & Testing ​

Vite dev shim: @productifyfw/dev ​

Running npm run dev without the proxy means no injected window.__PRODUCTIFY__. The dev package fakes it, reading the identity file that pfy dev seed writes:

typescript
// vite.config.ts
import { productifyDevShim } from "@productifyfw/dev";

export default defineConfig({
  plugins: [
    vue(),
    productifyDevShim({
      configFile: "dev.productify.json", // default
      user: "dev@local.test", // pick a dev user by id, email or username
      authType: "oauth", // default
    }),
  ],
});

The shim injects the payload from dev.productify.json into index.html during dev, and no-ops in production builds; it declares apply: 'serve', so it is not part of a build at all. The injected block is byte-for-byte the one pfy dev proxy emits (a JSON <script id="productify-config"> plus a parse shim), and it uses the same auth_type snake_case wire key the production proxy emits.

The shim seeds no signing keys

dev.productify.json carries no pilet_signing_keys, so under the shim there is no trust anchor and loadPilets' dev escape hatch is honoured: pass verifySignature: false (and verifyIntegrity: false) to load unsigned local bundles. Nothing you can set here weakens a real deployment; the anchor's presence in production forces both checks back on. This means the shim does not rehearse the production verification path; use pfy dev up and the local signed feed for that.

Test helper ​

typescript
// vitest setup (requires a DOM environment, e.g. environment: "jsdom")
import { setupProductifyGlobal, resetProductifyGlobal } from "@productifyfw/dev";

afterEach(() => resetProductifyGlobal());

setupProductifyGlobal({
  user: { id: "u1", email: "test@local", name: "Test", username: "test" },
  tenant: { id: "t1", name: "acme" },
  config: { enabled_modules: ["dashboard@1.0"] },
  // authType: "oauth"  — written to the payload as `auth_type`
});

user, tenant and application are merged field-by-field onto built-in defaults (so you only pass what your test cares about); config replaces wholesale and is omitted entirely when you pass none, matching the proxy's omitempty. Component tests then exercise the real @productifyfw/core client against a controlled global, with no mocking of library internals.

Best Practices ​

  1. Create the ProductifyClient instance at app startup
  2. Share one instance across your application
  3. Provide defaults for configuration values
  4. Cache expensive checks in component state or computed properties
  5. Handle a missing config
  6. Never modify window.__PRODUCTIFY__ directly
  7. Use TypeScript generics for typed configuration access

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
  5. Verify HTML middleware is enabled in Caddyfile

Missing Configuration Values ​

If specific configuration values are missing:

  1. Check the Manager's /api/frontend-config/:application_id/:tenant_id endpoint
  2. Verify application ID is correct in proxy configuration
  3. Check Manager has configuration set for the application
  4. Use default values as fallback

Type Errors ​

If you're getting TypeScript errors:

  1. Ensure @productifyfw/core is properly installed
  2. Check TypeScript version compatibility (requires 5.0+)
  3. Verify types are being loaded correctly
  4. Use explicit type parameters: getConfig<string>('key')

Advanced Usage ​

Strict Mode ​

Enable strict mode to throw an error when window.__PRODUCTIFY__ is missing (the default is to work with an empty configuration):

typescript
const pfy = new ProductifyClient({ strict: true });
// Throws error if window.__PRODUCTIFY__ is not available

Configuration Changes ​

Listen for configuration changes at runtime:

typescript
const pfy = new ProductifyClient({
  onChange: (config) => {
    console.log("Configuration updated:", config);
  },
});

// Later, trigger refresh
pfy.refresh();

onChange is a single option slot owned by the host application. For anything else that needs to observe changes (a framework binding, a widget, a second module) use subscribe(), which supports many independent listeners alongside the host's onChange and returns a release function:

typescript
const release = pfy.subscribe((config) => {
  console.log("Configuration updated:", config);
});

// later
release();

A listener that throws is reported to the console and does not prevent the remaining listeners from running.

Singleton Pattern ​

Use the built-in singleton for global access:

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

const pfy = getProductifyClient();
// Returns the same instance every time

Type Reference ​

typescript
interface ProductifyFrontendConfig {
  user: ProductifyUser;
  tenant: ProductifyTenant;
  application: ProductifyApplication;
  /** Canonical camelCase key. Not what the proxy emits; see below. */
  authType?: AuthType;
  /** @deprecated Legacy snake_case key the proxy emits. */
  auth_type?: AuthType;
  config?: ProductifyConfig;
  /** The "Sign out" target; read it through getLogoutUrl(). */
  logout_url?: string;
  /** Tenants the user may switch into (id + name only). Absent-safe. */
  available_tenants?: AvailableTenant[];
  /** Membership role name; present only with opt-in role forwarding. */
  tenantRole?: string;
  /** Tenant-scoped permissions; present only with opt-in role forwarding. Gate on this, not tenantRole. */
  tenantPermissions?: string[];
}

interface ProductifyUser {
  id: string;
  email: string;
  name: string;
  username: string;
  /** From the IdP `picture` claim; present only with opt-in role forwarding. */
  picture?: string;
}

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

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

interface ProductifyApplication {
  id: string;
}

interface ProductifyConfig {
  available_modules?: string[];
  enabled_modules?: string[];
  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. */
  pilet_signing_keys?: Record<string, string>;
  /** The only communications state a browser sees. */
  communications?: { enabled: boolean };
  /** Per-(tenant, application) pilet version pins: name -> exact version. */
  pilets?: { pins?: Record<string, string> };
  key_value_pairs?: Array<{ key: string; value: string }>;
  [key: string]: unknown;
}

auth_type, not authType, is what is on the wire

The proxy marshals the field as snake_case auth_type. ProductifyFrontendConfig declares both keys and ProductifyClient.getAuthType() reads authType first with an auth_type fallback, so always call getAuthType() rather than reading the property. If you read the raw global, read auth_type. See Known Issues.

See Also ​