Skip to content

[2/4] feat(web): Gateway connection settings and session cookies - #77

Closed
immanuel-peter wants to merge 3 commits into
kvcache-ai:mainfrom
immanuel-peter:feat/web-2-connection
Closed

[2/4] feat(web): Gateway connection settings and session cookies#77
immanuel-peter wants to merge 3 commits into
kvcache-ai:mainfrom
immanuel-peter:feat/web-2-connection

Conversation

@immanuel-peter

@immanuel-peter immanuel-peter commented Jul 29, 2026

Copy link
Copy Markdown

What

Add Gateway connection Settings: httpOnly session cookies for Gateway URL / API key / optional admin token, connection validate API routes, and the shared gatewayFetch client foundation.

Why

Credential handling is the highest-scrutiny part of the Web UI and should be reviewable without the full resource-page surface area.

Related issue

Part of #6.

Scope and non-goals

In scope

  • web/src/lib/session.ts and connection API routes
  • gatewayFetch, errors, paging, shared types
  • Settings form/status UI (replaces stub)
  • Temporary dashboard placeholder that shows connection state

Non-goals

  • Sandbox/snapshot/template/node/dashboard feature UIs (PRs 3–4)

Design and behavior changes

  • Credentials stored in httpOnly cookies with no maxAge (cleared on logout / browser session end)
  • Server-side Gateway fetches only; secrets are not logged in full
  • AENV_DEFAULT_GATEWAY_URL prefills the Gateway field when set

Compatibility and operations

  • Public API or generated protocol: N/A (consumes existing Gateway HTTP API)
  • Configuration or defaults: optional AENV_DEFAULT_GATEWAY_URL
  • Snapshot manifest, artifact layout, or storage format: N/A
  • Upgrade and rollback: additive UI-only
  • Host requirements, permissions, ports, or dependencies: same as PR 1

Validation

  • Web lint/build

Commands and results:

cd web && pnpm lint && pnpm build  # pass

Skipped checks and reasons:

  • Rust/Go suites: no backend code changes in this slice

Risks and reviewer notes

Stack

Merge order: #76#77#78#79

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

immanuel-peter and others added 2 commits July 29, 2026 21:30
Add the Next.js app shell, shadcn UI kit, Compose/Kubernetes web service,
and getting-started docs. Feature routes are stubs until later PRs in the
stack land connection and resource views.

Co-authored-by: Cursor <cursoragent@cursor.com>
Store Gateway URL, API key, and optional admin token in httpOnly session
cookies, validate connectivity through the Next.js connection routes, and
replace the Settings stub with the real form.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 39 issue(s) in this PR.

  • ✅ Successfully posted inline: 39 comment(s)

⚠️ 7 warning(s) occurred during review.


