Files
clawmates/frontend/src/proxy.ts
T
Omar SobhandClaude Opus 5 fe45f72f09
deploy / test (push) Successful in 4m28s
deploy / build (push) Successful in 6m25s
feat(podcast): a PODCAST tier, a topics field, and a feed a phone can actually reach
Three gaps between "the pipeline works" and "you can use it".

**1. Topics could not be set.** The wizard never sent `config.topics`, so every
mission created through the UI silently fell back to
`library::default_topics()` — a hardcoded list that is somebody else's research
interests. The card now takes one arXiv search per line, and the description
field says plainly that for this template it IS the brief the agents judge
relevance against.

**2. There was nowhere to see or subscribe.** New PODCAST tier in the left rail,
between AGENT and REPOS: the feed URL with a copy button, the episode list, and
an inline player for checking one at a desk. `GET /api/podcast/episodes` and
`/subscription` back it. The panel also reports how many missions produced no
audio, so a missing day reads as a known gap rather than silence.

**3. The feed 404'd for the only client that will ever request it.** Three
layers each assumed a browser:

  - `resolveBearer` is server-only (`next/headers`), so a client component that
    imported it broke the build outright. The panel now goes through the
    same-origin proxy like every other panel, and the backend mints the feed URL
    because the session lives in an httpOnly cookie JavaScript cannot read.
  - The `/api` proxy demanded a session COOKIE. A podcast app has none and
    carries `?token=` instead — the same shape as the existing `hooks/` prefix,
    which is already exempt for exactly this reason.
  - The local autologin middleware 307'd it to `/auth/autologin`. A podcast app
    follows redirects blindly and would have stored an HTML page as the episode.

Neither exemption weakens auth: the backend still validates the token and
answers 401 to a bad one, verified. `episode_audio` accepts the token from
either the query string or an Authorization header, because the app fetches it
one way and the browser player the other, and refusing either breaks one of the
two ways this is listened to.

`CLAWMATES_PUBLIC_URL` matters and was wrong first: the tailnet root proxies to
a different service on :18789, and this frontend is on :8443. A feed advertising
an unreachable origin syncs silently forever, so `/subscription` returns a
`reachable` flag and the panel warns when it is still localhost.

Verified from a phone's point of view: feed 200 application/rss+xml over the
tailnet, enclosure 200 with 6,739,582 bytes of audio at 421s, bad token 401.

367 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:55:19 -07:00

74 lines
3.1 KiB
TypeScript

// Next middleware (Next 16: proxy.ts). Two jobs: route the marketing
// landing for logged-out visitors at "/", and (in clerk mode) run Clerk's
// middleware so resolveBearer() has an auth context.
import { NextResponse, type NextRequest } from "next/server";
import type { NextFetchEvent } from "next/server";
const SESSION_COOKIE = "cm_session";
// Rewrite "/" to the public marketing landing. Returns the rewrite Response, or
// null to let the request fall through to the workspace app.
function marketingRewrite(request: NextRequest): NextResponse | null {
if (request.nextUrl.pathname !== "/") return null;
const url = request.nextUrl.clone();
url.pathname = "/marketing";
return NextResponse.rewrite(url);
}
export default async function proxy(
request: NextRequest,
event: NextFetchEvent,
) {
// Clerk mode: run Clerk's middleware (it also establishes the auth context
// resolveBearer() needs) and decide the "/" rewrite from the REAL auth state.
// We must NOT sniff the __session cookie ourselves — it's a short-lived (~60s)
// token that is frequently absent for a signed-in user, which would bounce
// authed users to the marketing page. Use Clerk's auth() instead.
if (process.env.AUTH_MODE === "clerk") {
const { clerkMiddleware } = await import("@clerk/nextjs/server");
return clerkMiddleware(async (auth, req) => {
if (req.nextUrl.pathname === "/") {
const { userId } = await auth();
if (!userId) return marketingRewrite(req) ?? undefined;
}
return undefined;
})(request, event);
}
// Local mode: key on our own session cookie.
if (!request.cookies.has(SESSION_COOKIE)) {
// LOCAL-ONLY: a single-user local stack goes straight to the dashboard.
// The route itself re-checks both env vars and 404s without them, so this
// redirect is inert in any deployment that has not opted in. Excluded
// paths would otherwise loop: /auth/autologin sets the cookie, and /login
// must stay reachable to show a failed-autologin message.
const path = request.nextUrl.pathname;
if (
process.env.LOCAL_AUTOLOGIN_EMAIL &&
process.env.LOCAL_AUTOLOGIN_PASSWORD &&
path !== "/auth/autologin" &&
path !== "/login" &&
// The podcast feed and its audio authenticate by `?token=`, and are
// fetched by a podcast app that has no cookie and follows redirects
// blindly. Sent to autologin it would store an HTML page as the episode.
// The backend still validates the token, so this exempts the redirect,
// not the auth.
!path.startsWith("/api/podcast/")
) {
const url = request.nextUrl.clone();
url.pathname = "/auth/autologin";
url.search = `?next=${encodeURIComponent(path + request.nextUrl.search)}`;
return NextResponse.redirect(url);
}
const rewrite = marketingRewrite(request);
if (rewrite) return rewrite;
}
return NextResponse.next();
}
export const config = {
// Everything except static assets and prebuilt files.
matcher: ["/((?!_next|icons|fonts|.*\\.(?:png|svg|ico|woff2?|js|css|map)$).*)"],
};