Understand Proxies
This guide explains how to use an Epicenter proxy to enforce access rules that cannot be expressed within Epicenter's built-in scope boundaries alone.
To learn how to set up the project in Epicenter, read Create the Project.
Implementing a proxy
All code samples below are taken directly from the reference application code.
The JavaScript examples in this tutorial are compliant with the ECMAScript® 2026 language specification.
Declare the proxy runtime
Epicenter recognizes the proxy/ directory as a proxy because it contains an index.js entry point and an index.ctx2 that declares the runtime:
{
"language": "JAVASCRIPT_20", // Node.js version 20 is used
"minimumLogLevel": "INFO",
"inceptionGracePeriodSeconds": 60
}
The proxy runs as a separate Node.js process alongside the simulation model. The model/ directory still holds the project's simulation model (model.py); the two coexist independently.
Configure the Express server
The proxy entry point is a plain Express application. When deployed to Epicenter, the global epicenter object is injected and provides the project's connection details and shared secret. For local development, the same fields are read from a git-ignored proxy/env.json file instead:
const express = require('express');
const cors = require('cors');
const epicenterLibs = require('epicenter-libs');
const { Fault, config, runAdapter } = epicenterLibs;
const app = express();
app.use(
cors({
origin: /https?:\/\/localhost:8888/,
})
);
app.use(express.json());
try {
const proxyConfig = epicenter.proxyConfig();
config.setContext({
apiProtocol: proxyConfig.apiScheme.toLowerCase(),
apiHost: proxyConfig.apiHost,
accountShortName: proxyConfig.accountShortName,
projectShortName: proxyConfig.projectShortName,
});
} catch (e) {
// No injected epicenter === local dev
if (e instanceof ReferenceError) {
const envJson = require('./env.json');
const env = Object.assign({}, envJson, process.env);
config.setContext({
apiProtocol: 'https',
apiHost: env.API_HOST,
accountShortName: env.ACCOUNT_SHORT_NAME,
projectShortName: env.PROJECT_SHORT_NAME,
});
epicenter = {
proxyConfig: () => ({
externalPort: 80,
apiSharedSecret: env.API_SHARED_SECRET,
apiHost: config.apiHost,
accountShortName: config.accountShortName,
projectShortName: config.projectShortName,
}),
log: console.log,
};
// Strip the /proxy/<account>/<project> prefix that epicenter-libs adds
// to proxy URLs in the client; in production this prefix is transparent,
// but on a local server it appears at the beginning of every request URL.
app.use((req, res, next) => {
const proxyPrefix = new RegExp(
`^/proxy/${config.accountShortName}/${config.projectShortName}`
);
req.url = req.url.replace(proxyPrefix, '');
next();
});
}
}
In production, Epicenter routes /proxy/<account>/<project>/… requests to the proxy and strips the prefix before they arrive. Locally, the Express server receives the full path, and the middleware removes the prefix manually so that route patterns match identically in both environments.
Verify the caller
Every proxy route should confirm that the request comes from a valid session for this account and project before doing anything else. The verify middleware calls the Epicenter /verification endpoint with the caller's bearer token and rejects mismatches:
const { Router, Fault, SCOPE_BOUNDARY } = require('epicenter-libs');
const getAuthorizationHeader = (req) => {
const authorization = req.headers['authorization'];
return typeof authorization === 'string' ? authorization : undefined;
};
const verify = (epicenter) => async (req, res, next) => {
const authorization = getAuthorizationHeader(req);
if (!authorization) {
return res.status(401).json({ error: 'Unauthorized. Missing authorization.' });
}
try {
const session = await new Router()
.withAuthorization(authorization)
.get('/verification')
.then(({ body }) => body);
const reject = (reason) =>
res.status(401).json({ error: 'Unauthorized. ' + reason });
if (session.accountShortName !== epicenter.proxyConfig().accountShortName)
return reject('Account mismatch.');
if (session.projectShortName !== epicenter.proxyConfig().projectShortName)
return reject('Project mismatch.');
req.session = session;
return next();
} catch (error) {
if (error instanceof Fault) {
const { status, message, information } = error;
return res.status(status ?? 500).json({ message, information });
}
return res
.status(500)
.json({ error: 'Internal Server Error', message: String(error) });
}
};
verify attaches the verified session to req.session so later middleware can read the caller's userKey, groupKey, and other fields without a second network call.
Check episode and world access
Because the proxy carves out special exceptions to Epicenter permissions, we need to ensure that only the necessary minimum of information is exposed and only for a specific use case.
In this example, for routes that read another team's data, the caller must also belong to the same episode. The requireEpisodeWorldAccess middleware fetches all worlds in the requested episode using the caller's own token, then confirms that the target world exists in the episode and that the caller is assigned to some world there.
By fetching the world list using the caller's own token rather than the project token, this middleware confirms that the caller can already see the episode. The project token is only minted after both checks pass.
const isEpisodeWorld = (world, episodeKey) =>
world.orbitType?.toLowerCase() === SCOPE_BOUNDARY.EPISODE.toLowerCase() &&
world.orbitKey === episodeKey;
const requireEpisodeWorldAccess = async (req, res, next) => {
const authorization = getAuthorizationHeader(req);
if (!authorization) {
return res.status(401).json({ error: 'Unauthorized. Missing authorization.' });
}
if (!req.session?.userKey) {
return res.status(403).json({ error: 'Forbidden. Participant session required.' });
}
const { episodeKey, worldKey } = req.params;
try {
const worlds = await new Router()
.withAuthorization(authorization)
.get(`/world/with/${SCOPE_BOUNDARY.EPISODE}/${encodeURIComponent(episodeKey)}`)
.then(({ body }) => body);
const world = worlds.find(
(candidate) =>
candidate.worldKey === worldKey && isEpisodeWorld(candidate, episodeKey)
);
const ownWorld = worlds.find(
(candidate) =>
isEpisodeWorld(candidate, episodeKey) &&
candidate.assignments?.some(
(assignment) => assignment.user?.userKey === req.session.userKey
)
);
if (!world) {
return res.status(404).json({ error: 'World not found in episode.' });
}
if (!ownWorld) {
return res
.status(403)
.json({ error: 'Forbidden. No participant assignment in this episode.' });
}
req.world = world;
return next();
} catch (error) {
if (error instanceof Fault) {
const { status, message, information } = error;
return res.status(status ?? 500).json({ message, information });
}
return res
.status(500)
.json({ error: 'Internal Server Error', message: String(error) });
}
};
Mint a project token
When a route needs to read data the caller cannot access with their own token (such as another team's run), the empowerWithProjectToken middleware authenticates with the project's shared secret and attaches the resulting project-level bearer token to the request:
const { Router, Fault } = require('epicenter-libs');
const empowerWithProjectToken = (epicenter) => async (req, res, next) => {
try {
const session = await new Router()
.post('/authentication', {
inert: true,
includeAuthorization: false,
body: {
objectType: 'account',
secretKey: epicenter.proxyConfig().apiSharedSecret,
},
})
.then(({ body }) => body);
if (!session?.token) {
return res.status(500).json({
error: 'Internal Server Error',
message: 'Project authentication response did not include a token.',
});
}
req.projectAuthorization = `Bearer ${session.token}`;
return next();
} catch (error) {
if (error instanceof Fault) {
const { status, message, information } = error;
return res.status(status ?? 500).json({ message, information });
}
return res
.status(500)
.json({ error: 'Internal Server Error', message: String(error) });
}
};
The project token grants broad read access across the project. Always place empowerWithProjectToken after verify and any access-check middleware so the privileged token is only minted after the caller's identity and permissions have been confirmed. Never pass req.projectAuthorization back to the client.
Serve the public variables
Wire the three middleware in the correct order to guarantee that the app can read only the allowlisted public variables using the project token. The PUBLIC_WORLD_VARIABLES array lists the variables available to the participants in the same episode:
/**
* The variables from a team's world run that same-episode peers may read.
* Widening the carveout is a deliberate, reviewable edit to this one array;
* `private_note` is absent here by construction.
*/
const PUBLIC_WORLD_VARIABLES = ['signal', 'pitch'];
const readPublicWorldVariables = async (req, res) => {
const { world } = req;
const { variableNames } = req.params;
try {
const requestedVariables = variableNames.split(';').filter(Boolean);
if (!requestedVariables.length) {
return res.status(400).json({ error: 'No public variables requested.' });
}
if (
requestedVariables.some(
(variableName) => !PUBLIC_WORLD_VARIABLES.includes(variableName)
)
) {
return res.status(404).json({ error: 'Public variable not found.' });
}
if (!world.runKey) {
return res.status(404).json({ error: 'World has no associated run.' });
}
const variables = await runAdapter.getVariables(world.runKey, requestedVariables, {
authorization: req.projectAuthorization,
});
return res.status(200).json(variables);
} catch (error) {
if (error instanceof Fault) {
const { status, message, information } = error;
return res.status(status ?? 500).json({ message, information });
}
return res
.status(500)
.json({ error: 'Internal Server Error', message: String(error) });
}
};
app.get(
'/world/:episodeKey/:worldKey/public/:variableNames',
verify(epicenter),
requireEpisodeWorldAccess,
empowerWithProjectToken(epicenter),
readPublicWorldVariables
);
Any variable name not in PUBLIC_WORLD_VARIABLES returns a 404 response, so even the existence of private variable names is not confirmed to the caller.
verify → requireEpisodeWorldAccess → empowerWithProjectToken → handler. Changing the order would break the security guarantee: for example, running empowerWithProjectToken before requireEpisodeWorldAccess would mint the project token even when the caller has no assignment in the episode.
Stub a server-held secret
The /completion route demonstrates the "hold a private key server-side" pattern without making any external API call. The key is read from server-side configuration and never reaches the browser:
const privateEnv = () => {
try {
return require('./env.json');
} catch (_error) {
return process.env;
}
};
const completion = async (req, res) => {
const { prompt } = req.body;
const env = privateEnv();
const hasOpenAIKey = Boolean(env.OPENAI_API_KEY);
return res.status(200).json({
data: prompt,
hasOpenAIKey,
});
};
app.post('/completion', verify(epicenter), completion);
hasOpenAIKey only reports whether the key is present. The key itself is never returned. Apply verify here too: even routes that hold secrets rather than upgrade permissions should confirm the caller belongs to this project.
Calling the proxy from the client
epicenter-libs exposes the proxy base URL through config. The client constructs the full URL and passes the user's bearer token in the Authorization header:
import { config, type UserSession } from 'epicenter-libs';
const proxyBase = () =>
config.apiProtocol
.concat('://')
.concat(config.apiHost)
.concat(`/proxy/${config.accountShortName}/${config.projectShortName}`);
const readPublicWorldVariables = async ({
token,
episodeKey,
worldKey,
variableNames,
}: {
token: string;
episodeKey: string;
worldKey: string;
variableNames: readonly string[];
}): Promise<PublicPosture> => {
const response = await fetch(
`${proxyBase()}/world/${encodeURIComponent(episodeKey)}/${encodeURIComponent(
worldKey
)}/public/${encodeURIComponent(variableNames.join(';'))}`,
{
headers: {
authorization: `Bearer ${token}`,
},
}
);
const body = await response.json().catch(() => null);
if (!response.ok) {
const error =
typeof body?.error === 'string'
? body.error
: typeof body?.message === 'string'
? body.message
: 'Unavailable';
throw new Error(error);
}
return body as PublicPosture;
};
The reference application wraps this in a TanStack Query queryOptions object, keyed on the session token, episode, and world, so the cache invalidates correctly when a desk locks and the floor is revealed:
export const PUBLIC_WORLD_VARIABLES = ['signal', 'pitch'] as const;
export type PublicWorldVariableName = (typeof PUBLIC_WORLD_VARIABLES)[number];
export type PublicPosture = Partial<Record<PublicWorldVariableName, string>>;
const publicWorldVariables = ({
session,
episodeKey,
worldKey,
variableNames,
}: {
session: UserSession;
episodeKey: string;
worldKey: string;
variableNames: readonly PublicWorldVariableName[];
}) =>
queryOptions({
queryKey: [
'proxy',
'episode',
session.token,
episodeKey,
'world',
worldKey,
'public',
variableNames,
],
queryFn: () =>
readPublicWorldVariables({
token: session.token,
episodeKey,
worldKey,
variableNames,
}),
staleTime: 5_000,
});
export const ProxyQuery = {
publicWorldVariables,
publishFloorChange,
};
The floor data is considered fresh for 5 seconds (staleTime: 5_000). Public world variables are cheap to re-read and change continuously as desks lock, so a short stale time is appropriate here.
Triggering real-time updates with a push channel
When a desk locks, it publishes a message to the group's CONTROL push channel so all other open sessions know to refetch that desk's public variables. The publish call is made with the Channel class from epicenter-libs:
import { Channel, PUSH_CATEGORY, SCOPE_BOUNDARY } from 'epicenter-libs';
import { type FloorChannelContent } from '~/types/push';
const publishFloorChange = ({
session,
activity,
episodeKey,
worldKey,
runKey,
}: PublishFloorChangeInput) => {
if (!session.groupKey) {
return Promise.reject(new Error('Cannot publish floor changes without a group.'));
}
return new Channel({
scopeBoundary: SCOPE_BOUNDARY.GROUP,
scopeKey: session.groupKey,
pushCategory: PUSH_CATEGORY.CONTROL,
}).publish({
groupKey: session.groupKey,
objectType: 'floor',
activity,
episodeKey,
worldKey,
runKey,
} satisfies FloorChannelContent);
};
The player submits their desk and then publishes the floor change in parallel:
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const signal = formData.get('signal');
const pitch = formData.get('pitch');
const privateNote = formData.get('private_note');
return runAdapter
.updateVariables(run.runKey, {
signal,
pitch: pitch.trim(),
private_note: privateNote.trim(),
ready: true,
})
.then(() => Promise.all([invalidateRun(), publishFloorChange('lock')]));
};
On the receiving side, the player shell subscribes to the CONTROL channel and invalidates the cached proxy query for the specific world that just changed:
const controlChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.GROUP,
scopeKey: session.groupKey!,
pushCategory: PUSH_CATEGORY.CONTROL,
});
const onControlChannelPush = useCallback(
(message: FloorChannelContent) => {
switch (message.objectType) {
case 'floor':
return queryClient.invalidateQueries(
ProxyQuery.publicWorldVariables({
session,
episodeKey: message.episodeKey,
worldKey: message.worldKey,
variableNames: PUBLIC_WORLD_VARIABLES,
})
);
default:
console.warn('Unknown control channel message', message);
}
},
[queryClient, session]
);
useChannelEffect({
token: session.token,
channel: controlChannel,
callback: onControlChannelPush,
});
Because the query key includes worldKey, invalidating a single world's cached variables does not force the other worlds to refetch. Only the desk that just locked triggers a new request.
Rendering the floor reveal
The player's home screen reads the public posture of all other worlds in the episode. The floorQueries array is built with TanStack Query's useQueries, one query per world, each enabled only after the current desk is locked (ready === true):
const ready = variables.ready === true;
const floorWorlds = worlds
.filter((floorWorld) => floorWorld.worldKey !== world.worldKey)
.sort((a, b) => nameForWorld(a).localeCompare(nameForWorld(b)));
const floorQueries = useQueries({
queries: floorWorlds.map((floorWorld) => ({
...ProxyQuery.publicWorldVariables({
session,
episodeKey: episode.episodeKey,
worldKey: floorWorld.worldKey,
variableNames: PUBLIC_WORLD_VARIABLES,
}),
enabled: ready,
})),
});
Before the desk is locked, the floor panel shows a gate prompt instead of data. After locking, the table renders each desk's signal and pitch or an 'empty' / 'error' state when a desk has not yet locked, or the request fails:
{ready ? (
<Table compact hover>
<thead>
<tr>
<th>Desk</th>
<th>Stance</th>
<th>Pitch</th>
</tr>
</thead>
<tbody>
{floor.map((row) => (
<tr key={row.worldKey} data-status={row.status}>
<td>{row.worldName}</td>
<td>
{row.status === 'ok' ? (
<span data-signal={row.signal}>{signalLabel(row.signal)}</span>
) : (
<span>—</span>
)}
</td>
<td>
{row.status === 'ok' && (row.pitch || 'No pitch')}
{row.status === 'empty' && 'Not locked'}
{row.status === 'error' && (row.error ?? 'Unavailable')}
</td>
</tr>
))}
</tbody>
</Table>
) : (
<div>Lock your desk to open the floor</div>
)}
Summary
| Step | Where | What |
|---|---|---|
| 1 | proxy/index.ctx2 | Declare the proxy runtime with "language": "JAVASCRIPT_20". |
| 2 | proxy/index.js | Configure the Express server. Use the injected epicenter global in production and proxy/env.json locally. |
| 3 | proxy/middleware/verify.js | Verify the caller by checking the bearer token against /verification and confirming the account and project match. |
| 4 | proxy/middleware/verify.js | Check episode and world access with requireEpisodeWorldAccess. The target world must be in the episode, and the caller must have an assignment there. |
| 5 | proxy/middleware/empowerWithProjectToken.js | Mint a project token only after all caller checks pass, and attach it to the request for use by the handler. |
| 6 | proxy/index.js | Serve the public variables from PUBLIC_WORLD_VARIABLES. Reject any requested variable not on the allowlist as 404. |
| 7 | proxy/index.js | Stub a server-held secret with /completion. Confirm the caller with verify and never return the secret key itself. |
| 8 | src/query/proxy.ts | Call the proxy from the client using fetch with the user's bearer token, wrapped in a TanStack Query queryOptions object. |
| 9 | src/query/proxy.ts / play.tsx | Trigger real-time updates by publishing a CONTROL channel message on lock, and subscribing to invalidate the affected world's cached proxy query. |
| 10 | src/routes/play/index/index.tsx | Render the floor reveal with useQueries, gated on ready === true, and handle 'empty' and 'error' states gracefully. |