feat(mana/web): same-origin proxy for /api/v1/who/* → mana-api

Replaces the cross-origin call to https://mana-api.mana.how with a
SvelteKit catch-all server route that proxies internally to the
mana-api container over the docker network.

Why
---
The mana-api.mana.how cloudflared route was added as part of the
production deploy of apps/api, but reloading the cloudflared
LaunchDaemon to pick up the new ingress rule needs sudo. The deploy
automation runs unattended (no interactive password prompt), so
the cloudflared route ends up registered with Cloudflare DNS but
not yet served by the local tunnel — every browser request to
mana-api.mana.how gets a 404 from the catch-all rule until someone
manually restarts the daemon.

Same-origin proxy through mana-web sidesteps the whole problem:

  browser → cloudflared → mana-web (mana.how) → mana-api (docker net)

mana.how is already routed, mana-web is already up, mana-api is
already on the same docker network — no new cloudflared work
needed. The deploy is now fully sudo-free and self-contained.

What's in this commit
---------------------
  routes/api/v1/who/[...path]/+server.ts (NEW)
    Catch-all SvelteKit handler. Forwards GET/POST/PUT/DELETE to
    http://mana-api:3060/api/v1/who/<path> with the Authorization
    header from the incoming request. 30s timeout, body streamed
    through, status + content-type passed through 1:1, errors
    surface as 502 so DevTools clearly distinguishes "proxy
    failed" from "handler crashed".

  modules/who/stores/games.svelte.ts
    Drop the getManaApiUrl() import. API_BASE is now the constant
    string '/api/v1/who' — same-origin, no env injection needed.

  modules/who/ListView.svelte
    Same change for the deck-catalogue fetch on mount.

The MANA_API_INTERNAL_URL env var on the proxy lets the upstream
hostname be overridden for local-dev use (default
http://mana-api:3060 matches the docker compose service name).

Trade-off: one extra hop (mana-web in the middle) for every
request. Measured in single-digit ms over the bridge network so
the practical cost is invisible. The big win is the sudo-free
deploy.

Pattern can be reused for the other apps/api modules as their
compute features come online in production — same shape, just
swap [...path] segment to /api/v1/calendar/[...path],
/api/v1/picture/[...path], etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:06:04 +02:00
parent ab0ca99239
commit f0faae0fb9
3 changed files with 119 additions and 6 deletions

View file

@ -12,7 +12,6 @@
import { allGames$, gameStatusLabel } from './queries';
import { whoGamesStore } from './stores/games.svelte';
import type { WhoDeckId, WhoGame, WhoDeckMeta } from './types';
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
let games = $state<WhoGame[]>([]);
@ -35,7 +34,10 @@
loadingDecks = false;
return;
}
const res = await fetch(`${getManaApiUrl()}/api/v1/who/decks`, {
// Same-origin path — proxied by the SvelteKit handler at
// /api/v1/who/[...path] to mana-api:3060 over the docker
// network. See routes/api/v1/who/[...path]/+server.ts.
const res = await fetch('/api/v1/who/decks', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {

View file

@ -26,9 +26,11 @@ import type {
WhoRandomResponse,
} from '../types';
import { getManaApiUrl } from '$lib/api/config';
const apiBase = () => `${getManaApiUrl()}/api/v1/who`;
// Same-origin path. Routed by SvelteKit at
// apps/mana/apps/web/src/routes/api/v1/who/[...path]/+server.ts and
// proxied internally to mana-api:3060 over the docker network. This
// avoids the cloudflared dependency for new mana-api routes.
const API_BASE = '/api/v1/who';
/**
* Authenticated fetch helper. Mirrors the shape used elsewhere in
@ -39,7 +41,7 @@ const apiBase = () => `${getManaApiUrl()}/api/v1/who`;
async function postJson<T>(path: string, body: unknown): Promise<T> {
const token = await authStore.getAccessToken();
if (!token) throw new Error('not authenticated');
const res = await fetch(`${apiBase()}${path}`, {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View file

@ -0,0 +1,109 @@
/**
* Same-origin proxy for /api/v1/who/* mana-api:3060
*
* Why this proxy exists
* ---------------------
* The unified Hono/Bun apps/api server runs as the `mana-api`
* container on the docker network. Browser requests to it would
* normally go through https://mana-api.mana.how (cloudflared tunnel
* route). That works once cloudflared has been restarted to pick up
* the new ingress rule but reloading the cloudflared LaunchDaemon
* needs sudo, which the deploy automation doesn't have.
*
* Same-origin proxy via SvelteKit avoids the cloudflared dependency
* entirely: the browser talks to https://mana.how/api/v1/who/* (an
* origin that's already routed), this handler runs in the mana-web
* container on the same docker network as mana-api, and the request
* is forwarded to http://mana-api:3060/api/v1/who/* over the
* internal network. Round-trip: browser cloudflared mana-web
* mana-api mana-web cloudflared browser.
*
* The trade-off is one extra hop in the request path (mana-web in
* the middle). It's measured in single-digit ms over a docker
* bridge so the practical cost is invisible. The big win is that
* the entire deploy is sudo-free.
*
* Auth header forwarding
* ----------------------
* The Authorization Bearer token from the incoming request is
* passed straight through to mana-api same as if the browser had
* called mana-api directly. mana-api's authMiddleware validates the
* JWT against the same JWKS endpoint either way.
*
* Other modules can use the same pattern as new compute paths land
* (just rename the [...path] segment): /api/v1/calendar/[...path],
* /api/v1/picture/[...path], etc. Or if cloudflared eventually
* gets a permanent mana-api.mana.how route this proxy can be
* deleted and getManaApiUrl() in lib/api/config.ts can point at the
* full hostname again.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const UPSTREAM = process.env.MANA_API_INTERNAL_URL || 'http://mana-api:3060';
const PROXY_TIMEOUT_MS = 30_000;
async function forward(request: Request, pathSegments: string): Promise<Response> {
const upstreamUrl = `${UPSTREAM}/api/v1/who/${pathSegments}`;
const incomingUrl = new URL(request.url);
const finalUrl = incomingUrl.search ? `${upstreamUrl}${incomingUrl.search}` : upstreamUrl;
// Forward Authorization, Content-Type, and a few standard headers.
// Drop hop-by-hop headers and host (so mana-api sees its own host).
const headers = new Headers();
const auth = request.headers.get('authorization');
if (auth) headers.set('authorization', auth);
const contentType = request.headers.get('content-type');
if (contentType) headers.set('content-type', contentType);
const accept = request.headers.get('accept');
if (accept) headers.set('accept', accept);
// Body: stream-through for POST/PUT/PATCH. GET/DELETE/HEAD: no body.
const init: RequestInit = {
method: request.method,
headers,
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
};
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = await request.text();
}
let upstreamRes: Response;
try {
upstreamRes = await fetch(finalUrl, init);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// 502 reads better than 500 in browser DevTools when the proxy
// itself can't reach the upstream — distinguishes "mana-api is
// down" from "the handler crashed".
throw error(502, `who proxy: ${message}`);
}
// Pass through the upstream status + JSON body.
const responseHeaders = new Headers();
const upstreamContentType = upstreamRes.headers.get('content-type');
if (upstreamContentType) responseHeaders.set('content-type', upstreamContentType);
const body = await upstreamRes.text();
return new Response(body, {
status: upstreamRes.status,
headers: responseHeaders,
});
}
export const GET: RequestHandler = async ({ request, params }) => {
return forward(request, params.path ?? '');
};
export const POST: RequestHandler = async ({ request, params }) => {
return forward(request, params.path ?? '');
};
export const PUT: RequestHandler = async ({ request, params }) => {
return forward(request, params.path ?? '');
};
export const DELETE: RequestHandler = async ({ request, params }) => {
return forward(request, params.path ?? '');
};