Skip to content

Commit 747b446

Browse files
committed
fix(showcase/shell-dashboard): proxy contract hardening — build-arg, normalization, empty-env, tests
CR R1 follow-ups on top of the /api/ops proxy fix. Bucket (a) — must-fix: - Dockerfile: declare ARG/ENV OPS_BASE_URL in the builder stage. next.config.ts evaluates rewrites() at build time and throws if OPS_BASE_URL is unset, which aborted `next build` in CI. Mirrors the existing NEXT_PUBLIC_SHELL_URL / NEXT_PUBLIC_POCKETBASE_URL pattern. - showcase_deploy.yml: pipe OPS_BASE_URL through to docker build for the shell-dashboard matrix entry, defaulting to the production showcase-ops-production.up.railway.app URL. - next.config.ts: strip trailing slashes from OPS_BASE_URL before constructing the rewrite destination, matching the same normalization in src/lib/ops-api.ts:resolveBaseUrl so server-side rewrite and client-side fetch agree on the URL shape. - src/lib/ops-api.ts: treat empty / whitespace NEXT_PUBLIC_OPS_BASE_URL as "no override". `??` only short-circuits on null/undefined, so an env var set to "" silently produced baseUrl="" and URLs of the form "/probes" with no /api/ops prefix. - use-probes.integration.test.tsx: snapshot+restore process.env.NEXT_PUBLIC_OPS_BASE_URL in beforeEach/afterEach so tests never leak env state. Strengthen the proxy contract assertions: lock toHaveBeenCalledTimes(1), assert method=GET, cache=no-store, accept JSON header, and signal is an AbortSignal. Tighten the 404 regression to assert the canonical ensureOk message shape so a refactor that changes the format trips the test. Bucket (b) — applied since the diff stayed focused: - triggerProbe: add cache:"no-store" for parity with the GET fetches. - fetchProbeDetail / triggerProbe: throw early when id is empty so callers get a clean error instead of a request to /probes//... . - ensureOk: bump body-truncation cap from 200 to 500 chars and append a `[truncated, N bytes total]` marker so operators can see they're missing tail bytes when the server returns a long HTML/stack-trace body. - ops-api.ts: drop dev-loop review-cycle tag prefixes (R2-C.3, R3-C, R3-D.1) from comments. Keep the actual rationale. - ops-api.ts header docstring: clarify that NEXT_PUBLIC_OPS_BASE_URL is read live at runtime in this codebase (SSR + tests), not just statically inlined into the client bundle. Verified: - Tests: vitest run — 26 files, 293 passed, 1 skipped (no test count change). - Typecheck: tsc --noEmit clean. - Lint + format: oxlint + oxfmt clean on changed files. - Local Docker build: `docker build --build-arg OPS_BASE_URL=https://...` succeeds. Without --build-arg the build fails with "OPS_BASE_URL must be set" as expected, confirming the fix is load-bearing.
1 parent c666dec commit 747b446

5 files changed

Lines changed: 90 additions & 22 deletions

File tree

