Skip to content

Commit 0f07dc7

Browse files
feat(agentex-ui): account picker via same-origin BFF proxy (#350)
## Summary First of a **2-PR stack** bringing account scoping + OIDC login to agentex-ui. This base PR routes the SDK through a same-origin BFF and adds an account switcher. Chart + deploy side lives in scaleapi/sgp#3961. - Routes the agentex SDK through a same-origin **BFF** (`/api/agentex`) instead of calling the API directly from the browser — the upstream URL and credentials never reach client JS. - Shared `applyBffCredentials` forwards `x-selected-account-id`, drops any client-sent `Authorization`, and strips the account-scoped `_jwt` cookie so SGP honors the **selected** account rather than the one you linked in with (identity stays via `_identityJwt`). The proxy also strips `Location` on upstream 3xx so internal redirect targets don't leak. - Account selection is driven by the `account_id` query param (no cookie), injected on every SDK request via a synchronous ref. Switching accounts resets the open task + selected agent to the account's home grid, and `resetQueries` drops the previous account's cached data so it never briefly renders the old agents. - `/api/user-info` fetches the caller's accounts; the picker bootstraps to the first when the param is missing/stale, shows a disabled/empty loading state (not a skeleton), renders single-account as static context, and lives in the sidebar footer (collapsed → icon-only). - Env-gated on the platform API being configured (`SGP_API_URL` / `NEXT_PUBLIC_SGP_APP_URL`); no-op otherwise. **Env:** `NEXT_PUBLIC_AGENTEX_API_BASE_URL` → server-only `AGENTEX_API_URL`. In this PR the BFF runs in default (cookie) mode — no auth dependency. The token-hiding Bearer path arrives in the stacked PR. ## Stack 1. **This PR** — account picker + same-origin BFF proxy → `main` 2. #351 — OIDC login with server-side access token → stacked on this branch 3. scaleapi/sgp#3961 — Helm chart + system-manager pack (deploy side) ## Test plan - [x] `npm run typecheck` / `npm run lint` — clean - [x] Runtime: switching accounts re-scopes agents/tasks (and SGP calls via the `_jwt` strip); single / multi / no-account render; loading state; no stale-account flash 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3b722f6 commit 0f07dc7

16 files changed

Lines changed: 523 additions & 97 deletions

File tree

agentex-ui/DOCKER.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,13 @@ The application runs with the following default environment variables:
9191
- `PORT=3000`
9292
- `HOSTNAME=0.0.0.0`
9393

94-
To configure public runtime variables, pass them when running the container:
94+
To configure runtime variables, pass them when running the container:
9595

9696
```bash
9797
# Explicit env flags
9898
docker run --rm -p 3000:3000 \
99-
-e NEXT_PUBLIC_AGENTEX_API_BASE_URL=http://localhost:5003 \
100-
-e NEXT_PUBLIC_SGP_APP_URL=https://egp.dashboard.scale.com \
99+
-e AGENTEX_API_URL=http://localhost:5003 \
100+
-e NEXT_PUBLIC_SGP_APP_URL=https://app.example.com \
101101
agentex-ui:latest
102102

103103
# Or via an env file

agentex-ui/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ cp example.env.development .env.development
9696
Edit `.env.development` with your configuration:
9797

9898
```bash
99-
# Backend API endpoint
100-
NEXT_PUBLIC_AGENTEX_API_BASE_URL=http://localhost:5003
99+
# Backend API endpoint — server-only upstream for the /api/agentex BFF proxy
100+
AGENTEX_API_URL=http://localhost:5003
101101
```
102102

103103
### 3. Install Dependencies

agentex-ui/app/api/_lib/bff.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/** Server-only platform API base for the BFF routes. Prefers SGP_API_URL, else the app URL. */
2+
export const SGP_BASE_URL =
3+
process.env.SGP_API_URL ??
4+
(process.env.NEXT_PUBLIC_SGP_APP_URL
5+
? `${process.env.NEXT_PUBLIC_SGP_APP_URL}/api`
6+
: undefined);
7+
8+
/** Return the Cookie header with the named cookie removed (null if nothing remains). */
9+
function stripCookie(header: string | null, name: string): string | null {
10+
if (!header) return null;
11+
const kept = header
12+
.split(';')
13+
.map(c => c.trim())
14+
.filter(c => {
15+
const eq = c.indexOf('=');
16+
return (eq === -1 ? c : c.slice(0, eq)) !== name;
17+
});
18+
return kept.length ? kept.join('; ') : null;
19+
}
20+
21+
/**
22+
* Attach credentials to an upstream request's `headers` in place, so none reach client JS:
23+
* forward `x-selected-account-id` (the upstream authorizes the account), drop any
24+
* client-sent `authorization`, and forward cookies for the upstream's own auth — minus the
25+
* account-scoped `_jwt` (access-profile) cookie. Cookie-auth backends prioritize `_jwt` over
26+
* `x-selected-account-id`, so leaving it would pin the account to the one the user linked in
27+
* with and ignore the selected account; identity still comes from `_identityJwt`.
28+
*/
29+
export async function applyBffCredentials(
30+
req: Request,
31+
headers: Headers
32+
): Promise<void> {
33+
const accountId = req.headers.get('x-selected-account-id');
34+
if (accountId) headers.set('x-selected-account-id', accountId);
35+
else headers.delete('x-selected-account-id');
36+
37+
headers.delete('authorization');
38+
39+
const cookie = stripCookie(req.headers.get('cookie'), '_jwt');
40+
if (cookie) headers.set('cookie', cookie);
41+
else headers.delete('cookie');
42+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { applyBffCredentials } from '@/app/api/_lib/bff';
2+
3+
/**
4+
* Same-origin BFF proxy for the Agentex API, so the upstream URL and credentials never
5+
* reach client JS. applyBffCredentials attaches credentials server-side.
6+
*/
7+
export const dynamic = 'force-dynamic';
8+
9+
const UPSTREAM = (
10+
process.env.AGENTEX_API_URL ?? 'http://localhost:5003'
11+
).replace(/\/$/, '');
12+
13+
// Hop-by-hop headers to drop. Credential headers (cookie/authorization) are handled by
14+
// applyBffCredentials, not here.
15+
const STRIP_REQ = ['host', 'connection', 'content-length'];
16+
const STRIP_RES = [
17+
'content-encoding',
18+
'content-length',
19+
'transfer-encoding',
20+
'connection',
21+
];
22+
23+
async function proxy(
24+
req: Request,
25+
ctx: { params: Promise<{ path?: string[] }> }
26+
): Promise<Response> {
27+
const { path = [] } = await ctx.params;
28+
const search = new URL(req.url).search;
29+
const target = `${UPSTREAM}/${path.join('/')}${search}`;
30+
31+
const headers = new Headers(req.headers);
32+
for (const h of STRIP_REQ) headers.delete(h);
33+
await applyBffCredentials(req, headers);
34+
35+
const method = req.method.toUpperCase();
36+
const hasBody = method !== 'GET' && method !== 'HEAD';
37+
const upstream = await fetch(target, {
38+
method,
39+
headers,
40+
body: hasBody ? req.body : undefined,
41+
redirect: 'manual',
42+
// @ts-expect-error `duplex` is required to stream a request body (undici)
43+
duplex: 'half',
44+
});
45+
46+
// Pass the upstream body through unbuffered so SSE / streaming responses work.
47+
const resHeaders = new Headers(upstream.headers);
48+
for (const h of STRIP_RES) resHeaders.delete(h);
49+
// Don't leak an upstream (internal) redirect target to the browser.
50+
if (upstream.status >= 300 && upstream.status < 400) {
51+
resHeaders.delete('location');
52+
}
53+
return new Response(upstream.body, {
54+
status: upstream.status,
55+
headers: resHeaders,
56+
});
57+
}
58+
59+
export {
60+
proxy as DELETE,
61+
proxy as GET,
62+
proxy as HEAD,
63+
proxy as OPTIONS,
64+
proxy as PATCH,
65+
proxy as POST,
66+
proxy as PUT,
67+
};

agentex-ui/app/api/feedback/route.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { NextResponse } from 'next/server';
22

3+
import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff';
4+
35
type FeedbackRequestBody = {
46
traceId: string;
57
messageId: string;
@@ -13,33 +15,10 @@ type FeedbackRequestBody = {
1315
agentAcpType?: string;
1416
};
1517

16-
const SGP_BASE_URL =
17-
process.env.NEXT_PUBLIC_SGP_API_URL ??
18-
(process.env.NEXT_PUBLIC_SGP_APP_URL
19-
? `${process.env.NEXT_PUBLIC_SGP_APP_URL}/api`
20-
: undefined);
21-
22-
function getSGPHeaders(request: Request): Record<string, string> {
23-
const headers: Record<string, string> = {
24-
'Content-Type': 'application/json',
25-
};
26-
const forwarded = [
27-
'cookie',
28-
'authorization',
29-
'x-api-key',
30-
'x-selected-account-id',
31-
];
32-
for (const key of forwarded) {
33-
const value = request.headers.get(key);
34-
if (value) headers[key] = value;
35-
}
36-
return headers;
37-
}
38-
3918
async function sgpPost<T>(
4019
path: string,
4120
body: unknown,
42-
headers: Record<string, string>
21+
headers: Headers
4322
): Promise<T> {
4423
const res = await fetch(`${SGP_BASE_URL}${path}`, {
4524
method: 'POST',
@@ -57,8 +36,7 @@ export async function POST(request: Request) {
5736
if (!SGP_BASE_URL) {
5837
return NextResponse.json(
5938
{
60-
error:
61-
'SGP feedback is not configured. Set NEXT_PUBLIC_SGP_API_URL or NEXT_PUBLIC_SGP_APP_URL.',
39+
error: 'SGP feedback is not configured. Set SGP_API_URL.',
6240
},
6341
{ status: 503 }
6442
);
@@ -100,7 +78,9 @@ export async function POST(request: Request) {
10078
);
10179
}
10280

103-
const sgpHeaders = getSGPHeaders(request);
81+
// Credentials attached server-side (see applyBffCredentials).
82+
const sgpHeaders = new Headers({ 'Content-Type': 'application/json' });
83+
await applyBffCredentials(request, sgpHeaders);
10484
const now = new Date().toISOString();
10585

10686
try {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { applyBffCredentials, SGP_BASE_URL } from '@/app/api/_lib/bff';
4+
5+
/**
6+
* Scoped BFF proxy for the caller's accounts (access_profiles), used to bootstrap/switch
7+
* the selected account. Only this path is exposed — not a catch-all — so the browser can't
8+
* reach arbitrary platform endpoints with the server-attached credentials.
9+
*/
10+
export const dynamic = 'force-dynamic';
11+
12+
export async function GET(request: Request): Promise<Response> {
13+
if (!SGP_BASE_URL) {
14+
return NextResponse.json(
15+
{ error: 'SGP is not configured. Set SGP_API_URL.' },
16+
{ status: 503 }
17+
);
18+
}
19+
20+
const headers = new Headers({ accept: 'application/json' });
21+
await applyBffCredentials(request, headers);
22+
const upstream = await fetch(`${SGP_BASE_URL}/user-info`, { headers });
23+
return new Response(upstream.body, {
24+
status: upstream.status,
25+
headers: { 'content-type': 'application/json' },
26+
});
27+
}

agentex-ui/app/page.tsx

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,13 @@ export default async function RootPage() {
77
await connection();
88

99
const sgpAppURL = process.env.NEXT_PUBLIC_SGP_APP_URL ?? '';
10-
const agentexAPIBaseURL =
11-
process.env.NEXT_PUBLIC_AGENTEX_API_BASE_URL ?? 'http://localhost:5003';
12-
13-
if (!agentexAPIBaseURL) {
14-
return (
15-
<div role="alert">
16-
<p>Missing some configs</p>
17-
<pre>{JSON.stringify({ sgpAppURL, agentexAPIBaseURL }, null, 2)}</pre>
18-
</div>
19-
);
20-
}
10+
// The account picker needs the platform API (accounts come from /api/user-info).
11+
const accountsEnabled = !!(
12+
process.env.SGP_API_URL ?? process.env.NEXT_PUBLIC_SGP_APP_URL
13+
);
2114

2215
return (
23-
<AgentexProvider
24-
sgpAppURL={sgpAppURL}
25-
agentexAPIBaseURL={agentexAPIBaseURL}
26-
>
16+
<AgentexProvider accountsEnabled={accountsEnabled} sgpAppURL={sgpAppURL}>
2717
<AgentexUIRoot />
2818
</AgentexProvider>
2919
);

0 commit comments

Comments
 (0)