Foundation Service — Extension & Lifecycle Framework

Platform: Infosys Equinox | The per-instance customisation model

This is the central capability of the extension-support work: a tenant (instance) can inject custom behaviour into Foundation at runtime — custom validation and request/response interception — without modifying or redeploying Foundation itself.

There are two independent mechanisms:

  1. Validator extensions — replace or augment the built-in domain validation for an entity.
  2. Lifecycle extensions — run custom logic before (pre-handle) and after (post-handle) a request.

Both are configured per instance under instanceContext.properties.pluginConfig and loaded through the shared @equinox/plugin-registry.

Why this exists

Different tenants have different rules. One retailer may require store names to match a corporate code pattern; another may need to enrich a create request with data from an external system, or reshape a response for a legacy client. Hard-coding every tenant’s rules into Foundation does not scale. The extension framework lets each instance plug in exactly the behaviour it needs.

Where extension code can be loaded from

@equinox/plugin-registry resolves an extension’s module reference from one of three sources, each guarded by an allowlist environment variable:

Source module value example Allowlist env var
npm package / Lambda layer "my-store-validator" ALLOWED_PLUGIN_PACKAGES
Local / EFS JS file "/opt/validators/store.js" ALLOWED_PLUGIN_FILE_PREFIXES
S3 JS file "s3://my-bucket/store.js" ALLOWED_PLUGIN_S3_BUCKETS

If a module is not permitted by the corresponding allowlist, it will not load. This prevents arbitrary code execution — only vetted packages/paths/buckets are honoured.

Validator Extensions

A validator extension replaces the built-in domain validation for an entity type. Foundation currently supports these validator extension types (loaded via getExtensions(<type>, pluginConfig)):

  • storeValidator
  • siteValidator
  • foundationLocationValidator
  • sellerValidator

How the service uses it

Example from src/service/store.service.ts (create flow):

// Load custom store validator if configured via pluginConfig (Extension Point)
let customStoreValidator;
try {
  customStoreValidator = await getExtensions('storeValidator', null);
} catch {
  // Fallback to default validation
}

if (customStoreValidator) {
  const result = await customStoreValidator.validate(storeData);
  // isWorking === false means the validator could not run — skip and fall back
  if (result.isWorking !== false && !result.isValid) {
    const err = new Error('STORE_VALIDATION_FAILED');
    err.errorCode = ErrorCodes.STORE_VALIDATION_FAILED;   // EQ-FND-4220124
    err.details = result.errors;            // surfaced to the caller
    err.extensionDetails = result.details;
    throw err;
  }
}
// ...then the built-in domain validation runs as well

Behaviour rules

  • Precedence: if a custom validator is configured, it runs (the built-in domain rules still apply afterward for core invariants like hierarchy depth).
  • Graceful fallback: if the validator cannot be loaded or returns isWorking: false, Foundation falls back to default validation rather than failing the request.
  • Failure surface: when a validator rejects a payload, the caller receives 422 STORE_VALIDATION_FAILED (EQ-FND-4220124) with the extension’s errors in details and any extra context in extensionDetails.

Expected validator contract

A validator module exports a class with a validate method returning:

{
  isValid: boolean;
  isWorking: boolean;   // false = "couldn't run, please fall back"
  errors: string[];     // human-readable validation errors
  details: unknown;     // optional structured detail
}

Lifecycle Extensions (pre/post handlers)

Lifecycle extensions run around the normal request handling, driven by instanceContext.properties.pluginConfig.lifecycle. Managed by src/hooks/LifecycleExtensionManager.ts.

Request
  → PRE-HANDLERS  (may ABORT the request)
  → normal route/controller/service logic
  → POST-HANDLERS (may REWRITE the response body)
  → Response

Configuration shape

{
  "pluginConfig": {
    "lifecycle": {
      "preHandlers": {
        "enabled": true,
        "handlers": [
          {
            "name": "enrich-store-create",
            "routes": ["/v1/stores"],          // optional route filter (substring match)
            "module": "/opt/ext/enrich.js",     // in-process class...
            "class": "EnrichStore",
            "config": { "...": "..." }
          },
          {
            "name": "remote-guard",
            "appUrl": "https://ext.example.com/pre",  // ...OR a remote HTTP app
            "appId": "<bearer-token>"
          }
        ]
      },
      "postHandlers": {
        "enabled": true,
        "handlers": [ /* same shape */ ]
      }
    }
  }
}

Pre-handlers

  • Run only if preHandlers.enabled is true and (when routes is set) the current route matches (substring includes).
  • In-process handler (module + optional class): dynamically imported; its preHandle(context) is called with { request, reply, instanceId, instanceContext }.
  • Remote handler (appUrl): Foundation POSTs a JSON payload { handlerName, instanceId, route, method, body, queryParams, config, timestamp } (with Authorization: Bearer <appId> if provided). If the response has { abort: true, errorCode, message }, the request is aborted with that error.
  • Any pre-handler error aborts the request — the error propagates to the client.

Post-handlers

  • Run only if postHandlers.enabled is true and the route matches.
  • Receive the response data and may return a modified response body, which replaces the original before it is sent to the client.
  • Also support in-process (module/class) and remote (appUrl) forms.

Route scoping

Each handler may carry a routes: string[]. A handler runs only when the current route URL includes one of those strings. Omit routes (or leave it empty) to run on every route.

Security & operational notes

  • Allowlists are mandatory in practice — set ALLOWED_PLUGIN_PACKAGES / ALLOWED_PLUGIN_FILE_PREFIXES / ALLOWED_PLUGIN_S3_BUCKETS to the exact, vetted sources. Unlisted sources will not load.
  • Remote handlers make outbound HTTP calls on the request path — a slow or failing remote pre-handler will slow or abort requests. Use timeouts and health-check your extension apps.
  • Fallback semantics differ by mechanism: a validator that can’t run falls back to default validation; a pre-handler that throws aborts the request. Design accordingly.
  • Debugging: the lifecycle manager logs skipped handlers (route mismatch) at debug level and logs pre-handler failures at error level with the handler name.

Source map

Concern File
Validator loading src/plugins/pluginRegistry.ts (wraps @equinox/plugin-registry getExtensions)
Validator usage src/service/store.service.ts, and sibling services / src/domain/validator/*
Validator interfaces src/domain/validator/foundationExtensions.ts, baseExtension.ts
Lifecycle manager src/hooks/LifecycleExtensionManager.ts
Lifecycle interfaces src/domain/validator/lifecycleExtensions.ts
Config source instanceContext.properties.pluginConfig


Revision History
2026-08-04 | AN – Page created and uploaded the contents