Skip to main content

Configure a DXP Client

A DXP Client is a Stack9 instance that hosts the Email, Marketing, and Pages modules on behalf of multiple organisations. Users do not log in to it directly — they arrive via a tile click from a DXP Provider instance, and a local session is established automatically via the auth exchange flow.


Prerequisites

  • Stack9 Core instance running v5.0.0 or later.
  • The DXP modules you want to serve must be listed in Modules in stack9.config.json.
  • The DXP Provider instance is deployed and its URL is known.
  • The same APP_DXP_CODE_EXCHANGE_SECRET value used on the Provider is available.
  • Redis is available (required for session API key caching).

Step 1: Set environment variables

VariableRequiredDescription
APP_DXP_CODE_EXCHANGE_SECRETYesPre-shared secret sent in the x-dxp-secret header on every server-to-server code exchange. Must match the Provider's value exactly.
APP_DXP_PROVIDER_EXCHANGE_URLNoOverrides the browser-supplied origin URL when making the server-to-server code exchange call. When absent, the Client uses the origin parameter from the browser redirect — which is the Provider's URL as seen by the browser. This is correct for production deployments where the Provider is publicly reachable. Set this only when the Provider's public URL is not reachable from the Client server (e.g. both run locally in Docker and the Provider's localhost address resolves to the wrong container).
AUTH_COOKIE_TIMEYesSession lifetime in minutes. Controls both the JWT expiry and the Redis TTL for the cached API key.

When to set APP_DXP_PROVIDER_EXCHANGE_URL

The Client exchanges the one-time code by calling the Provider's /api/auth/dxp/exchange-code endpoint server-to-server. The target URL defaults to the origin the Provider passed in the redirect — which is the Provider's public URL from the browser's perspective.

This fallback works correctly in production because the Provider's public URL is reachable from the Client's server. You only need to override it in specific networking situations:

ScenarioSet APP_DXP_PROVIDER_EXCHANGE_URL?Value
Production — Provider has a public URLNo
Multiple Provider instances (multi-tenant)No— (each request carries its own origin)
Local dev — both instances on the same machine, different portsUsually no
Both in Docker, Provider not on host networkYesProvider's Docker service name, e.g. http://provider:3333
Provider behind internal-only hostname in stagingYesProvider's internal hostname

Multi-provider support: Because the exchange host is resolved from each request's origin, a single Client instance can serve users arriving from multiple different Providers — no per-provider config is needed.

Example APP_SECRETS fragment (production — no override needed):

{
"APP_DXP_CODE_EXCHANGE_SECRET": "your-shared-secret-here",
"AUTH_COOKIE_TIME": "480"
}

Example with Docker override:

{
"APP_DXP_CODE_EXCHANGE_SECRET": "your-shared-secret-here",
"APP_DXP_PROVIDER_EXCHANGE_URL": "http://provider-service:3333",
"AUTH_COOKIE_TIME": "480"
}

Step 2: Configure stack9.config.json

{
"ProjectName": "acme-dxp",
"Stack9CoreVersion": "5.0.0",
"Modules": [
"@april9au/stack9-core-module",
"@april9au/stack9-marketing-module",
"@april9au/stack9-email-module",
"@april9au/stack9-pages-module"
],
"dxp": {
"mode": "client",
"apps": [
{
"appKey": "mkt",
"path": "/mkt"
},
{
"appKey": "email",
"path": "/email"
},
{
"appKey": "pages",
"path": "/pages"
}
]
}
}

dxp block fields (client mode)

FieldTypeDescription
mode"client"Activates client mode. The /api/auth/dxp/callback endpoint becomes active.
appsarrayEach entry represents one DXP app this Client hosts.
apps[].appKey"email" | "mkt" | "pages"Identifies the DXP module. Must match a module installed in Modules.
apps[].pathstringThe local path users land on after sign-in (e.g. /mkt).

Startup validation: The instance will refuse to start if APP_DXP_CODE_EXCHANGE_SECRET is missing, apps is empty, or any appKey references a module not in Modules.


Step 3: Run bootstrap

Bootstrap is a mandatory, one-time step (re-runnable safely) that provisions the database records required for DXP logins to work. It runs as a separate process from the webserver — the webserver does not call bootstrap on startup.

node main.js bootstrap

In a typical CI/CD pipeline, this runs as a pre-deployment step before the webserver container starts.

What bootstrap does

flowchart TD
A[Bootstrap starts] --> B{dxp.mode = client?}
B -- No --> Z[Skip DXP bootstrap]
B -- Yes --> C[ensureUserGroup\nCreate dxp-users group if missing]
C --> D[ensureSecurityRole\nCreate DXP User role if missing]
D --> E[ensureAppPermissions\nFor each appKey:\n• minimumRole ≤ Privileged 3\n• Grant dxp-users at level 3]
E --> F[ensureScreenQueryPermissions\nFor each DXP app screen:\n• Cap all query roles > 3 down to 3]
F --> G[resolveJoinTable\nVerify user↔user_group join table exists]
G --> H[Bootstrap complete]
StepWhat it creates/updatesWhy it's needed
ensureUserGroupuser_group row with code: dxp-usersAll DXP-arrived users are added to this group on login
ensureSecurityRolesecurity_role row with name: DXP UserAssigned to users created on first DXP login (required field)
ensureAppPermissionsapp_permissions row per appKeyGrants the dxp-users group access at Privileged level; sets minimumRole so tiles appear
ensureScreenQueryPermissionsUpdates app_screen_permissions query rolesNew screen queries default to Admin (4); DXP users are at Privileged (3) — bootstrap caps them so DXP users can run screen queries
resolveJoinTableValidates join table existsSanity check before webserver starts

Idempotency: Every step checks for existing records before inserting. Running bootstrap multiple times is safe.


Step 4: Start the webserver

node main.js webserver

The webserver does not repeat the bootstrap steps. It reads the dxp-users group from the database at request time (no in-memory cache).


What happens on the first DXP login

When a user arrives at /api/auth/dxp/callback for the first time:

sequenceDiagram
participant U as Browser
participant C as Client
participant P as Provider (exchange endpoint)
participant DB as Client DB
participant R as Client Redis

U->>C: GET /api/auth/dxp/callback?code=X&origin=Y&redirect=/mkt
C->>P: POST /api/auth/dxp/exchange-code (x-dxp-secret header)
P-->>C: { userId, email, appType, apiKey, apisBaseUrl, origin }

C->>DB: Find user by email
alt User not found (first login)
C->>DB: Insert user (email, DXP User role, is_active=true)
C->>DB: Re-fetch user
end

C->>DB: Check if user is in dxp-users group
alt Not in group yet
C->>DB: Insert into user↔user_group join table
end

C->>R: Cache { apiKey, apisBaseUrl, appType, providerOrigin }<br/>Key: dxp:apicfg:{localUserId}
C->>DB: Compute JWT roles (from local user_groups + app_permissions)
C->>C: Sign JWT
C-->>U: HTTP 302 → /mkt (Set-Cookie: api-token)

User identity

  • Identity is keyed by email address, not by the Provider's user ID. The same email arriving from different Provider instances resolves to the same local user on the Client.
  • The Provider's userId integer is stored in identity_provider_user_id for audit purposes only and is never used for lookups.
  • Provider-computed roles are intentionally discarded — the Client recomputes roles from its own user_groups and app_permissions. This prevents cross-instance role leakage.
AttributeValue
Nameapi-token
httpOnlytrue (except local dev environment)
securetrue (except local dev environment)
sameSitelax
ExpiryAUTH_COOKIE_TIME minutes from login

Verifying the setup

After bootstrap and before the first login, check:

# Bootstrap log should contain:
# bootstrap.dxp.ready
# bootstrap.dxp.user_group.created (first run only)
# bootstrap.dxp.security_role.created (first run only)
# bootstrap.dxp.app_permission.created / .updated
# bootstrap.dxp.screen_permission.capped (if screen query roles were above 3)

On first tile click:

# Client webserver logs should contain:
# dxp.client.callback.received
# dxp.client.callback.exchange_host (if DXP_PROVIDER_EXCHANGE_URL is set)
# dxp.client.callback: DXP_PROVIDER_EXCHANGE_URL not set (warn — if not set, origin fallback is used)
# dxp.client.user.created (first login only)
# dxp.client.user.backfilled_to_dxp_group (if user wasn't in group)
# dxp.client.session.created

Common configuration issues

SymptomLikely causeFix
dxp.client.callback: DXP user group not foundBootstrap has not run for this instanceRun node main.js bootstrap
ECONNREFUSED 127.0.0.1:3333 in Client logsClient is in Docker and the Provider's localhost address is unreachableSet APP_DXP_PROVIDER_EXCHANGE_URL to the Provider's Docker service name (e.g. http://provider:3333)
dxp.client.callback: provider rejected the code exchange (401)APP_DXP_CODE_EXCHANGE_SECRET mismatch between Provider and ClientEnsure both instances use the same secret value
dxp.client.callback: exchange response did not match expected schemaProvider and Client are running incompatible versionsUpdate both instances to the same Stack9 Core version
You have no access to run {queryName} (403)Screen query roles were not capped — bootstrap ensureScreenQueryPermissions step did not run or ran on an older versionRe-run node main.js bootstrap
Blank page after loginScreen has an empty component tree (nodes: [])Add UI components to the screen definition via the visual editor

See the Testing guide for step-by-step scenarios.