Files
base/plugins/platform/idv_proxy.go
zeekay e7df8c856e base: complete pocketbase→base rename; Base = pure IAM client
Rename / namespace:
- Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep
  across all Go sources; restore the renamed root launcher (Base, base.go).
- Drop the upstream-only surfaces ours doesn't ship (admin ui/, the
  password/OTP/oauth2/impersonate auth files, extensions) re-added by the
  upstream merge.
- Decomplect the on-disk layout to one base_/ namespace: data/, migrations/,
  hooks/, types.d.ts are siblings under base_/ (default data dir
  base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's
  identical JS/Go branches to one path.
- Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and
  gitignore the runtime namespace dirs.

Deps / build:
- kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module
  (the root kms module zip exceeds Go's download limit); cloud/pkg/base
  Mount removed (lives in the fused daemon).
- replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto);
  network/metrics.go import fix; auth gitlab/github tidy.

Tests / behavior:
- Wire registerNotifyWatcherHooks; debounce notify files past the kqueue
  rescan window so cross-instance settings/collections reload fires.
- migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate
  Go+JS automigration goldens from canonical output.
- Convert the two stale archive red-probes to assert the landed defenses
  (backlog cap bounds memory; forged-segment signature is rejected).

IAM-native (Base = pure IAM client):
- Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the
  iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers
  rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused
  daemon) is required; Base validates IAM JWTs via JWKS only — superuser is
  the IAM admin claim, resolved to an ephemeral unpersisted record.
- Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was
  mis-colocated in the embedded onboarding file and is independent of IAM.
- Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a
  JWKS stub (no hardcoded record tokens, no persisted rows).
2026-06-10 21:29:14 -07:00

141 lines
4.3 KiB
Go

// IDV (identity verification) transparent proxy for the platform plugin.
//
// IDV lives at /v1/idv/* — its own service domain, a sibling of /v1/iam,
// NOT a child. Base proxies the whole /v1/idv/* surface to
// ${IDV_ENDPOINT}/v1/idv/* so SPAs talk to one host while the upstream
// `hanzoai/idv` service stays decomplected. With IDV_ENDPOINT unset the
// proxy returns 200 {enabled:false} on /v1/idv/status and 503 elsewhere.
//
// This is independent of where IAM lives — Base is a pure IAM client and
// proxies to whatever IDV service IDV_ENDPOINT points at.
package platform
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/router"
)
// IDVEndpoint returns the configured upstream IDV service URL, or
// "" when IDV is disabled. The trailing slash is stripped so
// callers can always concatenate "/v1/idv/...".
func IDVEndpoint() string {
return strings.TrimRight(os.Getenv("IDV_ENDPOINT"), "/")
}
// idvMount is the mount path for the IDV proxy. Sibling of /v1/iam,
// NOT a child — IDV is its own service domain.
const idvMount = "/v1/idv"
type idvStatusResp struct {
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Provider string `json:"provider"`
Label string `json:"label"`
}
// registerIDVProxy mounts /v1/idv/* — sibling of /v1/iam, unconditional.
// With IDV_ENDPOINT unset, /v1/idv/status returns {enabled:false} and the
// rest return 503.
func (p *plugin) registerIDVProxy(r *router.Router[*core.RequestEvent]) {
r.GET(idvMount+"/status", p.handleIDVStatus)
r.GET(idvMount+"/{path...}", p.handleIDVProxy)
r.POST(idvMount+"/{path...}", p.handleIDVProxy)
r.PUT(idvMount+"/{path...}", p.handleIDVProxy)
r.PATCH(idvMount+"/{path...}", p.handleIDVProxy)
r.DELETE(idvMount+"/{path...}", p.handleIDVProxy)
}
func (p *plugin) handleIDVStatus(e *core.RequestEvent) error {
endpoint := IDVEndpoint()
if endpoint == "" {
return e.JSON(http.StatusOK, idvStatusResp{Enabled: false})
}
ctx, cancel := context.WithTimeout(e.Request.Context(), 4*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", endpoint+"/v1/idv/status", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// IDV unreachable — surface "enabled but degraded" rather
// than masking. SPAs can show a friendly error.
return e.JSON(http.StatusOK, idvStatusResp{
Enabled: true,
Endpoint: endpoint,
Label: "unreachable",
})
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var upstream idvStatusResp
if err := json.Unmarshal(body, &upstream); err != nil {
return e.JSON(http.StatusOK, idvStatusResp{
Enabled: true,
Endpoint: endpoint,
Label: "unknown",
})
}
upstream.Enabled = true
if upstream.Endpoint == "" {
upstream.Endpoint = endpoint
}
return e.JSON(http.StatusOK, upstream)
}
// handleIDVProxy is a thin reverse proxy for everything under
// /v1/idv/* (other than /status which is handled separately for the
// "disabled" short-circuit). One round-trip, no body buffering, no
// header rewriting beyond Host.
func (p *plugin) handleIDVProxy(e *core.RequestEvent) error {
endpoint := IDVEndpoint()
if endpoint == "" {
return e.Error(http.StatusServiceUnavailable, "IDV not configured (set IDV_ENDPOINT)", nil)
}
upstreamPath := e.Request.URL.Path
if upstreamPath == "" {
upstreamPath = "/v1/idv/"
}
upstreamURL := endpoint + upstreamPath
if e.Request.URL.RawQuery != "" {
upstreamURL += "?" + e.Request.URL.RawQuery
}
req, err := http.NewRequestWithContext(e.Request.Context(), e.Request.Method, upstreamURL, e.Request.Body)
if err != nil {
return e.Error(http.StatusInternalServerError, "proxy req: "+err.Error(), nil)
}
// Forward client headers verbatim. The IDV service handles its
// own auth (API key / signature on inbound webhooks).
for k, vv := range e.Request.Header {
for _, v := range vv {
req.Header.Add(k, v)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return e.Error(http.StatusBadGateway, "upstream IDV unreachable: "+err.Error(), nil)
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
e.Response.Header().Add(k, v)
}
}
e.Response.WriteHeader(resp.StatusCode)
_, _ = io.Copy(e.Response, resp.Body)
return nil
}