Skip to content

feat(den-gateway): serve the app and route each user to their own instance - #3191

Merged
benjaminshafii merged 1 commit into
devfrom
feat/den-gateway
Jul 27, 2026
Merged

feat(den-gateway): serve the app and route each user to their own instance#3191
benjaminshafii merged 1 commit into
devfrom
feat/den-gateway

Conversation

@benjaminshafii

Copy link
Copy Markdown
Member

Slice 1 of ui.openworklabs.com. ee/apps/den-gateway serves the OpenWork web build and proxies each signed-in user to their own Cloud instance, injecting that instance's credentials server-side.

Spec is the approved voice-over at evals/voiceovers/den-gateway.md — the demo line being "I never learn what a sandbox is."

The boundary is the feature

browser ── ui.openworklabs.com (den-gateway) ── den-api ── Daytona
             serves apps/app build,             owns provisioning,
             routes user → their instance,        wake/stop, preview URLs
             injects creds server-side
  • The gateway holds no Daytona key, never imports the SDK, and has no DB access. den-api stays the only thing that talks to Daytona — so swapping compute vendors touches one layer.
  • The browser never receives an instance token or a preview URL. That's what retires "URL-as-credential" and the three CORS patches from this week.
  • The gateway cannot assert identity on its own — it acts strictly on behalf of the caller's Den session. den-api's internal-principal secret is randomBytes(32) per process specifically to prevent cross-service impersonation, and this PR does not touch it.

den-api: GET /v1/cloud/gateway/resolve

Caller's own session bearer plus a shared gateway secret (timingSafeEqual); the route 404s when the secret env is unset, so it doesn't exist on deployments without a gateway. Returns URL + collaborator token only when ready, never the host token, and reuses the existing per-user state machine instead of forking it.

Routing — the part worth reviewing

Two bugs were caught in review before this landed:

  1. Namespace matching. The allowlist originally matched exactly, so /files/sessions/*, /workspaces/local, /workspaces/:id/activate, /env/keys and /approvals/:id fell through and would have answered API callers with HTML — breaking file sessions, workspace create/activate, env management, and approving a write (frame 5 of the script). Every entry now matches as a namespace so this can't recur as the API grows.

  2. /workspace/ is genuinely ambiguous. openwork-server owns API routes there; the BrowserRouter SPA owns reloadable deep links (/workspace/:id/session/:sessionId, /workspace/:id/settings/*). A blanket prefix would proxy a user's reload to the instance — breaking frame 7 ("the same address opens my workspace"). Document navigations now serve index.html, everything else proxies, via one named function with a comment so it doesn't get "simplified" back into a bug. /opencode/ and /w/ are always API, since openwork-server claims all of /w/.

Streaming correctness

The SPA holds two permanently-open SSE streams, so: bodies piped not buffered, content-encoding/transfer-encoding stripped (undici already decoded — same fix openwork-server's sanitizeProxyResponse makes), no idle timeout, upstream caching passed through. These are exactly the four things den-worker-proxy gets wrong, which is why this is written fresh rather than forked.

Static serving mirrors apps/server/src/static-ui.ts: SPA history fallback, immutable /assets caching, traversal rejection, and a hard 404 for a missing hashed asset so a stale index surfaces as a real 404 instead of HTML parsed as JavaScript. Gateway health is /__gw/health because /health belongs to the instance.

Tests

  • 10 gateway tests: static, SPA fallback, asset 404, traversal, credential injection (upstream sees the instance token, never the Den bearer, no cookie), resolve cache (two rapid requests → one resolve), non-ready status, SSE non-buffering, gzip encoding, and every routing case above including the /workspace/…/session/… reload.
  • 24 den-api cloud tests, including the new resolve gating.
  • Builds clean; no any/as.

Scope

Slice 1 only — apps/app is not wired to the gateway yet (slice 2: the host adapter + sending the Den bearer instead of an instance token) and no deployment config (slice 3: GHCR image + Render service + serving the SPA build). Nothing in production changes by merging this.

…tance

First slice of ui.openworklabs.com. den-gateway serves the OpenWork web
build and proxies each signed-in user to their own Cloud instance,
injecting that instance's credentials server-side. The browser never
receives an instance token or a preview URL, and never learns which
compute vendor is behind it.

The security boundary is the point: the gateway holds no Daytona key,
never imports the SDK, and has no database access. den-api remains the
only thing that talks to Daytona. The gateway also cannot assert identity
on its own - it acts strictly on behalf of the caller's Den session, so
den-api's per-process internal-principal secret stays untouched.

den-api gains GET /v1/cloud/gateway/resolve: the caller's own session plus
a shared gateway secret (timing-safe, and the route 404s when the secret
env is unset, so it does not exist on deployments without a gateway). It
returns the instance URL and collaborator token only when ready, never the
host token, and reuses the existing per-user state machine rather than
forking it.

Routing is the subtle part. Every allowlisted entry matches as a namespace,
because /files/sessions/*, /workspaces/local, /workspaces/:id/activate,
/env/keys and /approvals/:id would otherwise fall through and answer an API
caller with HTML. And /workspace/ is genuinely ambiguous - openwork-server
owns API routes there while the BrowserRouter SPA owns reloadable
session/settings deep links - so document navigations serve index.html
while everything else proxies. /opencode/ and /w/ are always API, since
openwork-server claims all of /w/.

Streaming correctness is deliberate: the SPA holds two permanently-open SSE
streams, so bodies are piped rather than buffered, content-encoding and
transfer-encoding are stripped (undici already decoded), no idle timeout is
imposed, and upstream caching passes through. Static serving mirrors
openwork-server: SPA history fallback, immutable /assets caching, and a hard
404 for a missing hashed asset so a stale index surfaces as a 404 instead of
HTML parsed as JavaScript. Gateway health lives at /__gw/health because
/health belongs to the instance.

Lands the approved voice-over and scaffolded flow for the den-gateway demo.

Slice 1 only: apps/app is not wired to it yet and no deployment config is
included.

Verified: den-gateway builds, 10 gateway tests pass (static, SPA fallback,
asset 404, traversal, injection, resolve cache, non-ready, SSE
non-buffering, gzip, and every routing case above); 24 den-api cloud tests
pass including the new resolve gating.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jul 27, 2026 2:36pm
openwork-den Ready Ready Preview, Comment Jul 27, 2026 2:36pm
openwork-den-worker-proxy Ready Ready Preview, Comment Jul 27, 2026 2:36pm
openwork-diagnostics Ready Ready Preview, Comment Jul 27, 2026 2:36pm
openwork-landing Ready Ready Preview, Comment, Open in v0 Jul 27, 2026 2:36pm

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.

1 participant