⚠️ Warnings:

  • web/src/app/(console)/settings/page.tsx (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/app/(console)/dashboard/page.tsx (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/app/globals.css (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/components/ui/dropdown-menu.tsx (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/components/ui/tabs.tsx (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/lib/api/paging.ts (subtask_error): LLM completion error: POST "https://api.madsys.dev/v1/chat/completions": 429 Too Many Requests {"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}
  • web/src/lib/api/types.ts (subtask_error): LLM completion error: context deadline exceeded

Comment thread web/src/app/api/connection/route.ts Outdated
Comment on lines +26 to +27
export async function POST(request: Request) {
const resolved = await resolveConnectionInput(request);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Unexpected failures from input resolution, probing, or cookie writes are not handled. For example, a cookie write can fail when credentials exceed browser cookie limits, causing Next.js to return a generic 500 rather than the documented JSON response and a user-friendly error. Wrap the handler's async operations in try/catch, log the internal error safely, and return a typed ConnectionApiResponse with an appropriate status. The GET and DELETE handlers have the same error-response concern.

Comment thread web/src/app/api/connection/route.ts Outdated
return NextResponse.json(payload, { status: 400 });
}

const probe = await probeConnection(resolved.credentials);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
This turns a client-supplied Gateway URL into server-side HTTP requests. Without an authorization boundary and destination validation, callers can use this endpoint to probe services reachable only from the web server (loopback, private networks, or cloud metadata), making it an SSRF primitive. Restrict destinations to trusted Gateway hosts, or resolve and reject loopback/link-local/private addresses (including redirects and DNS rebinding), and protect this route with authentication/network policy if private Gateway addresses must remain supported.

Comment on lines +26 to +33
const NAV = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboardIcon },
{ href: "/nodes", label: "Nodes", icon: ServerIcon },
{ href: "/sandboxes", label: "Sandboxes", icon: BoxIcon },
{ href: "/snapshots", label: "Snapshots", icon: CameraIcon },
{ href: "/templates", label: "Templates", icon: LayersIcon },
{ href: "/settings", label: "Settings", icon: SettingsIcon },
] as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · medium]
These business route paths are hardcoded here, and /dashboard is already repeated by the branding link and the root redirect. A route rename can therefore leave navigation, redirection, and active-state detection inconsistent. Define these paths in a shared typed route map (for example, APP_ROUTES) and consume those constants both here and at other navigation/redirect call sites.

Comment on lines +149 to +153
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
AlertDialogAction is rendered as a plain button, so activating the confirmation action does not invoke the alert dialog's close behavior. Unlike AlertDialogCancel, this leaves an uncontrolled dialog open unless every caller manually manages its state. Compose the button through AlertDialogPrimitive.Close (while retaining the desired button props), so both standard actions dismiss the dialog as expected.

Suggestion:

Suggested change
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
<AlertDialogPrimitive.Close
data-slot="alert-dialog-action"
render={<Button className={cn(className)} />}
{...props}
/>

import { cn } from "@/lib/utils"

const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
AlertAction accepts arbitrary content, but the alert always reserves only pr-18 while positioning that content absolutely. A longer/localized action label or multiple controls can therefore overlap the title/description, especially in a narrow container. Please make the action part of the grid flow (or otherwise size the reserved column from its content) instead of relying on a fixed 4.5rem allowance.

Comment thread web/src/lib/session.ts
Comment on lines +25 to +28
const jar = await cookies();
const gatewayUrl = jar.get(SESSION_COOKIE.gatewayUrl)?.value;
const apiKey = jar.get(SESSION_COOKIE.apiKey)?.value;
const adminToken = jar.get(SESSION_COOKIE.adminToken)?.value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
This cookie-read and conversion logic is duplicated in getStoredConnectionFields, so validation or normalization changes can easily diverge between full-session and partial-update paths. Extract one internal raw/normalized field reader and have both public helpers apply their respective completeness rules to its result.

Comment thread web/src/lib/session.ts
Comment on lines +30 to +35
if (!gatewayUrl || !apiKey) {
return null;
}

return {
gatewayUrl: normalizeGatewayUrl(gatewayUrl),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Completeness is checked before normalization. A forged or legacy truthy cookie such as whitespace or / normalizes to an empty string, yet this function returns a configured session; summaries then report configured: true, while network calls fail with malformed URLs. Normalize and fully parse first, then return null unless the normalized URL and trimmed credentials are valid.

Comment thread web/src/lib/session.ts
Comment on lines +34 to +38
return {
gatewayUrl: normalizeGatewayUrl(gatewayUrl),
apiKey,
adminToken: adminToken || undefined,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
gatewayUrl comes from an unsigned, client-controlled cookie and is returned without URL or destination validation. gatewayFetch subsequently concatenates this value into a server-side fetch, so a caller can forge aenv_gateway_url to probe loopback, link-local/cloud-metadata, or other internal services; the validation in resolveConnectionInput does not protect this read path. Validate at the point of use/read, enforce an authenticated allowlist or explicit private-network policy, and cryptographically protect the session against tampering.

Comment thread web/src/lib/session.ts
Comment on lines +80 to +82
jar.set(SESSION_COOKIE.apiKey, session.apiKey, options);
if (session.adminToken) {
jar.set(SESSION_COOKIE.adminToken, session.adminToken, options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
This persists the full API key and privileged admin token directly in browser cookies. httpOnly blocks JavaScript access but does not encrypt the values at rest or protect them from browser-profile theft, cookie/header logging, backups, or replay; these cookies are also attached to every request under /. Prefer an opaque server-side session identifier, or at minimum authenticated encryption with a server-held secret and narrowly scoped cookie settings.

Comment thread web/src/lib/session.ts
Comment on lines +80 to +82
jar.set(SESSION_COOKIE.apiKey, session.apiKey, options);
if (session.adminToken) {
jar.set(SESSION_COOKIE.adminToken, session.adminToken, options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
There is no encoded-size limit for either credential before writing it as an individual cookie. Browsers and proxies commonly cap each cookie near 4 KiB and may silently discard an oversized Set-Cookie, leaving the API response successful but the stored session absent or partially updated. Reject values that exceed a conservative encoded cookie budget (with a user-facing validation error), or move credentials to server-side session storage.

Refuse redirect-following with auth headers, require a fresh API key when
the Gateway URL changes, and return JSON errors from connection routes
instead of unhandled 500s.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment on lines +25 to +27
{session
? `Connected to ${session.gatewayUrl}.`
: "Not connected yet."}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
getConnectionSessionSummary() always returns an object, including EMPTY_SESSION_SUMMARY when no connection is configured, so this condition is always truthy and the disconnected state renders Connected to null.. Branch on session.configured (and preferably ensure gatewayUrl is non-null) instead.

Suggestion:

Suggested change
{session
? `Connected to ${session.gatewayUrl}.`
: "Not connected yet."}
{session.configured && session.gatewayUrl
? `Connected to ${session.gatewayUrl}.`
: "Not connected yet."}

message: string,
): Promise<NextResponse<ConnectionApiResponse>> {
const payload: ConnectionApiResponse = {
session: await getConnectionSessionSummary(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
The error path can repeat the operation that just failed. For example, if GET catches a failure from getConnectionSessionSummary(), errorResponse() calls it again; if it throws again, the handler rejects instead of returning the intended JSON 500 response. Build the error payload with EMPTY_SESSION_SUMMARY, or catch summary retrieval independently and fall back to that value.

return NextResponse.json(payload, { status: 400 });
}

const probe = await probeConnection(resolved.credentials);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
This turns a request-controlled gatewayUrl into server-side fetches. parseGatewayUrl only restricts the scheme, so an unauthenticated caller can target loopback, RFC1918/internal services, or cloud metadata addresses and use the returned status/timing for service discovery (and trigger GET endpoints). Restrict destinations using a deployment allowlist and resolved-IP validation (including DNS rebinding protection), or require authorization before allowing arbitrary internal destinations.

Comment on lines +29 to +32
const payload: ConnectionApiResponse = {
session,
probe: await probeConnection(resolved.credentials),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
This endpoint performs server-side requests to a caller-controlled gatewayUrl. Since URL validation only restricts the scheme to HTTP(S), a caller can probe loopback, link-local/cloud-metadata, or private-network addresses and receive HTTP status/timing details in the response (SSRF). Before calling probeConnection, enforce an allowlist or resolve and reject loopback, link-local, private, multicast, and metadata destinations (including redirects/DNS rebinding), and require appropriate authentication/authorization for this route.

Comment thread web/src/app/page.tsx
import { redirect } from "next/navigation";

export default function HomePage() {
redirect("/dashboard");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
The dashboard URL is hardcoded here and is also repeated in app-sidebar.tsx. This can drift when routes are renamed. Define the path in a shared route constants module and reuse it for redirects and links.

Suggestion:

Suggested change
redirect("/dashboard");
redirect(ROUTES.dashboard);

Comment on lines +158 to +160
if (nodes && nodes.outcome !== "ok") {
return "Gateway reachable. Node views are unavailable with the current admin token.";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This attributes every node-check failure to the admin token, but runCheck maps transport errors, timeouts, and all non-401/403 HTTP responses (including 5xx) to "failed". Those cases will show a misleading credential diagnosis. Use the token-specific message only for outcome === "unauthorized"; provide a general node-endpoint failure summary for "failed".

Comment thread web/src/lib/session.ts
return {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
Production mode unconditionally marks these cookies Secure, but the newly added Docker Compose exposure is plain HTTP on port 3000 (and the base Kubernetes Service also only defines HTTP). On a non-localhost HTTP deployment, browsers will reject/withhold these cookies, so saving a connection appears successful but no session persists. Derive this from an explicit HTTPS configuration and/or ensure every supported production deployment terminates TLS before exposing the app.

Comment thread web/src/lib/session.ts
Comment on lines +80 to +82
jar.set(SESSION_COOKIE.apiKey, session.apiKey, options);
if (session.adminToken) {
jar.set(SESSION_COOKIE.adminToken, session.adminToken, options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · high]
This persists the raw gateway API key and privileged admin token as browser cookies. httpOnly prevents JavaScript reads but does not make these credentials opaque or encrypted; they remain bearer credentials stored client-side and are sent with every same-origin request because the path is /. Prefer an opaque, short-lived session ID backed by a server-side credential store (or, if server-side storage is impossible, authenticated encryption with rotation and expiration) so compromise of the browser cookie does not directly disclose reusable gateway credentials.

Comment thread web/src/lib/session.ts
Comment on lines +83 to +85
} else {
jar.delete(SESSION_COOKIE.adminToken);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
The cookie was created with Path=/, but this deletion does not emit the same path attribute. When invoked from /api/connection, an expiration cookie without Path=/ may only target the API route's default path and leave the original root-scoped admin-token cookie active. Expire it by setting an empty value with the original options and maxAge: 0 (and apply the same approach in clearConnectionSession).

Suggestion:

Suggested change
} else {
jar.delete(SESSION_COOKIE.adminToken);
}
} else {
jar.set(SESSION_COOKIE.adminToken, "", { ...options, maxAge: 0 });
}

Comment thread web/src/lib/session.ts
Comment on lines +90 to +92
for (const name of Object.values(SESSION_COOKIE)) {
jar.delete(name);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
These cookies were set with Path=/, so deletion must match that path. Since this runs from /api/connection, name-only deletion can leave the root-scoped gateway credentials intact, causing “clear session” to report success while subsequent requests remain authenticated. Reissue each cookie with the original attributes and maxAge: 0.

Suggestion:

Suggested change
for (const name of Object.values(SESSION_COOKIE)) {
jar.delete(name);
}
const options = sessionCookieOptions();
for (const name of Object.values(SESSION_COOKIE)) {
jar.set(name, "", { ...options, maxAge: 0 });
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants