cx-composer
# ────── architecture ──────

How microservice Client Extensions work at runtime

Once your CX is deployed, every user-agent type — Object Action, Workflow Action, Notification Type, Object Validation Rule, Object Entry Manager, CAPTCHA, all Commerce integrations — walks the same flowchart at request time. The scenario switcher above the diagram traces one of three real outcomes:

  1. Invalid agent token — the token fails signature, issuer, or expiry checks. The microservice returns 401 immediately and the cycle ends. No business logic, no Headless call.
  2. Call Headless with the agent token — token is valid, need to update Liferay, and the acting user has enough scope. Reuse the same agent token to hit any /o/headless-*/v1.0/... endpoint on their behalf.
  3. Call Headless with a server token — token is valid, need to update Liferay, but the operation requires higher scope than the acting user has. Mint a server token via client_credentials against a registered oAuthApplicationHeadlessServer, then call Headless with it.

Switch scenarios above the diagram; the packet re-routes through the same flowchart. Diamonds are decision points, rectangles are process steps, pills are terminals. Hover any node to tilt in 3D.

# runtime workflow — drag to rearrange · scroll / pinch to zoom
Valid token, need to update Liferay, acting user has enough scope: reuse the same agent token to call Headless on their behalf.
Mini Map
01.User triggers action

A logged-in user clicks an Object Action button, or the workflow engine hits a transition that maps to your Workflow Action.

Inside your handler

The three snippets below map to the three modes in the diagram. All of them live inside the same POST handler — pick the pattern that matches what the current request needs to do.

1. Validate the agent token
import express, { Request, Response } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const LIFERAY = process.env.LIFERAY_URL!;
const JWKS = createRemoteJWKSet(new URL(`${LIFERAY}/o/oauth2/jwks`));

const app = express();
app.use(express.json({ limit: '2mb' }));

app.post('/actions/my-action', async (req: Request, res: Response) => {
    // Grab the agent token Liferay attached.
    const auth = req.header('authorization') ?? '';
    const agentToken = auth.replace(/^Bearer\s+/i, '');
    if (!agentToken) return res.status(401).json({ error: 'no token' });

    // Verify signature + issuer. Any failure = 401 rejected.
    let claims;
    try {
        ({ payload: claims } = await jwtVerify(agentToken, JWKS, {
            issuer: LIFERAY,
        }));
    } catch (err) {
        return res.status(401).json({ error: 'invalid token', detail: String(err) });
    }
    const actingUserId = claims.sub;

    // ... run your business logic here, then respond.
    res.json({ status: 'success', actingUserId });
});

app.listen(Number(process.env.PORT ?? 8080));
2. Call Headless as the acting user
// After validating: re-attach the SAME agent token to a Headless call.
// Liferay runs it as the acting user — their site scope + permissions apply.
// No extra token generation.

const asUser = await fetch(
    `${LIFERAY}/o/headless-admin-user/v1.0/my-user-account`,
    { headers: { Authorization: `Bearer ${agentToken}` } },
);

if (!asUser.ok) {
    // 403 = the acting user isn't allowed to see it. Normal ACL response.
    return res.status(asUser.status).json({ error: 'headless failed' });
}
const me = await asUser.json();

res.json({ status: 'success', me });
3. Get a server token (only when higher scope is needed)
// Only run this branch when the operation exceeds the acting user's scope.
// client_id + client_secret come from a server-type OAuth application
// you registered with Liferay. Keep them as env vars.

const tokenRes = await fetch(`${LIFERAY}/o/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: process.env.SERVER_CLIENT_ID!,
        client_secret: process.env.SERVER_CLIENT_SECRET!,
    }),
});
if (!tokenRes.ok) throw new Error(`token ${tokenRes.status}`);
const { access_token: serverToken } = await tokenRes.json();

// Cache it. Refresh when it expires. Don't ask for a new one every request.
4. Call Headless with the server token
// With the server token in hand, call any Headless endpoint scoped to
// what the server OAuth app was granted. Typical: admin-only resources.

const asServer = await fetch(
    `${LIFERAY}/o/headless-admin-user/v1.0/user-accounts`,
    { headers: { Authorization: `Bearer ${serverToken}` } },
);
const users = await asServer.json();

// Combine data as needed, then respond to Liferay.
res.json({ status: 'success', users });

CX types that follow this flow

CX Composer scaffolds a full working microservice (Node.js or TypeScript) plus the paired OAuth application for each of these types. The route, the container, the OAuth entry — all wired up.

Try it in your workspace