"""FastAPI proxy for Earth Engine that injects per-tenant SA tokens.
The proxy receives requests from EE clients (browser JS, Python SDK
via ``geeViz.eeAuth.client``), looks up the requested tenant in the SA
registry, mints a token (cached), and forwards to the real EE endpoint
with the right ``Authorization`` and ``x-goog-user-project`` headers.
Two ways to use:
1. **Standalone**::
python -m geeViz.eeAuth --port 8888
or programmatically::
from geeViz.eeAuth.server import create_proxy_app
app = create_proxy_app()
# serve with uvicorn / etc.
2. **Mounted in an existing FastAPI app**::
from fastapi import FastAPI
from geeViz.eeAuth.server import build_proxy_router
app = FastAPI()
app.include_router(build_proxy_router(), prefix="/ee-api")
Tenant routing — the proxy picks the SA in this order:
1. ``X-geeViz-Creds`` request header (server-side EE SDK; set by
``geeViz.eeAuth.client.TenantAwareHttp``).
2. ``?tenant=`` query string parameter (browser map iframes).
3. Default tenant (the registry's ``default`` entry, loaded from
``GEE_SERVICE_ACCOUNT_B64``).
Workload tagging — every POST is stamped with a workload tag
``ee-proxy__<tenant>`` in the query string for billing attribution.
Pass ``workload_tag_builder=...`` to ``build_proxy_router`` if you want
to construct your own tag (e.g. include user / session).
"""
from __future__ import annotations
import asyncio
import datetime
import logging
import os
from typing import Callable, Optional
from urllib.parse import parse_qsl, urlencode
# Module-load timestamp — used by the /health probe so detached-mode
# clients can tell how stale a discovered proxy process is.
_PROCESS_STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
from fastapi import APIRouter, FastAPI, Request, Response
from fastapi.responses import HTMLResponse
from html import escape as _html_escape
from starlette.requests import ClientDisconnect
from .registry import get_registry
from .tags import build_workload_tag
logger = logging.getLogger(__name__)
# Default upstream — EE serves compute + maps from content-earthengine.
# value:compute also works at earthengine.googleapis.com, but
# content-earthengine accepts both, so we route everything there.
DEFAULT_UPSTREAM = "https://content-earthengine.googleapis.com"
# Header the proxy expects for routing. Default is geeViz-branded so it's
# obviously library-owned in browser DevTools / packet captures; override
# per-deployment via ``build_proxy_router(tenant_header=...)``. The agent
# uses ``X-AskTerra-Tenant`` for back-compat with iframe URLs already in
# production. Both sides (client transport + proxy router) must use the
# SAME value — the library's defaults match by convention.
DEFAULT_TENANT_HEADER = "X-geeViz-Creds"
# Headers we never forward to upstream — they're either hop-by-hop, leak
# our infrastructure (IAP, forwarding proxies), or are our own internal
# routing signals that EE would reject.
_STRIPPED_HEADERS = frozenset({
"host", "content-length", "authorization",
"x-forwarded-for", "x-forwarded-proto", "x-forwarded-host", "x-real-ip",
"x-goog-authenticated-user-email", "x-goog-authenticated-user-id",
"x-goog-iap-jwt-assertion",
# Stripped because the server sets its own — what the client claims is irrelevant.
"x-goog-user-project",
})
def _default_tenant_resolver(
request: Request, tenant_header: str
) -> str:
"""Read tenant from header, then query param. Returns ``""`` if
neither present — the registry's default tenant will be used."""
t = request.headers.get(tenant_header, "").strip().lower()
if t:
return t
return (request.query_params.get("tenant", "") or "").strip().lower()
def _default_workload_tag_builder(
request: Request, tenant: str
) -> str:
"""Default workload-tag policy for the ee-proxy.
Rule: **if the client already set a tag** (via
``ee.data.setWorkloadTag()`` on the Python side, or baked into a
tile URL returned by ``getMapId``), respect it. Otherwise mint a
reversible fallback tag that includes richer parts than plain
``ee-proxy__<tenant>`` and store the mapping in the eeCreds
singleton's ``TagStore`` so ``eeCreds.lookupWorkloadTag(tag)`` can
recover the parts later.
Custom builders passed via ``workload_tag_builder=...`` skip this
entirely and own their own policy — see the agent's
``TenantAwareHttp`` path for an example.
"""
# 1. Client-set tag wins. Same rule applies to Python getInfo calls
# (SDK puts workloadTag in the query) AND browser tile fetches
# (URL from getMapId already includes it).
client_tag = request.query_params.get("workloadTag")
if client_tag:
return client_tag
# 2. Fallback — mint richer parts + persist mapping so the tag is
# reversible. Pull the eeCreds singleton lazily to avoid a
# circular import at module load.
try:
from geeViz.eeAuth.eeCreds import eeCreds as _singleton
from geeViz.eeAuth.tags import mint_workload_tag, _default_secret
parts = {
"tenant": tenant or "default",
"cred": _singleton.current() or "unknown",
"pid": os.getpid(),
"src": "proxy-default",
}
secret = _singleton._resolve_tag_secret() if hasattr(
_singleton, "_resolve_tag_secret"
) else _default_secret()
tag = mint_workload_tag(parts, secret=secret)
try:
_singleton.getTagStore().put(tag, parts)
except Exception:
logger.exception("ee-proxy: default builder store.put failed")
return tag
except Exception:
# Never let attribution failure break a live request. Fall back
# to the legacy shape.
logger.exception("ee-proxy: default builder minting failed; using legacy shape")
parts_list = ["ee-proxy"]
if tenant:
parts_list.append(tenant)
return build_workload_tag(*parts_list)
def _rewrite_query_with_workload_tag(
query: str,
tenant: str,
workload_tag_builder: Callable[[Request, str], str],
request: Request,
tenant_query_param: str,
) -> str:
"""Strip any client-set workloadTag and tenant query param; add our
own workload tag if a tag builder produced one."""
try:
tag = workload_tag_builder(request, tenant)
except Exception:
logger.exception("ee-proxy: workload_tag_builder failed")
tag = ""
pairs = [
(k, v) for k, v in parse_qsl(query or "", keep_blank_values=True)
if k != "workloadTag" and k != tenant_query_param
]
if tag:
pairs.append(("workloadTag", tag))
return urlencode(pairs)
[docs]
def build_proxy_router(
creds=None,
upstream: str = DEFAULT_UPSTREAM,
tenant_header: str = DEFAULT_TENANT_HEADER,
tenant_query_param: str = "tenant",
tenant_resolver: Optional[Callable[[Request, str], str]] = None,
workload_tag_builder: Optional[Callable[[Request, str], str]] = None,
) -> APIRouter:
"""Build a FastAPI ``APIRouter`` that handles ``{path:path}`` and
proxies every request to ``upstream`` with the right SA token.
Args:
creds: Object exposing ``get_token(tenant, force_refresh=False)
-> {access_token, project_id, tenant, ...}``. Accepts an
:class:`EECreds` instance, an :class:`SARegistry`, or any
other object with the same interface. ``None`` (default)
uses the process-wide env-var registry (legacy).
upstream: Base URL of the real EE API.
``content-earthengine.googleapis.com`` works for both maps
and compute. ``earthengine.googleapis.com`` is also accepted
for most endpoints.
tenant_header: Header name to read for routing. Default
``X-geeViz-Creds``. Must match the client side.
tenant_query_param: Query string key to read for tenant routing
(browser iframe pattern). Default ``"tenant"``. Stripped
from the outbound URL so EE never sees it.
tenant_resolver: Custom function ``(request) -> str`` to pick
the tenant. Override for richer auth schemes (e.g. resolve
via IAP email lookup). Default reads ``tenant_header`` then
``tenant_query_param``.
workload_tag_builder: Custom function ``(request, tenant) -> str``
that returns the workload tag for billing attribution.
Returning ``""`` disables tagging on this request. Default
builds ``ee-proxy__<tenant>``.
Mount the returned router on whatever prefix you like — typically
``/ee-api``.
"""
upstream = upstream.rstrip("/")
resolver = tenant_resolver or (
lambda r: _default_tenant_resolver(r, tenant_header)
)
tag_builder = workload_tag_builder or _default_workload_tag_builder
# Shared async HTTP client. Opening a new ``httpx.AsyncClient`` per
# request — which the original code did — costs a fresh TLS handshake
# to ``content-earthengine.googleapis.com`` on every EE call (50-150ms
# round-trips that pile up fast when the map viewer fires N parallel
# ``value:compute`` queries per layer). One shared client per router
# keeps connections in a pool and reuses them. ``http2=True`` because
# EE supports it and HTTP/2 multiplexing further reduces head-of-line
# blocking for parallel requests on a single connection.
import httpx as _httpx
upstream_client = _httpx.AsyncClient(
timeout=_httpx.Timeout(120.0, connect=10.0),
follow_redirects=False,
limits=_httpx.Limits(
max_keepalive_connections=64,
max_connections=128,
keepalive_expiry=60.0,
),
)
def _resolve_creds():
"""Resolve the credential source for each request. Honours the
``creds`` argument when provided, else falls back to the
env-var registry singleton — both expose ``get_token`` so the
proxy code below doesn't care which is in use."""
if creds is not None:
return creds
return get_registry()
router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def index(request: Request) -> HTMLResponse:
"""Human-friendly landing page — served when someone visits
``/ee-api/`` in a browser instead of an EE client.
Lists tenants, endpoints, health link, upstream URL, version.
No JS, no external assets — works over air-gapped networks and
renders identically in every browser."""
try:
from geeViz import __version__ as _ver
except Exception:
_ver = "(unknown)"
src = _resolve_creds()
tenants: list = []
try:
if hasattr(src, "list"):
tenants = list(src.list())
elif hasattr(src, "list_tenants"):
tenants = list(src.list_tenants())
except Exception:
tenants = []
tenants.sort()
base = str(request.url).rstrip("/")
health_url = f"{base}/health"
tenant_rows = (
"".join(f"<li><code>{_html_escape(t)}</code></li>" for t in tenants)
if tenants
else "<li><em>(no tenants registered)</em></li>"
)
html = f"""<!doctype html>
<html><head><meta charset="utf-8">
<title>geeViz eeAuth proxy</title>
<style>
body {{ font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif;
max-width: 780px; margin: 2rem auto; padding: 0 1rem; color: #222; }}
h1 {{ border-bottom: 1px solid #ddd; padding-bottom: .4rem; }}
h2 {{ margin-top: 1.6rem; }}
code, pre {{ font-family: SFMono-Regular, Menlo, monospace; font-size: 13px; }}
pre {{ background: #f6f8fa; padding: .8rem 1rem; border-radius: 6px;
overflow-x: auto; }}
.grid {{ display: grid; grid-template-columns: 12rem 1fr; gap: .4rem 1rem; }}
.grid dt {{ font-weight: 600; }}
.muted {{ color: #666; }}
table {{ border-collapse: collapse; margin: .5rem 0 1rem; }}
th, td {{ text-align: left; padding: .3rem .8rem .3rem 0; }}
th {{ border-bottom: 1px solid #ddd; }}
a {{ color: #0969da; }}
@media (prefers-color-scheme: dark) {{
body {{ background: #0d1117; color: #c9d1d9; }}
h1 {{ border-color: #30363d; }}
pre {{ background: #161b22; }}
th {{ border-color: #30363d; }}
.muted {{ color: #8b949e; }}
a {{ color: #58a6ff; }}
}}
</style>
</head><body>
<h1>geeViz eeAuth proxy</h1>
<p class="muted">
This URL is a <strong>reverse proxy</strong> that forwards Earth Engine
REST calls to the upstream API, injecting per-tenant service-account
bearer tokens. Point your EE client at it instead of calling EE
directly — see below.
</p>
<h2>Status</h2>
<dl class="grid">
<dt>Proxy base URL</dt><dd><code>{_html_escape(base)}</code></dd>
<dt>Upstream</dt><dd><code>{_html_escape(upstream)}</code></dd>
<dt>geeViz version</dt><dd><code>{_html_escape(_ver)}</code></dd>
<dt>Health probe</dt><dd><a href="{_html_escape(health_url)}"><code>{_html_escape(health_url)}</code></a></dd>
<dt>Tenant header</dt><dd><code>{_html_escape(tenant_header)}</code></dd>
<dt>Tenant query param</dt><dd><code>{_html_escape(tenant_query_param)}</code></dd>
</dl>
<h2>Registered tenants ({len(tenants)})</h2>
<ul>{tenant_rows}</ul>
<h2>Endpoints</h2>
<table>
<tr><th>Method</th><th>Path</th><th>Purpose</th></tr>
<tr><td>GET</td><td><code>/</code></td><td>This page.</td></tr>
<tr><td>GET</td><td><code>/health</code></td><td>Liveness + tenant fingerprint (JSON).</td></tr>
<tr><td>ANY</td><td><code>/{{ee-api-path}}</code></td><td>Proxied to <code>{_html_escape(upstream)}/{{path}}</code> with the tenant's SA token.</td></tr>
</table>
<h2>Proxy modes</h2>
<p class="muted">Which one this URL is running under depends on how you started it — set via <code>Map.setAuthMode(...)</code>, the <code>GEEVIZ_EEAUTH_MODE</code> env var, or the default.</p>
<table>
<tr><th>Mode</th><th>Process</th><th>Failure behavior</th></tr>
<tr><td><code>attached</code></td><td>in-process daemon thread</td><td>silent fallback</td></tr>
<tr><td><code>attached_strict</code></td><td>in-process daemon thread</td><td>raises on failure</td></tr>
<tr><td><code>detached</code></td><td>subprocess (survives script exit)</td><td>silent fallback</td></tr>
<tr><td><code>legacy</code></td><td>none (tokens minted into URL)</td><td>deprecated</td></tr>
</table>
<p class="muted">Legacy aliases: <code>auto</code> → <code>attached</code>, <code>proxy</code> → <code>attached_strict</code>.</p>
<h2>Use it from Python</h2>
<pre>from geeViz.eeAuth import initialize_via_proxy
initialize_via_proxy("{_html_escape(base)}")
import ee
ee.Number(1).getInfo() # → routes through this proxy</pre>
<h2>Use it from curl</h2>
<pre>curl -H "{_html_escape(tenant_header)}: <tenant-name>" \\
{_html_escape(base)}/v1/projects/earthengine-legacy/algorithms</pre>
<p class="muted" style="margin-top: 2rem; font-size: 12px;">
This page renders because the incoming request had no path after
<code>/</code>. Any real EE path (e.g.
<code>/v1/projects/…/value:compute</code>) is proxied through, not
rendered.
</p>
</body></html>
"""
return HTMLResponse(content=html)
@router.get("/health")
async def health() -> dict: # noqa: F811 – see docstring for fields
"""Liveness + identity probe for detached-mode discovery.
Returned fields:
- ``ok`` — always true (request reached us)
- ``version`` — geeViz package version (for
version-skew detection in
``eeCreds._ensure_detached_proxy``)
- ``tenant_fingerprint`` — sha256 of sorted tenant names, so
clients can detect when the
detached process is using a
stale tenant set vs. the
current environment
- ``tenants`` — list of tenant names currently
registered (mainly for human
debugging via curl)
- ``pid`` — process id of the proxy
- ``started_at`` — ISO timestamp of process start
"""
import hashlib
import os
try:
from geeViz import __version__ as _ver
except Exception:
_ver = ""
src = _resolve_creds()
names = []
try:
if hasattr(src, "list"):
names = list(src.list())
elif hasattr(src, "list_tenants"):
names = list(src.list_tenants())
except Exception:
names = []
names.sort()
fp = hashlib.sha256(",".join(names).encode("utf-8")).hexdigest()[:16]
return {
"ok": True,
"version": _ver,
"tenant_fingerprint": fp,
"tenants": names,
"pid": os.getpid(),
"started_at": _PROCESS_STARTED_AT,
}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
)
async def ee_proxy(path: str, request: Request) -> Response:
"""Forward an EE API request to the upstream, injecting the tenant's
Bearer token.
Tenant resolution order (first non-empty wins): path prefix
``/t/<tenant>/`` → configured header (``X-EE-Tenant`` by default)
→ configured query param (``tenant`` by default) → default tenant.
Args:
path: Everything after ``/ee-api/`` in the request URL.
request: FastAPI Request; body, headers, and query params are
forwarded as-is (Origin stripped for token / SSO paths so
EE doesn't reject).
Returns:
Response: Upstream response passed through with its status,
headers (minus hop-by-hop), and body. 204 for the tenant-ack
path (``/t/<tenant>`` with no trailing segment).
"""
import httpx
# 1. Resolve tenant + mint a token from the credential source.
#
# Path-prefix syntax ``/ee-api/t/<tenant>/<rest>`` wins over
# header and query. ``Map.view()`` bakes the tenant into the
# JS-side ``authProxyAPIURL`` exactly this way to pin each
# browser tab to its load-time tenant, immune to process-wide
# eeCreds switches in the host script. Strip the prefix so
# only the genuine EE path is forwarded upstream.
path_tenant = ""
if path.startswith("t/"):
rest = path[len("t/"):]
slash = rest.find("/")
if slash > 0:
path_tenant = rest[:slash]
path = rest[slash + 1:]
else:
# ``/ee-api/t/<tenant>`` with no trailing segment —
# tenant-ack ping, no upstream call needed.
return Response(content=b"", status_code=204)
tenant = path_tenant or resolver(request)
registry = _resolve_creds()
try:
# ``get_token`` calls ``creds.refresh()`` (synchronous OAuth
# HTTP roundtrip, ~200-1000ms+ on cache miss) which would
# block the asyncio event loop. Offload to the default
# threadpool so other /ee-api requests — including the MCP
# subprocess's first-init verification call — can proceed
# in parallel. Cached tokens (TTL ~50min) return instantly,
# but the first request per tenant pays the refresh cost,
# and that's exactly when the agent's own map renderer and
# the MCP subprocess race for the same loop.
tok = await asyncio.to_thread(
registry.get_token, tenant or None
)
except KeyError as e:
return Response(
content=f"tenant routing failed: {e}",
status_code=400,
)
except Exception as e:
logger.exception("ee-proxy: token mint failed (tenant=%r)", tenant)
return Response(content=f"auth mint failed: {e}", status_code=500)
actual_tenant = tok.get("tenant", tenant or "default")
access_token = tok["access_token"]
quota_project = (
tok.get("project_id")
or os.environ.get("GEE_PROJECT", "")
)
# 2. Rewrite the query string: strip client-set workloadTag and
# the internal tenant param; add our own workload tag on POSTs.
# GET requests can't carry unknown query params on most EE
# endpoints, so we just strip there without adding.
if request.method == "POST":
rewritten_query = _rewrite_query_with_workload_tag(
request.url.query or "",
actual_tenant,
tag_builder,
request,
tenant_query_param,
)
else:
rewritten_query = urlencode([
(k, v) for k, v in parse_qsl(
request.url.query or "", keep_blank_values=True
)
if k != "workloadTag" and k != tenant_query_param
])
upstream_url = f"{upstream}/{path}"
if rewritten_query:
upstream_url = f"{upstream_url}?{rewritten_query}"
# 3. Forward headers — strip hop-by-hop, auth, IAP, and our own
# tenant routing header (must never leak to EE).
stripped = set(_STRIPPED_HEADERS)
stripped.add(tenant_header.lower())
fwd_headers = {}
for k, v in request.headers.items():
if k.lower() in stripped:
continue
fwd_headers[k] = v
fwd_headers["authorization"] = f"Bearer {access_token}"
# ``$discovery/rest`` is the googleapiclient discovery doc. EE
# itself strips quota-project on credentials before fetching it
# (see ee._cloud_api_utils.build_cloud_resource) because the
# serviceUsage API rejects discovery requests that carry a
# consumer project. Mirror that here — without this, SAs that
# otherwise work fine 403 on init.
is_discovery = "$discovery/rest" in path
if quota_project and not is_discovery:
fwd_headers["x-goog-user-project"] = quota_project
try:
body = await request.body()
except ClientDisconnect:
# Browser aborted the request before we finished reading it —
# typical map-viewer pattern where pan/zoom cancels in-flight
# tile fetches. Client is gone; no one to respond to. Return
# a 499 (Nginx's "Client Closed Request") so anything logging
# by status still sees this as a disconnect, not a 5xx.
return Response(status_code=499)
# 4. Forward + retry once on 401 (token rotation). Uses the
# shared ``upstream_client`` (keep-alive connection pool) — see
# the construction above for why we don't create per-request.
async def _do_upstream():
return await upstream_client.request(
request.method, upstream_url,
content=body if body else None,
headers=fwd_headers,
)
try:
upstream_resp = await _do_upstream()
except (httpx.ReadTimeout, httpx.ConnectTimeout) as e:
# EE compute occasionally exceeds the 120s deadline. One
# retry with fresh connection buys the caller another shot
# before a hard 504. Compute requests are idempotent — the
# server-side operation may still complete, but retrying
# the read doesn't duplicate work.
logger.warning(
"ee-proxy: upstream timeout on %s %s — retrying once",
request.method, path,
)
try:
upstream_resp = await _do_upstream()
except (httpx.ReadTimeout, httpx.ConnectTimeout):
logger.warning(
"ee-proxy: upstream timeout on %s %s — returning 504",
request.method, path,
)
return Response(
content=f"upstream timeout after retry: {e}",
status_code=504,
)
except httpx.HTTPError as e2:
logger.exception("ee-proxy: upstream error on retry for %s %s",
request.method, path)
return Response(content=f"upstream error: {e2}", status_code=502)
except httpx.HTTPError as e:
logger.exception("ee-proxy: upstream error for %s %s",
request.method, path)
return Response(content=f"upstream error: {e}", status_code=502)
if upstream_resp.status_code == 401:
try:
tok = await asyncio.to_thread(
registry.get_token, actual_tenant, True
)
fwd_headers["authorization"] = f"Bearer {tok['access_token']}"
qp = (tok.get("project_id")
or os.environ.get("GEE_PROJECT", ""))
if qp and not is_discovery:
fwd_headers["x-goog-user-project"] = qp
upstream_resp = await upstream_client.request(
request.method, upstream_url,
content=body if body else None,
headers=fwd_headers,
)
except Exception:
logger.exception("ee-proxy: retry after 401 failed")
# 5. Pass through, stripping hop-by-hop and auth-related response
# headers that are context-specific to the upstream.
resp_headers = {}
for k, v in upstream_resp.headers.items():
if k.lower() in ("content-encoding", "content-length",
"transfer-encoding", "connection", "server"):
continue
resp_headers[k] = v
return Response(
content=upstream_resp.content,
status_code=upstream_resp.status_code,
headers=resp_headers,
media_type=upstream_resp.headers.get("content-type"),
)
return router
[docs]
def create_proxy_app(
creds=None,
upstream: str = DEFAULT_UPSTREAM,
tenant_header: str = DEFAULT_TENANT_HEADER,
tenant_query_param: str = "tenant",
tenant_resolver: Optional[Callable[[Request, str], str]] = None,
workload_tag_builder: Optional[Callable[[Request, str], str]] = None,
prefix: str = "/ee-api",
serve_geeview: bool = True,
) -> FastAPI:
"""Build a standalone FastAPI app with the proxy mounted at ``prefix``.
Suitable for direct serving via ``uvicorn`` or for testing.
``creds`` accepts an :class:`EECreds` / :class:`SARegistry`-like
object; ``None`` falls back to the env-var registry. See
:func:`build_proxy_router` for the other parameters.
Use ``build_proxy_router`` directly if you want to mount in an
existing FastAPI app and share its middleware / lifecycle.
Args:
serve_geeview: When True (default for standalone runs), also
mount the geeView frontend bundle at ``/geeView/*``. This
makes the detached proxy the single long-lived server for
both EE auth (``/ee-api/*``) and ``Map.view()`` HTML
(``/geeView/...``). Same origin, same port — browser tabs
survive script exits without a daemon-thread server inside
each script. Set False to keep the proxy auth-only.
"""
app = FastAPI(title="geeViz EE proxy")
app.include_router(
build_proxy_router(
creds=creds,
upstream=upstream,
tenant_header=tenant_header,
tenant_query_param=tenant_query_param,
tenant_resolver=tenant_resolver,
workload_tag_builder=workload_tag_builder,
),
prefix=prefix,
)
if serve_geeview:
# Mount the geeViz package directory at /geeView. Map.view()
# writes exports into ``<package>/geeView/src/gee/gee-run/`` —
# the browser fetches them at ``/geeView/src/gee/gee-run/<file>``
# and all relative asset references (``src/lib/...``,
# ``src/css/...``, ``src/gee/...``) resolve under the same
# ``/geeView/`` root, matching what the legacy in-script
# ``_GeeVizRequestHandler`` served.
from fastapi.staticfiles import StaticFiles
import os as _os
_PKG_DIR = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
_GEEVIEW_DIR = _os.path.join(_PKG_DIR, "geeView")
if _os.path.isdir(_GEEVIEW_DIR):
app.mount(
"/geeView",
StaticFiles(directory=_GEEVIEW_DIR, html=True),
name="geeview-static",
)
def _list_tenants() -> list:
if creds is None:
return get_registry().list_tenants()
# EECreds uses list() (insertion order); SARegistry uses list_tenants()
if hasattr(creds, "list_tenants"):
return creds.list_tenants()
return creds.list()
@app.get("/")
def _root():
"""Lightweight health check + tenant listing."""
return {
"service": "geeViz.eeAuth proxy",
"tenants_loaded": _list_tenants(),
"mount_prefix": prefix,
"upstream": upstream,
"tenant_header": tenant_header,
}
return app