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
Modulesinstack9.config.json. - The DXP Provider instance is deployed and its URL is known.
- The same
APP_DXP_CODE_EXCHANGE_SECRETvalue used on the Provider is available. - Redis is available (required for session API key caching).
Step 1: Set environment variables
| Variable | Required | Description |
|---|---|---|
APP_DXP_CODE_EXCHANGE_SECRET | Yes | Pre-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_URL | No | Overrides 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_TIME | Yes | Session 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:
| Scenario | Set APP_DXP_PROVIDER_EXCHANGE_URL? | Value |
|---|---|---|
| Production — Provider has a public URL | No | — |
| Multiple Provider instances (multi-tenant) | No | — (each request carries its own origin) |
| Local dev — both instances on the same machine, different ports | Usually no | — |
| Both in Docker, Provider not on host network | Yes | Provider's Docker service name, e.g. http://provider:3333 |
| Provider behind internal-only hostname in staging | Yes | Provider'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)
| Field | Type | Description |
|---|---|---|
mode | "client" | Activates client mode. The /api/auth/dxp/callback endpoint becomes active. |
apps | array | Each 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[].path | string | The local path users land on after sign-in (e.g. /mkt). |
Startup validation: The instance will refuse to start if
APP_DXP_CODE_EXCHANGE_SECRETis missing,appsis empty, or anyappKeyreferences a module not inModules.
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]
| Step | What it creates/updates | Why it's needed |
|---|---|---|
ensureUserGroup | user_group row with code: dxp-users | All DXP-arrived users are added to this group on login |
ensureSecurityRole | security_role row with name: DXP User | Assigned to users created on first DXP login (required field) |
ensureAppPermissions | app_permissions row per appKey | Grants the dxp-users group access at Privileged level; sets minimumRole so tiles appear |
ensureScreenQueryPermissions | Updates app_screen_permissions query roles | New screen queries default to Admin (4); DXP users are at Privileged (3) — bootstrap caps them so DXP users can run screen queries |
resolveJoinTable | Validates join table exists | Sanity 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
userIdinteger is stored inidentity_provider_user_idfor audit purposes only and is never used for lookups. - Provider-computed roles are intentionally discarded — the Client recomputes roles from its own
user_groupsandapp_permissions. This prevents cross-instance role leakage.
Session cookie
| Attribute | Value |
|---|---|
| Name | api-token |
httpOnly | true (except local dev environment) |
secure | true (except local dev environment) |
sameSite | lax |
| Expiry | AUTH_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
| Symptom | Likely cause | Fix |
|---|---|---|
dxp.client.callback: DXP user group not found | Bootstrap has not run for this instance | Run node main.js bootstrap |
ECONNREFUSED 127.0.0.1:3333 in Client logs | Client is in Docker and the Provider's localhost address is unreachable | Set 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 Client | Ensure both instances use the same secret value |
dxp.client.callback: exchange response did not match expected schema | Provider and Client are running incompatible versions | Update 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 version | Re-run node main.js bootstrap |
| Blank page after login | Screen 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.