.github/workflows/showcase_deploy.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ jobs:
230230
{"dispatch_name":"starter-spring-ai","filter_key":"starter_spring_ai","context":"showcase/starters/spring-ai","image":"showcase-starter-spring-ai","railway_id":"3559ece3-7ba3-41ac-b24c-1f780133ec58","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
231231
{"dispatch_name":"starter-strands","filter_key":"starter_strands","context":"showcase/starters/strands","image":"showcase-starter-strands","railway_id":"06db2bb8-e15d-4c6a-97ad-e14777c92d9f","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
232232
{"dispatch_name":"shell-dojo","filter_key":"shell_dojo","context":".","image":"showcase-shell-dojo","railway_id":"7ad1ece7-2228-49cd-8a78-bddf30322907","timeout":10,"lfs":false,"build_args":"","dockerfile":"showcase/shell-dojo/Dockerfile","health_path":"/"},
233-
{"dispatch_name":"shell-dashboard","filter_key":"shell_dashboard","context":".","image":"showcase-shell-dashboard","railway_id":"4d5dfd74-be61-40b2-8564-b53b7dd4c15b","timeout":10,"lfs":true,"build_args_sha":"${{ github.sha }}","build_args_branch":"${{ github.ref_name }}","build_args_pb_url":"https://showcase-pocketbase-production.up.railway.app","build_args_shell_url":"https://showcase.copilotkit.ai","dockerfile":"showcase/shell-dashboard/Dockerfile","health_path":"/"},
233+
{"dispatch_name":"shell-dashboard","filter_key":"shell_dashboard","context":".","image":"showcase-shell-dashboard","railway_id":"4d5dfd74-be61-40b2-8564-b53b7dd4c15b","timeout":10,"lfs":true,"build_args_sha":"${{ github.sha }}","build_args_branch":"${{ github.ref_name }}","build_args_pb_url":"https://showcase-pocketbase-production.up.railway.app","build_args_shell_url":"https://showcase.copilotkit.ai","build_args_ops_url":"https://showcase-ops-production.up.railway.app","dockerfile":"showcase/shell-dashboard/Dockerfile","health_path":"/"},
234234
{"dispatch_name":"shell-docs","filter_key":"shell_docs","context":".","image":"showcase-shell-docs","railway_id":"7badfb8d-4228-414c-9145-b4026803714f","timeout":10,"lfs":true,"build_args_sha":"${{ github.sha }}","build_args_branch":"${{ github.ref_name }}","dockerfile":"showcase/shell-docs/Dockerfile","health_path":"/"},
235235
{"dispatch_name":"showcase-ops","filter_key":"showcase_ops","context":".","image":"showcase-ops","railway_id":"3a14bfed-0537-4d71-897b-7c593dca161d","timeout":20,"lfs":false,"build_args":"","dockerfile":"showcase/ops/Dockerfile","health_path":"/health"}
236236
]'
@@ -352,6 +352,11 @@ jobs:
352352
if [ -n "${{ matrix.service.build_args_shell_url }}" ]; then
353353
ARGS="${ARGS:+${ARGS}$'\n'}NEXT_PUBLIC_SHELL_URL=${{ matrix.service.build_args_shell_url }}"
354354
fi
355+
if [ -n "${{ matrix.service.build_args_ops_url }}" ]; then
356+
# OPS_BASE_URL is required at build time by shell-dashboard's
357+
# next.config.ts rewrites() — without it `next build` aborts.
358+
ARGS="${ARGS:+${ARGS}$'\n'}OPS_BASE_URL=${{ matrix.service.build_args_ops_url }}"
359+
fi
355360
# Use delimiter to safely pass multiline value
356361
echo "args<<BUILDARGS_EOF" >> $GITHUB_OUTPUT
357362
echo "$ARGS" >> $GITHUB_OUTPUT

showcase/shell-dashboard/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ ARG NEXT_PUBLIC_SHELL_URL
3434
ENV NEXT_PUBLIC_SHELL_URL=${NEXT_PUBLIC_SHELL_URL}
3535
ARG NEXT_PUBLIC_POCKETBASE_URL
3636
ENV NEXT_PUBLIC_POCKETBASE_URL=${NEXT_PUBLIC_POCKETBASE_URL}
37+
# `next.config.ts` reads OPS_BASE_URL inside `rewrites()` and throws if it
38+
# is unset. `next build` evaluates rewrites at build time to generate the
39+
# routing manifest, so the value must be in the BUILDER environment — not
40+
# just runtime. Without this, `npx next build` aborts with the
41+
# "OPS_BASE_URL must be set" error from next.config.ts.
42+
ARG OPS_BASE_URL
43+
ENV OPS_BASE_URL=${OPS_BASE_URL}
3744

3845
# Generate registry + docs status, then build Next.js
3946
RUN cd scripts && node node_modules/tsx/dist/cli.mjs generate-registry.ts \

showcase/shell-dashboard/next.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ const nextConfig: NextConfig = {
2525
"(without it, /api/ops/* requests cannot proxy to showcase-ops)",
2626
);
2727
}
28+
// Strip trailing slashes so we never produce `https://host//api/...`
29+
// (some servers reject the double slash). Mirrors the same
30+
// normalization in `src/lib/ops-api.ts:resolveBaseUrl` so the
31+
// server-side rewrite and client-side fetch agree on the URL shape.
32+
const normalized = opsBase.replace(/\/+$/, "");
2833
return [
29-
{ source: "/api/ops/:path*", destination: `${opsBase}/api/:path*` },
34+
{ source: "/api/ops/:path*", destination: `${normalized}/api/:path*` },
3035
];
3136
},
3237
};

