feat(gateway): add environment-aware HTTP and websocket routing
New internal component (platform/gateway/) — the only student-facing path to
disposable Lab environments. Browsers never reach a Lab container, the
Provisioner, or Postgres directly.
Routes:
- /api/environment/{status,launch,reset} — student-facing glue (X-Student-Id),
calls the Provisioner internally (shared secret never reaches the browser).
Reuses an existing non-terminal environment on launch rather than always
provisioning a new one; reset destroys+recreates the same environment row.
- /lab/<token>/* — HTTP+WS proxy into that one student's Lab container,
resolved by opaque routing token via a new Provisioner endpoint
(GET /environments/by-token/:token). No raw container IP/port ever reaches
the browser. Rejects invalid tokens (404), unready environments (202/503),
and cross-student access via an optional ?sid= ownership check (403) — see
docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security" for why token-alone,
student-id-alone, and env-id-alone are each individually insufficient and
this is explicitly pilot-grade, not production auth.
- everything else — proxied to the persistent identity/curriculum backend
(roster, login, curriculum, progress, platform-shell frontend), or to the
student's own Lab if a valid lab-session cookie is present.
Routing design note: the existing Lab UI makes root-relative calls
(fetch('/api/scenarios'), the /shell-ws WebSocket) with no idea it might be
served under a /lab/<token>/ prefix — preserving that UI unmodified was a
hard requirement. Solved with an HttpOnly, SameSite=Lax cookie set on the
first /lab/<token>/ response: every later request from that browser carries
it, so the unmodified Lab UI's own unprefixed calls resolve correctly once a
student has navigated into a lab session, with zero changes to its code.
/api/environment/* is explicitly exempted from cookie-based routing so it
never gets hijacked by an active lab session.
Also added platform/docker-compose.dev.yml (the full local stack: Postgres +
egress-proxy + Provisioner + a persistent "identity" instance + Gateway) and
platform/README.md tying the components together. The "identity" instance
required an explicit `network_mode: bridge` — under Compose's own
auto-created bridge network, k3s failed at startup with "no default routes
found in /proc/net/route"; documented as a known limitation, not fully
root-caused.
Found and fixed during testing: express.json() applied globally broke
http-proxy's re-streaming of proxied POST bodies (e.g. POST /api/roster hung
forever, since the body had already been consumed by the parser before
proxy.web() tried to forward it). Removed — none of the Gateway's own routes
actually need a parsed body.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -125,6 +125,29 @@ app.get('/environments', asyncRoute(async (req, res) => {
|
||||
res.json(envs.map(redactRow));
|
||||
}));
|
||||
|
||||
// GET /environments/by-token/:token — Phase 2A addition, used by the Gateway to resolve a
|
||||
// student's opaque routing token to the environment it addresses (internal_ip, status,
|
||||
// owning student_id) before proxying. Internal-only, same shared-secret auth as every other
|
||||
// route here — the Gateway is the only caller, browsers never reach this directly.
|
||||
app.get('/environments/by-token/:token', asyncRoute(async (req, res) => {
|
||||
const { rows } = await getPool().query('SELECT * FROM environments WHERE routing_token = $1', [req.params.token]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
||||
res.json(redactRow(rows[0]));
|
||||
}));
|
||||
|
||||
// GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts
|
||||
// routing_token (see redactRow, and the "never log secrets in plaintext" requirement) — this
|
||||
// is the one narrow exception, for the Gateway's own legitimate need to reconstruct a
|
||||
// student's /lab/<token>/ launch URL for an environment that already exists (not just one it
|
||||
// just created, where the token comes back un-redacted from POST /environments directly).
|
||||
// Still shared-secret gated like every other route — nothing about this is student-reachable.
|
||||
app.get('/environments/:id/token', asyncRoute(async (req, res) => {
|
||||
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
||||
const env = await lifecycle.getEnvironment(getPool(), req.params.id);
|
||||
if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
||||
res.json({ environment_id: env.environment_id, routing_token: env.routing_token });
|
||||
}));
|
||||
|
||||
// GET /environments/:id
|
||||
app.get('/environments/:id', asyncRoute(async (req, res) => {
|
||||
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
||||
|
||||
Reference in New Issue
Block a user