showcase/shell-dashboard/src/hooks/use-probes.integration.test.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useProbes } from "./use-probes";
2424
import type { ProbesResponse } from "../lib/ops-api";
2525

2626
let fetchSpy: ReturnType<typeof vi.fn>;
27+
let savedOpsBaseUrl: string | undefined;
2728

2829
function jsonResponse(body: unknown): Response {
2930
return new Response(JSON.stringify(body), {
@@ -35,12 +36,21 @@ function jsonResponse(body: unknown): Response {
3536
beforeEach(() => {
3637
fetchSpy = vi.fn();
3738
vi.stubGlobal("fetch", fetchSpy);
39+
// Snapshot + clear NEXT_PUBLIC_OPS_BASE_URL so the resolveBaseUrl()
40+
// fallback path is exercised. Restored in afterEach so test ordering
41+
// never leaks env state to the next file.
42+
savedOpsBaseUrl = process.env.NEXT_PUBLIC_OPS_BASE_URL;
3843
delete process.env.NEXT_PUBLIC_OPS_BASE_URL;
3944
});
4045

4146
afterEach(() => {
4247
vi.unstubAllGlobals();
4348
vi.restoreAllMocks();
49+
if (savedOpsBaseUrl !== undefined) {
50+
process.env.NEXT_PUBLIC_OPS_BASE_URL = savedOpsBaseUrl;
51+
} else {
52+
delete process.env.NEXT_PUBLIC_OPS_BASE_URL;
53+
}
4454
});
4555

4656
describe("useProbes → ops-api → fetch wiring", () => {
@@ -66,9 +76,21 @@ describe("useProbes → ops-api → fetch wiring", () => {
6676
// The hook must call fetch through ops-api with the same-origin proxy
6777
// path. Anything else (the dashboard origin + /api/ops, or an inlined
6878
// ops base URL) means the rewrite contract is broken.
69-
expect(fetchSpy).toHaveBeenCalled();
79+
expect(fetchSpy).toHaveBeenCalledTimes(1);
7080
const url = String(fetchSpy.mock.calls[0]![0]);
7181
expect(url).toBe("/api/ops/probes");
82+
83+
// Lock in the request shape the proxy contract depends on: GET (no
84+
// method override), no-store cache (live polling, never serve a stale
85+
// response), JSON accept header, and an AbortSignal for hook-level
86+
// cancellation. A regression in any of these silently breaks polling.
87+
const callArg = fetchSpy.mock.calls[0]![1] as RequestInit | undefined;
88+
expect(callArg?.method).toBe("GET");
89+
expect(callArg?.cache).toBe("no-store");
90+
expect(callArg?.headers).toMatchObject({
91+
accept: expect.stringMatching(/json/i),
92+
});
93+
expect(callArg?.signal).toBeInstanceOf(AbortSignal);
7294
});
7395

7496
it("parses probes returned by the proxy and exposes them on data", async () => {
@@ -120,8 +142,12 @@ describe("useProbes → ops-api → fetch wiring", () => {
120142
const { result } = renderHook(() => useProbes());
121143
await waitFor(() => expect(result.current.error).not.toBeNull());
122144

123-
expect(result.current.error?.message).toMatch(/404/);
124-
expect(result.current.error?.message).toMatch(/\/api\/ops\/probes/);
145+
// Tighten to the canonical `ensureOk` shape so a future refactor that
146+
// changes the error format (status/statusText/url ordering) trips this
147+
// test instead of silently regressing the operator-facing message.
148+
expect(result.current.error?.message).toMatch(
149+
/^ops-api request failed: 404 Not Found at \/api\/ops\/probes/,
150+
);
125151
expect(result.current.data).toBeNull();
126152
});
127153
});

showcase/shell-dashboard/src/lib/ops-api.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
*
1111
* `baseUrl` resolution order (per call):
1212
* 1. explicit `baseUrl` param (overrides everything; used in tests + SSR)
13-
* 2. `process.env.NEXT_PUBLIC_OPS_BASE_URL` (inlined at build — opt-in
14-
* escape hatch for direct cross-origin calls; production does NOT use
15-
* this because showcase-ops has no CORS allowlist)
13+
* 2. `process.env.NEXT_PUBLIC_OPS_BASE_URL` — opt-in escape hatch for
14+
* direct cross-origin calls; production does NOT use this because
15+
* showcase-ops has no CORS allowlist. Note: Next inlines `NEXT_PUBLIC_*`
16+
* into the client bundle at build time, but this module ALSO runs in
17+
* SSR + tests where `process.env` is read live at call time — so
18+
* `delete process.env.NEXT_PUBLIC_OPS_BASE_URL` in a test does take
19+
* effect on subsequent `resolveBaseUrl()` calls.
1620
* 3. `/api/ops` — same-origin path served by the Next.js rewrite in
1721
* `next.config.ts`. This is the production contract, not a guess: the
1822
* rewrite forwards `/api/ops/:path*` to `${OPS_BASE_URL}/api/:path*`
@@ -129,10 +133,16 @@ const FALLBACK_BASE_URL = "/api/ops";
129133
* Resolve the API base URL with the precedence documented at the top of
130134
* this module. Trailing slashes are stripped so callers don't end up with
131135
* a double-slash like `http://host//probes` that some servers reject.
136+
*
137+
* `NEXT_PUBLIC_OPS_BASE_URL` is treated as missing when it is undefined,
138+
* empty, or whitespace-only. `??` only falls through on `null`/`undefined`,
139+
* so without this an env var set to `""` would silently produce
140+
* `baseUrl === ""` and URLs like `"/probes"` (no `/api/ops` prefix) — a
141+
* silent failure mode where the dashboard hits its own origin and 404s.
132142
*/
133143
function resolveBaseUrl(explicit?: string): string {
134-
const raw =
135-
explicit ?? process.env.NEXT_PUBLIC_OPS_BASE_URL ?? FALLBACK_BASE_URL;
144+
const envBase = process.env.NEXT_PUBLIC_OPS_BASE_URL?.trim();
145+
const raw = explicit ?? (envBase || undefined) ?? FALLBACK_BASE_URL;
136146
return raw.replace(/\/+$/, "");
137147
}
138148

@@ -147,11 +157,21 @@ async function ensureOk(response: Response, url: string): Promise<void> {
147157
let detail = "";
148158
try {
149159
const text = await response.text();
150-
if (text && text.length <= 200) detail = ` — ${text}`;
160+
if (text) {
161+
if (text.length <= 500) {
162+
detail = ` — ${text}`;
163+
} else {
164+
// Bodies larger than 500B (often HTML 5xx pages or stack traces)
165+
// would balloon the error message and trash log readability;
166+
// truncate but keep the marker + total size so an operator can see
167+
// they're missing tail data and re-fetch from the source if needed.
168+
detail = ` — ${text.slice(0, 500)} [truncated, ${text.length} bytes total]`;
169+
}
170+
}
151171
} catch (bodyErr) {
152-
// R2-C.3: propagate AbortError as-is so hook-layer cancellation
153-
// filters (`err.name === "AbortError"`) keep working. Wrapping it in a
154-
// generic Error would surface "AbortError" as user-facing noise.
172+
// Propagate AbortError as-is so hook-layer cancellation filters
173+
// (`err.name === "AbortError"`) keep working. Wrapping it in a generic
174+
// Error would surface "AbortError" as user-facing noise.
155175
if ((bodyErr as { name?: string })?.name === "AbortError") {
156176
throw bodyErr;
157177
}
@@ -174,14 +194,13 @@ async function parseJson<T>(response: Response, url: string): Promise<T> {
174194
try {
175195
return (await response.json()) as T;
176196
} catch (parseErr) {
177-
// R2-C.3: propagate AbortError as-is — same rationale as ensureOk.
197+
// Propagate AbortError as-is — same rationale as ensureOk.
178198
if ((parseErr as { name?: string })?.name === "AbortError") {
179199
throw parseErr;
180200
}
181201
const msg = (parseErr as Error)?.message ?? "unknown";
182-
// R3-C bonus: preserve the original error as `cause` so debuggers can
183-
// walk back to the SyntaxError name/stack without re-parsing the
184-
// wrapped message string.
202+
// Preserve the original error as `cause` so debuggers can walk back to
203+
// the SyntaxError name/stack without re-parsing the wrapped message.
185204
throw new Error(`ops-api JSON parse failed at ${url}: ${msg}`, {
186205
cause: parseErr,
187206
});
@@ -199,9 +218,9 @@ export async function fetchProbes(
199218
} = {},
200219
): Promise<ProbesResponse> {
201220
const url = `${resolveBaseUrl(opts.baseUrl)}/probes`;
202-
// R3-D.1: opt out of browser + Next.js fetch caching. Status data is
203-
// polled every ~10s and must reflect the current backend state, not a
204-
// cached response from a prior poll.
221+
// Opt out of browser + Next.js fetch caching. Status data is polled
222+
// every ~10s and must reflect the current backend state, not a cached
223+
// response from a prior poll.
205224
const response = await fetch(url, {
206225
method: "GET",
207226
signal: opts.signal,
@@ -216,8 +235,9 @@ export async function fetchProbeDetail(
216235
id: string,
217236
opts: { signal?: AbortSignal; baseUrl?: string } = {},
218237
): Promise<ProbeDetailResponse> {
238+
if (!id) throw new Error("probe id is required");
219239
const url = `${resolveBaseUrl(opts.baseUrl)}/probes/${encodeURIComponent(id)}`;
220-
// R3-D.1: see fetchProbes — same no-store rationale for live detail data.
240+
// See fetchProbes — same no-store rationale for live detail data.
221241
const response = await fetch(url, {
222242
method: "GET",
223243
signal: opts.signal,
@@ -239,6 +259,7 @@ export async function triggerProbe(
239259
id: string,
240260
opts: TriggerProbeOptions,
241261
): Promise<TriggerResponse> {
262+
if (!id) throw new Error("probe id is required");
242263
const url = `${resolveBaseUrl(opts.baseUrl)}/probes/${encodeURIComponent(
243264
id,
244265
)}/trigger`;
@@ -255,6 +276,10 @@ export async function triggerProbe(
255276
authorization: `Bearer ${opts.token}`,
256277
},
257278
body: JSON.stringify(body),
279+
// No-store for parity with the GET fetches — POST responses can be
280+
// cached by intermediaries when CDN-fronted; explicit no-store
281+
// guarantees the trigger response goes end-to-end live.
282+
cache: "no-store",
258283
signal: opts.signal,
259284
});
260285
await ensureOk(response, url);

0 commit comments

Comments
 (0)