Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b0b4c20b | ||
|
|
3547155ece | ||
|
|
cf6f8245f9 | ||
|
|
ec6b96f1aa | ||
|
|
b290ac0088 | ||
|
|
5907cb70cf | ||
|
|
c00c756229 |
+6
-27
@@ -34,7 +34,6 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -88,7 +87,6 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/graph"
|
||||
"github.com/hanzoai/cloud/clients/guide"
|
||||
"github.com/hanzoai/cloud/clients/iam"
|
||||
"github.com/hanzoai/cloud/clients/iam2"
|
||||
"github.com/hanzoai/cloud/clients/ingress"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/kafka"
|
||||
@@ -168,22 +166,6 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
|
||||
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
|
||||
// anything else — including unset, the production default — keeps the legacy beego
|
||||
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
|
||||
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
|
||||
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
|
||||
// in Wire, preserving mount order either way. os.Getenv (not the unexported
|
||||
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
|
||||
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
|
||||
func identitySpec() cloud.MountSpec {
|
||||
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
|
||||
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
|
||||
}
|
||||
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
|
||||
}
|
||||
|
||||
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
|
||||
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
|
||||
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
|
||||
@@ -215,15 +197,12 @@ func Wire() []cloud.MountSpec {
|
||||
// /v1/commerce/topup/wallet). MUST mount before the IAM /v1/iam/* wildcard (50) so
|
||||
// they win Fiber's first-match scan (framework-guaranteed since zip v1.3.0).
|
||||
{Name: "account", Mount: account.MountAccount},
|
||||
// Embedded IAM identity plane (/v1/iam/*, /.well-known/*, /login/oauth/*, /_/iam/*,
|
||||
// /cas/*, /scim/*) — the identity authority, mounts before its dependents. STAGED:
|
||||
// the operator adds "iam" to --enable only after IAM config + the fold are verified.
|
||||
// Which IMPLEMENTATION owns these prefixes is selected by CLOUD_IAM_IMPL
|
||||
// (identitySpec): the clean-room iam2 (zip+orm, beego-free) when =="iam2", else the
|
||||
// legacy beego Casdoor embed — the default (unset = today's behavior, byte-for-byte).
|
||||
// Both register the SAME absolute paths and cannot co-mount, so this is an either/or
|
||||
// switch at this ONE slot, never a shadow prefix.
|
||||
identitySpec(),
|
||||
// Embedded IAM identity plane (/v1/iam/*, /login/oauth/*) — the identity authority,
|
||||
// mounts before its dependents. The ONE implementation: the clean-room iam-v2
|
||||
// (zip-native + hanzoai/orm, beego-free); the retired Casdoor iam-v1 embed is GONE.
|
||||
// STAGED: the operator adds "iam" to --enable only after IAM config + the fold are
|
||||
// verified (login/authorize/token/jwks + the operator SSO chain).
|
||||
{Name: "iam", Mount: iam.Mount},
|
||||
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
|
||||
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
|
||||
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
|
||||
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
aiobject "github.com/hanzoai/ai/object"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -85,12 +86,47 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
}
|
||||
|
||||
// build carries no per-subsystem state — analytics reads the shared warehouse. It
|
||||
// only records the informative mount line.
|
||||
// records the informative mount line and installs the site-host ingest carve.
|
||||
func build(b cloud.Base) (state, error) {
|
||||
b.Log.Info("analytics surface", "warehouse", "hanzo", "brand", b.Brand)
|
||||
installHostCarve(b)
|
||||
return state{}, nil
|
||||
}
|
||||
|
||||
// installHostCarve wires the published-site-host beacon ingest (the twin of base's
|
||||
// sites.SetBaseHostHandler): a page served on a site host can POST its OWN analytics
|
||||
// beacon to the canonical /v1/event (or the deprecated /v1/analytics{,/batch} and
|
||||
// /v1/insights/e beacons kept working mid-migration) and have it ingested into
|
||||
// hanzo.events with the tenant FORCED to the site's resolved Org — the
|
||||
// server-supplied, host-derived tenant, never a body/header claim. It funnels
|
||||
// through the SAME write core (eventWithOrg / captureWithOrg / insightsWithOrg →
|
||||
// ingestBody → ingestEvents); the in-handler tenant resolvers are deliberately NOT
|
||||
// consulted because the host already authorizes the tenant.
|
||||
//
|
||||
// Gated by the SAME already-existing flag the anonymous ingest path uses —
|
||||
// CLOUD_ANALYTICS_PUBLIC_CAPTURE (publicCaptureEnabled, default ON) — so a site
|
||||
// host accepts its own beacons out of the box, and turning public capture off also
|
||||
// removes this carve (a site host then 405s a beacon POST, unchanged). The org is
|
||||
// the resolver's Site.Org; sites.Middleware gates this carve on method POST so the
|
||||
// authenticated GET read lenses are never hijacked.
|
||||
func installHostCarve(b cloud.Base) {
|
||||
if !publicCaptureEnabled() {
|
||||
b.Log.Info("analytics public-host ingest carve disabled", "flag", publicCaptureEnv)
|
||||
return
|
||||
}
|
||||
sites.SetAnalyticsHostHandler(func(org string, c *zip.Ctx) error {
|
||||
switch c.Path() {
|
||||
case "/v1/event": // the canonical door — host-forced org, canonical wire
|
||||
return eventWithOrg(org, c)
|
||||
case "/v1/insights/e": // deprecated PostHog-wire beacon
|
||||
return insightsWithOrg(org, c)
|
||||
default: // deprecated Segment/beacon wire: /v1/analytics{,/batch}
|
||||
return captureWithOrg(org, c)
|
||||
}
|
||||
})
|
||||
b.Log.Info("analytics public-host ingest carve enabled", "flag", publicCaptureEnv)
|
||||
}
|
||||
|
||||
// routes registers the analytics surface. Health owns /v1/analytics/health
|
||||
// explicitly (not JWT-gated: liveness must be probe-able); the data endpoints are
|
||||
// all org-gated in-handler.
|
||||
@@ -101,29 +137,34 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/analytics/top", cloud.Handle(s, top))
|
||||
|
||||
// Capture (WRITE) side — the ingest that fills hanzo.events. POST /v1/event
|
||||
// (event.go) is the ONE canonical front door: body Event | [Event], org
|
||||
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
|
||||
// (event.go) is the ONE canonical front door serving EVERY auth context (IAM
|
||||
// bearer | pk_ publishable key | site-host-forced) and EVERY wire shape
|
||||
// (Event | [Event] | {batch}) into the ONE write core (ingestEvents). Every
|
||||
// other route below is a thin alias/shim delegating to it.
|
||||
app.Post("/v1/event", cloud.Handle(s, eventIngest))
|
||||
|
||||
// Publishable-key direct ingest (publishable.go) — the FASTEST path: a
|
||||
// write-only pk_ key (HMAC-signed org, no IAM/DB hop) authenticates
|
||||
// {batch:[WireEvent]} straight into the ONE write core. /v1/ingest/keys mints a
|
||||
// pk_ for the caller's org; /v1/errors is the type:'error' read lens (validated
|
||||
// principal — reads never accept the write-only key).
|
||||
// /v1/ingest — a THIN DEPRECATED ALIAS of /v1/event (delegates to the exact
|
||||
// eventHandle logic: pk_ auth now lives on the canonical door). /v1/ingest/keys
|
||||
// mints a pk_ for the caller's org (minting is a distinct concern, not ingest);
|
||||
// /v1/errors is the type:'error' read lens (validated principal — reads never
|
||||
// accept the write-only key).
|
||||
app.Post("/v1/ingest", cloud.Handle(s, ingest))
|
||||
app.Post("/v1/ingest/keys", cloud.Handle(s, mintKey))
|
||||
app.Get("/v1/errors", cloud.Handle(s, errorsLens))
|
||||
|
||||
// DEPRECATED ingest aliases — thin wire adapters that normalize onto the SAME
|
||||
// write core (log a one-shot deprecation, keep working). /v1/analytics{,/batch}
|
||||
// and /v1/tracker speak the Segment/beacon CaptureBatch wire; /v1/tracker is a
|
||||
// bare route (never collides with the /v1/tracker/projects* issue tracker).
|
||||
// DEPRECATED foreign-protocol ingest shims — external-SDK compat ONLY; no Hanzo
|
||||
// surface uses these (Hanzo surfaces POST /v1/event). They normalize their own
|
||||
// wire onto CaptureEvent and funnel through the SAME write core (log a one-shot
|
||||
// deprecation, keep working so external Segment/beacon callers are unbroken).
|
||||
// /v1/analytics{,/batch} and /v1/tracker speak the Segment/beacon CaptureBatch
|
||||
// wire; /v1/tracker is a bare route (never collides with /v1/tracker/projects*).
|
||||
app.Post("/v1/analytics", cloud.Handle(s, capture))
|
||||
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
|
||||
app.Post("/v1/tracker", cloud.Handle(s, capture))
|
||||
|
||||
// /v1/insights — console reads over the SAME engine + the DEPRECATED PostHog-
|
||||
// wire ingest adapter (/v1/insights/e → the ONE write core). Flags live at /v1/flags.
|
||||
// wire ingest shim (/v1/insights/e → the ONE write core; external PostHog SDK
|
||||
// compat only). Flags live at /v1/flags.
|
||||
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
|
||||
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
|
||||
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
|
||||
|
||||
@@ -671,24 +671,32 @@ func ingestEvents(ctx context.Context, org, source string, evs []CaptureEvent) (
|
||||
return CaptureResult{Accepted: len(rows), Dropped: dropped}, nil
|
||||
}
|
||||
|
||||
// capture ingests a Segment/beacon batch into hanzo.events, tenant-scoped. It is
|
||||
// the DEPRECATED wire adapter behind /v1/analytics, /v1/analytics/batch, and
|
||||
// /v1/tracker: a thin CaptureBatch decoder over the ONE write core (ingestEvents).
|
||||
// New callers post the canonical Event to /v1/event; this alias keeps working and
|
||||
// keeps captureTenant's brand-host path for anonymous marketing traffic.
|
||||
// capture ingests a Segment/beacon batch into hanzo.events, tenant-scoped. It is a
|
||||
// DEPRECATED foreign-protocol shim behind /v1/analytics, /v1/analytics/batch, and
|
||||
// /v1/tracker — external-SDK compat ONLY; no Hanzo surface uses these (Hanzo
|
||||
// surfaces POST /v1/event). It funnels through the ONE ingest core (ingestBody via
|
||||
// captureWithOrg) and keeps captureTenant's brand-host path for anonymous external
|
||||
// marketing traffic, which the strict canonical door deliberately refuses.
|
||||
func capture(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
deprecated(s, c, "/v1/event")
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
}
|
||||
var batch CaptureBatch
|
||||
if err := c.Bind(&batch); err != nil {
|
||||
return zip.ErrBadRequest("malformed capture batch")
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceCapture, batch.events())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
return captureWithOrg(org, c)
|
||||
}
|
||||
|
||||
// captureWithOrg is the Segment/beacon ingest with the tenant supplied EXPLICITLY by
|
||||
// the caller — the ONE write path shared by two org sources:
|
||||
// - the /v1/analytics{,/batch}, /v1/tracker aliases resolve org via captureTenant
|
||||
// (validated principal | project key | brand host) and call this;
|
||||
// - the site-host carve (sites.SetAnalyticsHostHandler) FORCES org from the
|
||||
// resolved Site (server-supplied, host-derived — never the caller/body) and
|
||||
// calls this.
|
||||
//
|
||||
// It funnels through the ONE ingest core (ingestBody, source=capture), which is
|
||||
// wire-tolerant — so the Segment {batch} envelope this wire speaks decodes through
|
||||
// the SAME decoder as the canonical door; org is never read from the body.
|
||||
func captureWithOrg(org string, c *zip.Ctx) error {
|
||||
return ingestBody(c, org, sourceCapture)
|
||||
}
|
||||
|
||||
+153
-45
@@ -14,21 +14,36 @@
|
||||
|
||||
// event.go — the ONE canonical event-ingestion front door.
|
||||
//
|
||||
// POST /v1/event body: Event | [Event] -> {accepted, dropped}
|
||||
// POST /v1/event body: Event | [Event] | {batch:[…]} -> {accepted, dropped}
|
||||
//
|
||||
// A JSON object is one event; a JSON array IS the batch (there is deliberately no
|
||||
// /v1/event/batch). Every other ingest surface (the PostHog wire at
|
||||
// /v1/insights/e, the Segment/beacon wire at /v1/analytics{,/batch} and
|
||||
// /v1/tracker) is a thin DEPRECATED adapter that normalizes its own wire shape
|
||||
// onto CaptureEvent and funnels through the SAME write core (ingestEvents) into
|
||||
// the SAME hanzo.events table. One write path, many adapters.
|
||||
// ONE door, EVERY wire, EVERY auth context. The decoder (decodeIngest) is
|
||||
// wire-tolerant: a bare canonical Event object, a bare [Event] array, AND the
|
||||
// CaptureBatch envelope ({batch:[…]} | {events:[…]}) the Segment/beacon/publishable
|
||||
// paths speak all decode onto the SAME []CaptureEvent the ONE write core
|
||||
// (ingestEvents) consumes, into the SAME hanzo.events table. There is deliberately
|
||||
// no /v1/event/batch — a JSON array, or a batch envelope, IS the batch.
|
||||
//
|
||||
// AUTH — IAM ONLY, FAIL-CLOSED: the tenant is resolved SERVER-SIDE from a
|
||||
// validated bearer principal (its owner org) or, for a keyed bearer-less SDK, an
|
||||
// access key resolved through the ONE IAM key seam (cloud.OrgForKey). There is NO
|
||||
// brand-host fallback on this endpoint: an unauthenticated or unresolvable caller
|
||||
// is refused (403), so the canonical door never writes an event into a tenant IAM
|
||||
// did not vouch for. The org is NEVER read from the body.
|
||||
// AUTH is the orthogonal, PLUGGABLE concern on this one door (eventTenant), resolved
|
||||
// SERVER-SIDE and FAIL-CLOSED, in strict trust order:
|
||||
//
|
||||
// 1. a validated IAM bearer principal — its owner org;
|
||||
// 2. a write-only publishable key (pk_…) — HMAC-verified org, no IAM/DB hop (the
|
||||
// SAME key publishable.go mints; folded in here so a pk_ caller uses /v1/event
|
||||
// directly);
|
||||
// 3. an out-of-band IAM access key (hk-/sk-…) — resolved through the ONE key seam
|
||||
// (cloud.OrgForKey).
|
||||
//
|
||||
// None of the above ⇒ 403. There is NO brand-host fallback on the canonical door
|
||||
// (that path stays only on the deprecated aliases), so /v1/event never writes an
|
||||
// event into a tenant IAM did not vouch for. The org is NEVER read from the body.
|
||||
//
|
||||
// The site-host carve (eventWithOrg) is the ONE exception to in-handler auth: on a
|
||||
// published site host the tenant is FORCED from the resolved Site BEFORE the handler
|
||||
// — the same server-supplied, host-derived tenant the file/base carves trust.
|
||||
//
|
||||
// Every other ingest route (/v1/ingest, /v1/analytics{,/batch}, /v1/tracker,
|
||||
// /v1/insights/e) is a thin alias/shim that resolves org its own way and funnels
|
||||
// through the SAME decode + write core. One write path, many doors.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
@@ -64,17 +79,28 @@ func (e Event) toCapture() CaptureEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// eventTenant resolves the tenant for POST /v1/event — IAM ONLY, FAIL-CLOSED. A
|
||||
// validated bearer principal wins (its owner org); otherwise a presented access
|
||||
// key is resolved to its org through the ONE IAM key seam (resolveKeyOrg →
|
||||
// cloud.OrgForKey). There is NO brand-host fallback: an unauthenticated or
|
||||
// unresolvable caller returns ("", false) → 403. (The deprecated aliases keep
|
||||
// captureTenant's brand-host path for anonymous marketing traffic; the canonical
|
||||
// endpoint is deliberately stricter — IAM is the only tenant authority here.)
|
||||
// eventTenant resolves the tenant for the canonical door — PLUGGABLE auth,
|
||||
// FAIL-CLOSED, in strict trust order:
|
||||
//
|
||||
// 1. a validated IAM bearer principal wins (its owner org);
|
||||
// 2. else a presented write-only publishable key (pk_…) is HMAC-verified to its
|
||||
// org with no IAM/DB hop (the SAME verifier publishable.go's /v1/ingest used —
|
||||
// folded in here so a pk_ caller uses /v1/event directly);
|
||||
// 3. else a presented out-of-band IAM access key (hk-/sk-…) is resolved to its org
|
||||
// through the ONE key seam (resolveKeyOrg → cloud.OrgForKey).
|
||||
//
|
||||
// None matches ⇒ ("", false) → 403. There is NO brand-host fallback (that path
|
||||
// stays only on the deprecated aliases), so the canonical door is strictly authed —
|
||||
// IAM or a signed/resolvable key, never the request Host.
|
||||
func eventTenant(c *zip.Ctx) (string, bool) {
|
||||
if org, ok := tenant(c); ok {
|
||||
return org, true
|
||||
}
|
||||
if key := ingestKey(c); key != "" {
|
||||
if org, ok := verifyPublishableKey(ingestSecret(), key); ok {
|
||||
return org, true
|
||||
}
|
||||
}
|
||||
if key := projectKey(c); key != "" {
|
||||
if org, ok := resolveKeyOrg(c.Context(), key); ok {
|
||||
return org, true
|
||||
@@ -83,20 +109,27 @@ func eventTenant(c *zip.Ctx) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// decodeEvents decodes a request body as Event | []Event. The first non-space
|
||||
// byte decides: '[' ⇒ the array batch, anything else ⇒ a single Event. An empty
|
||||
// body yields no events (an honest empty receipt, not an error). Pure over the
|
||||
// raw bytes (the handler passes c.Body() — fasthttp-buffered, the same bytes
|
||||
// projectKey peeked) so the decode is driven directly by tests.
|
||||
func decodeEvents(body []byte) ([]Event, error) {
|
||||
i := 0
|
||||
for i < len(body) {
|
||||
if b := body[i]; b == ' ' || b == '\t' || b == '\r' || b == '\n' {
|
||||
i++
|
||||
continue
|
||||
// firstNonWS returns the index of the first non-JSON-whitespace byte, or len(body)
|
||||
// when the body is empty or all whitespace. The four bytes are JSON's insignificant
|
||||
// whitespace (RFC 8259 §2). The ONE place the ingest decoders skip leading space.
|
||||
func firstNonWS(body []byte) int {
|
||||
for i := 0; i < len(body); i++ {
|
||||
switch body[i] {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
default:
|
||||
return i
|
||||
}
|
||||
break
|
||||
}
|
||||
return len(body)
|
||||
}
|
||||
|
||||
// decodeEvents decodes a body as the canonical Event wire: Event | []Event. The
|
||||
// first non-space byte decides: '[' ⇒ the array batch, anything else ⇒ a single
|
||||
// Event. An empty body yields no events (an honest empty receipt, not an error).
|
||||
// Pure over the raw bytes; it is the canonical-Event sub-decoder inside decodeIngest
|
||||
// (which additionally accepts the CaptureBatch envelope).
|
||||
func decodeEvents(body []byte) ([]Event, error) {
|
||||
i := firstNonWS(body)
|
||||
if i >= len(body) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -114,26 +147,101 @@ func decodeEvents(body []byte) ([]Event, error) {
|
||||
return []Event{e}, nil
|
||||
}
|
||||
|
||||
// eventIngest answers POST /v1/event — the ONE canonical ingestion front door.
|
||||
// Org is IAM-derived and fail-closed (eventTenant); the body is Event | [Event];
|
||||
// every event flows through the ONE write core (ingestEvents) into the ONE
|
||||
// hanzo.events table, tagged source=event.
|
||||
func eventIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := eventTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a resolvable access key required")
|
||||
// decodeIngest is the ONE wire-tolerant decoder of the canonical door. It accepts
|
||||
// EVERY shape a Hanzo surface emits and yields the SAME []CaptureEvent the write
|
||||
// core consumes:
|
||||
//
|
||||
// - the CaptureBatch envelope {batch:[…]} | {events:[…]} — the Segment/beacon/
|
||||
// publishable wire — detected by a top-level batch/events key;
|
||||
// - a bare canonical Event object {"event":…,"distinctId":…};
|
||||
// - a bare canonical Event array [ {…}, … ].
|
||||
//
|
||||
// An empty/whitespace-only body ⇒ no events (honest empty receipt, not an error).
|
||||
// Pure over the raw bytes (handlers pass c.Body() — fasthttp-buffered, the same
|
||||
// bytes projectKey/ingestKey peeked), so the decode is driven directly by tests.
|
||||
func decodeIngest(body []byte) ([]CaptureEvent, error) {
|
||||
i := firstNonWS(body)
|
||||
if i >= len(body) {
|
||||
return nil, nil
|
||||
}
|
||||
evs, err := decodeEvents(c.Body())
|
||||
if body[i] == '{' {
|
||||
// An object is the CaptureBatch envelope iff it carries a batch/events key;
|
||||
// otherwise it is a bare canonical Event. RawMessage is non-nil whenever the
|
||||
// key is present (even `[]`), so an empty batch is still routed as an envelope.
|
||||
var probe struct {
|
||||
Batch json.RawMessage `json:"batch"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if probe.Batch != nil || probe.Events != nil {
|
||||
var batch CaptureBatch
|
||||
if err := json.Unmarshal(body, &batch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return batch.events(), nil
|
||||
}
|
||||
}
|
||||
evs, err := decodeEvents(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caps := make([]CaptureEvent, len(evs))
|
||||
for j, e := range evs {
|
||||
caps[j] = e.toCapture()
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// ingestBody is the ONE ingest core given an already-resolved org: wire-tolerant
|
||||
// decode (decodeIngest) → fold type:'error' events (foldException) → the ONE write
|
||||
// core (ingestEvents) → the honest receipt. org is the SERVER-resolved tenant (never
|
||||
// client input); source tags the front door for the $source migration signal. Every
|
||||
// door — the canonical /v1/event (pluggable auth), the site-host carve (forced org),
|
||||
// and the deprecated aliases — resolves org its OWN way then calls THIS: auth is the
|
||||
// only thing that differs between doors, the decode + write path is identical.
|
||||
func ingestBody(c *zip.Ctx, org, source string) error {
|
||||
evs, err := decodeIngest(c.Body())
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("malformed event payload")
|
||||
}
|
||||
caps := make([]CaptureEvent, len(evs))
|
||||
for i, e := range evs {
|
||||
caps[i] = e.toCapture()
|
||||
for i := range evs {
|
||||
evs[i] = foldException(evs[i])
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceEvent, caps)
|
||||
res, err := ingestEvents(c.Context(), org, source, evs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// eventHandle is the canonical-door handler core: pluggable in-handler auth
|
||||
// (eventTenant, fail-closed) → the ONE ingest core. source tags the door so the
|
||||
// canonical /v1/event and the /v1/ingest deprecated alias share ONE implementation,
|
||||
// differing only in origin tag (and the alias's deprecation log).
|
||||
func eventHandle(c *zip.Ctx, source string) error {
|
||||
org, ok := eventTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a resolvable ingest key required")
|
||||
}
|
||||
return ingestBody(c, org, source)
|
||||
}
|
||||
|
||||
// eventIngest answers POST /v1/event — the ONE canonical ingestion front door.
|
||||
// Org is resolved fail-closed (eventTenant: bearer | pk_ | access key); the body is
|
||||
// Event | [Event] | {batch}; every event flows through the ONE write core into the
|
||||
// ONE hanzo.events table, tagged source=event.
|
||||
func eventIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return eventHandle(c, sourceEvent)
|
||||
}
|
||||
|
||||
// eventWithOrg is the canonical-door ingest with the tenant supplied EXPLICITLY by
|
||||
// the caller — the twin of captureWithOrg/insightsWithOrg for the site-host carve.
|
||||
// The carve FORCES org from the resolved Site (host-derived, never the caller/body)
|
||||
// and calls this, so a published-site beacon POSTing <host>/v1/event lands as that
|
||||
// site's Org regardless of any body/header claim. Tagged source=event: it is the
|
||||
// canonical wire, merely authorized by the host instead of a bearer/key.
|
||||
func eventWithOrg(org string, c *zip.Ctx) error {
|
||||
return ingestBody(c, org, sourceEvent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// appBeaconBody is the EXACT payload the published-site page beacon posts
|
||||
// (app/lib/publishing/wired-injection.ts:68-69): a {batch:[CaptureEvent]} envelope.
|
||||
// The only change the app makes is repointing ANALYTICS_ENDPOINT from /v1/analytics
|
||||
// to /v1/event — the body is unchanged and MUST land via the canonical door's
|
||||
// site-host carve.
|
||||
const appBeaconBody = `{"batch":[{"messageId":"m-abc123","type":"pageview","event":"$pageview",` +
|
||||
`"timestamp":"2026-07-22T12:00:00.000Z","distinctId":"anon-9","anonymousId":"anon-9",` +
|
||||
`"sessionId":"sess-1","url":"https://yadota.hanzo.app/pricing","path":"/pricing",` +
|
||||
`"referrer":"https://news.ycombinator.com/","properties":{"space":"yadota","title":"Pricing"},` +
|
||||
`"library":"@hanzo/capture-wired","libraryVersion":"0.1.1"}]}`
|
||||
|
||||
// TestMount_HostCarve_EventDoorForcesOrg is the /v1/event twin of
|
||||
// TestMount_HostCarve_ForcesSiteOrg: a beacon POST to the CANONICAL door on a LIVE
|
||||
// site host is ingested as the site's Org even though the request carries a forged
|
||||
// org (body + X-Org-Id) and NO validated principal. 503-not-403 is the discriminator
|
||||
// — the same body 403s a DIRECT /v1/event call (no brand fallback), but here it
|
||||
// passes because the carve FORCES org from the host, stopping only at datastore-down.
|
||||
func TestMount_HostCarve_EventDoorForcesOrg(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
for _, body := range []string{
|
||||
`{"event":"signup_completed","distinctId":"d","org":"attacker"}`, // bare Event
|
||||
`[{"event":"signup_completed","distinctId":"d"}]`, // bare [Event]
|
||||
`{"batch":[{"type":"event","event":"signup_completed"}],"org":"evil"}`, // {batch} envelope
|
||||
} {
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", body,
|
||||
map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("/v1/event beacon %q want 503 (ingested as site org), got %d", body, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_AppBeaconExactBody confirms the CANONICAL door accepts the
|
||||
// APP beacon's EXACT {batch:[ev]} body via the site-host carve — the acceptance test
|
||||
// for repointing ANALYTICS_ENDPOINT to /v1/event. Admitted (503, datastore down),
|
||||
// tenant forced to the site's Org regardless of the beacon's properties.space claim.
|
||||
func TestMount_HostCarve_AppBeaconExactBody(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/event", appBeaconBody, nil)
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("exact app beacon on /v1/event want 503 (admitted via carve), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_EventEmptyBatchOK: an empty beacon batch on the canonical door
|
||||
// is an honest 200 (zero counts) BEFORE the datastore is consulted — proving the
|
||||
// carve decodes and funnels through the ONE write core with the host-forced org.
|
||||
func TestMount_HostCarve_EventEmptyBatchOK(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
if code := postHost(t, app, "yadota.hanzo.app", "/v1/event", `{"batch":[]}`, nil); code != http.StatusOK {
|
||||
t.Fatalf("empty beacon batch on /v1/event want 200, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_EventDirectNoHostStillFailsClosed pins that the forced-org
|
||||
// carve is HOST-scoped: the SAME anonymous /v1/event body on a NON-site host runs the
|
||||
// normal canonical gate (eventTenant, no brand fallback) and is refused 403 — the
|
||||
// carve did not fire, so the strict door invariant is unweakened.
|
||||
func TestMount_HostCarve_EventDirectNoHostStillFailsClosed(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
code := postHost(t, app, "evil.example.com", "/v1/event",
|
||||
`{"event":"signup_completed","distinctId":"d"}`, map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous /v1/event on a non-site host want 403 (no carve, no brand fallback), got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// liveResolver is a sites.Resolver that knows exactly ONE published site — the
|
||||
// slug key "yadota" and the bound custom host "yadota.tech" — and returns its live
|
||||
// Site whose Org the carve must force onto every ingested beacon. Any other key is
|
||||
// an honest miss (found=false), exactly as the real projects store behaves, so a
|
||||
// stray external host is NOT mistaken for a bound custom domain.
|
||||
type liveResolver struct{ org string }
|
||||
|
||||
func (r liveResolver) Resolve(_ context.Context, key string) (sites.Site, bool, error) {
|
||||
switch key {
|
||||
case "yadota", "yadota.tech":
|
||||
return sites.Site{Org: r.org, Slug: "yadota", Bucket: "b", Prefix: r.org + "/yadota", Status: "live"}, true, nil
|
||||
default:
|
||||
return sites.Site{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// carveApp mounts analytics (which installs the site-host ingest carve via
|
||||
// sites.SetAnalyticsHostHandler) BEHIND the sites host-router middleware, then
|
||||
// points the resolver at one live Site. A POST to the site host is intercepted by
|
||||
// the middleware and forced to Site.Org; a POST to any other host falls through to
|
||||
// the normal /v1/analytics route.
|
||||
func carveApp(t *testing.T, org string) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
|
||||
srv := sites.New(sites.Config{
|
||||
Apex: "hanzo.app",
|
||||
Reserved: []string{"app", "api", "admin"},
|
||||
SelfDomains: []string{"hanzo.ai", "hanzo.app"},
|
||||
}, luxlog.New("test"))
|
||||
app.Use(srv.Middleware())
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
sites.SetResolver(liveResolver{org: org})
|
||||
t.Cleanup(func() {
|
||||
sites.SetResolver(nil)
|
||||
sites.SetAnalyticsHostHandler(nil)
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func postHost(t *testing.T, app *zip.App, host, path, body string, hdr map[string]string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://"+host+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Host = host
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test POST %s%s: %v", host, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_ForcesSiteOrg is the end-to-end proof: Mount wires the carve,
|
||||
// and a beacon POST to a LIVE site host is ingested as the site's Org even though
|
||||
// the request carries a forged org (body + X-Org-Id) and NO validated principal.
|
||||
// The discriminator is 503-not-403: the SAME shape 403s on the normal route
|
||||
// (TestCapture_ForgedOrgWithoutBearerForbidden / unknown-host), but here it passes
|
||||
// the tenant gate (org forced from the host) and stops only at the datastore-down
|
||||
// 503 — proving the org is server-supplied from the host, never the caller/body.
|
||||
func TestMount_HostCarve_ForcesSiteOrg(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
|
||||
// Segment/beacon wire on /v1/analytics + its /batch alias.
|
||||
for _, p := range []string{"/v1/analytics", "/v1/analytics/batch"} {
|
||||
code := postHost(t, app, "yadota.hanzo.app", p,
|
||||
`{"batch":[{"type":"event","event":"signup_completed"}],"org":"attacker","properties":{"space":"attacker"}}`,
|
||||
map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("beacon POST %s want 503 (ingested as site org, datastore down), got %d", p, code)
|
||||
}
|
||||
}
|
||||
|
||||
// PostHog wire on /v1/insights/e.
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/insights/e",
|
||||
`{"event":"$pageview","distinct_id":"d","properties":{"space":"attacker"}}`,
|
||||
map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("insights beacon want 503 (ingested as site org), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_EmptyBatchOK: an empty beacon batch on the site host is an
|
||||
// honest 200 (zero counts) BEFORE the datastore is consulted — proving the carve
|
||||
// decodes and funnels through the ONE write core without any principal.
|
||||
func TestMount_HostCarve_EmptyBatchOK(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
if code := postHost(t, app, "yadota.hanzo.app", "/v1/analytics", `{"batch":[]}`, nil); code != http.StatusOK {
|
||||
t.Fatalf("empty beacon batch want 200, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_CustomDomainForcesOrg: the carve fires for a bound custom
|
||||
// domain too, forcing that Site's Org.
|
||||
func TestMount_HostCarve_CustomDomainForcesOrg(t *testing.T) {
|
||||
app := carveApp(t, "yadota")
|
||||
code := postHost(t, app, "yadota.tech", "/v1/analytics",
|
||||
`{"batch":[{"type":"pageview"}]}`, map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("custom-domain beacon want 503 (ingested as site org), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_GetNotHijacked: a GET on the site host is NOT ingest — it is
|
||||
// served as static (storage unconfigured here ⇒ 503 from the serve path), never
|
||||
// routed to the ingest carve; the read-lens surface is untouched.
|
||||
func TestMount_HostCarve_GetNotHijacked(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
req := httptest.NewRequest(http.MethodGet, "http://yadota.hanzo.app/v1/analytics/overview", nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// It must have reached the static serve, tagged X-Hanzo-Site — not the ingest
|
||||
// carve (which would 200 the empty body) and not the API pipeline.
|
||||
if resp.Header.Get("X-Hanzo-Site") != "yadota" {
|
||||
t.Fatalf("GET did not reach the static serve (X-Hanzo-Site=%q, status=%d)", resp.Header.Get("X-Hanzo-Site"), resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_NonSiteHostUsesNormalGate: on a NON-site host the middleware
|
||||
// Continues and the normal /v1/analytics route runs — so an anonymous unknown-host
|
||||
// beacon is refused 403 by captureTenant (the carve did not fire). This pins that
|
||||
// the forced-org path is host-scoped and does NOT weaken the normal ingest gate.
|
||||
func TestMount_HostCarve_NonSiteHostUsesNormalGate(t *testing.T) {
|
||||
app := carveApp(t, "hanzo")
|
||||
code := postHost(t, app, "evil.example.com", "/v1/analytics",
|
||||
`{"batch":[{"type":"pageview"}]}`, map[string]string{"X-Org-Id": "attacker"})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous unknown-host beacon on the normal route want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_HostCarve_DisabledWhenPublicCaptureOff: with public capture off the
|
||||
// carve is NOT installed, so a beacon POST to the site host falls to the static
|
||||
// serve and 405s (unchanged from before the fix).
|
||||
func TestMount_HostCarve_DisabledWhenPublicCaptureOff(t *testing.T) {
|
||||
t.Setenv(publicCaptureEnv, "off")
|
||||
app := carveApp(t, "hanzo")
|
||||
code := postHost(t, app, "yadota.hanzo.app", "/v1/analytics",
|
||||
`{"batch":[{"type":"pageview"}]}`, nil)
|
||||
if code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("public-capture-off site beacon want 405 (carve not installed), got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -102,17 +102,27 @@ func (e insightsEvent) toCapture() CaptureEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// insightsIngest answers POST /v1/insights/e — the DEPRECATED PostHog-wire
|
||||
// adapter. It normalizes the PostHog single/batch shape onto CaptureEvent and
|
||||
// funnels through the ONE write core (ingestEvents, source=posthog); it keeps
|
||||
// captureTenant's brand-host path so anonymous PostHog-wire traffic is unbroken.
|
||||
// New callers post the canonical Event to /v1/event.
|
||||
// insightsIngest answers POST /v1/insights/e — a DEPRECATED foreign-protocol shim
|
||||
// for the PostHog wire. External-SDK compat ONLY; no Hanzo surface uses it (Hanzo
|
||||
// surfaces POST /v1/event). It normalizes the PostHog single/batch shape onto
|
||||
// CaptureEvent and funnels through the ONE write core (ingestEvents, source=posthog);
|
||||
// it keeps captureTenant's brand-host path so anonymous external PostHog-wire traffic
|
||||
// is unbroken — the path the strict canonical door deliberately refuses.
|
||||
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
deprecated(s, c, "/v1/event")
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
}
|
||||
return insightsWithOrg(org, c)
|
||||
}
|
||||
|
||||
// insightsWithOrg is the PostHog-wire decode+ingest core with the tenant supplied
|
||||
// EXPLICITLY by the caller — the twin of captureWithOrg for the PostHog beacon
|
||||
// shape. The /v1/insights/e alias resolves org via captureTenant; the site-host
|
||||
// carve FORCES org from the resolved Site (host-derived, never the caller/body).
|
||||
// Both funnel through the ONE write core (ingestEvents, source=posthog).
|
||||
func insightsWithOrg(org string, c *zip.Ctx) error {
|
||||
var body insightsBody
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return zip.ErrBadRequest("malformed insights payload")
|
||||
|
||||
@@ -206,34 +206,16 @@ func foldException(e CaptureEvent) CaptureEvent {
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ingest answers POST /v1/ingest — the publishable-key direct capture path. The
|
||||
// org is resolved from the SIGNED key (no IAM, no DB), the body is the
|
||||
// @hanzo/event WireEvent batch ({batch:[…]} | {events:[…]}), error events are
|
||||
// folded, and everything funnels through the ONE write core into hanzo.events.
|
||||
// FAILS CLOSED: a missing/unverifiable key is refused (403); the org is never
|
||||
// read from the body.
|
||||
// ingest answers POST /v1/ingest — a THIN DEPRECATED ALIAS of the canonical door.
|
||||
// Since /v1/event now natively accepts the publishable key (pk_…, via eventTenant)
|
||||
// AND the {batch:[…]} wire (via decodeIngest), /v1/ingest is redundant: it delegates
|
||||
// to the EXACT canonical handler logic (eventHandle) — the SAME pluggable auth,
|
||||
// tolerant decode, error-fold, and ONE write core — differing only in a one-shot
|
||||
// deprecation log and the $source=ingest origin tag for the migration signal.
|
||||
// Existing pk_ callers keep working unchanged; there is ONE implementation.
|
||||
func ingest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
key := ingestKey(c)
|
||||
if key == "" {
|
||||
return zip.ErrForbidden("publishable ingest key required")
|
||||
}
|
||||
org, ok := verifyPublishableKey(ingestSecret(), key)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("invalid publishable ingest key")
|
||||
}
|
||||
var batch CaptureBatch
|
||||
if err := c.Bind(&batch); err != nil {
|
||||
return zip.ErrBadRequest("malformed ingest batch")
|
||||
}
|
||||
evs := batch.events()
|
||||
for i := range evs {
|
||||
evs[i] = foldException(evs[i])
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceIngest, evs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
deprecated(s, c, "/v1/event")
|
||||
return eventHandle(c, sourceIngest)
|
||||
}
|
||||
|
||||
// mintKey answers POST /v1/ingest/keys — an org owner (VALIDATED principal) mints
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file proves the unification net-invariant: there is ONE ingest handler
|
||||
// implementation and ONE write core; /v1/event is the single canonical door serving
|
||||
// EVERY wire shape (Event | [Event] | {batch}) and EVERY auth context (IAM bearer |
|
||||
// pk_ key | site-host-forced); the other routes are thin aliases/shims delegating to
|
||||
// it. The proof is decomposed to fit the harness (datastore is DOWN, so the HTTP
|
||||
// path stops at requireDatastore's 503 before a row is written):
|
||||
//
|
||||
// - WIRE dimension (deterministic, row layer): the SAME logical event in every
|
||||
// wire shape decodes through the ONE tolerant decoder (decodeIngest) and the ONE
|
||||
// normalizer (normalizeEvent) into a byte-identical warehouse row, tenant = the
|
||||
// server-resolved org. This is exactly the row buildEventsInsert binds.
|
||||
// - AUTH dimension (HTTP layer): each auth context is ADMITTED (503, never 403),
|
||||
// proving the canonical door resolved a tenant for it. Each pure resolver
|
||||
// (verifyPublishableKey→org, resolveKeyOrg→org, the host-forced Site.Org) is
|
||||
// unit-proven elsewhere (publishable_test, capture_keyorg_test, hostcarve_test),
|
||||
// so admission + those proofs compose into "lands in the SAME tenant".
|
||||
|
||||
// ── tolerant decoder: THREE shapes → the SAME []CaptureEvent ──────────────────
|
||||
|
||||
// TestDecodeIngest_ThreeShapes proves the ONE canonical decoder accepts a bare Event
|
||||
// object, a bare [Event] array, AND the {batch:[…]} envelope, yielding equivalent
|
||||
// CaptureEvents from each — no separate door is needed for any wire.
|
||||
func TestDecodeIngest_ThreeShapes(t *testing.T) {
|
||||
shapes := map[string]string{
|
||||
"bareObject": `{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}`,
|
||||
"bareArray": `[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]`,
|
||||
"batchEnv": `{"batch":[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]}`,
|
||||
"eventsEnv": `{"events":[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]}`,
|
||||
}
|
||||
for name, body := range shapes {
|
||||
evs, err := decodeIngest([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decodeIngest err: %v", name, err)
|
||||
}
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("%s: got %d events, want 1", name, len(evs))
|
||||
}
|
||||
e := evs[0]
|
||||
if e.Event != "signup_completed" || e.DistinctID != "u1" || e.Properties["plan"] != "pro" {
|
||||
t.Fatalf("%s: decoded = %+v", name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeIngest_BatchEnvelopeDetectedNotEventNamedBatch guards the envelope
|
||||
// discriminator: a bare Event whose NAME is "batch" is NOT mistaken for the batch
|
||||
// envelope (the probe checks a top-level batch/events KEY, not the event field).
|
||||
func TestDecodeIngest_BatchEnvelopeDetectedNotEventNamedBatch(t *testing.T) {
|
||||
evs, err := decodeIngest([]byte(`{"event":"batch","distinctId":"d"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeIngest err: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Event != "batch" {
|
||||
t.Fatalf("event named 'batch' must stay a single bare event, got %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeIngest_EmptyBatchIsEnvelope: {"batch":[]} is an empty ENVELOPE (zero
|
||||
// events, honest empty receipt) — the key is present even though the array is empty.
|
||||
func TestDecodeIngest_EmptyBatchIsEnvelope(t *testing.T) {
|
||||
evs, err := decodeIngest([]byte(`{"batch":[]}`))
|
||||
if err != nil || len(evs) != 0 {
|
||||
t.Fatalf("empty batch envelope ⇒ 0 events no error, got evs=%v err=%v", evs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeIngest_EmptyAndMalformed: an empty/whitespace body is a no-op receipt;
|
||||
// malformed JSON is an error the handler turns into 400.
|
||||
func TestDecodeIngest_EmptyAndMalformed(t *testing.T) {
|
||||
for _, b := range []string{"", " ", "\n\t"} {
|
||||
if evs, err := decodeIngest([]byte(b)); err != nil || len(evs) != 0 {
|
||||
t.Fatalf("empty %q ⇒ evs=%v err=%v", b, evs, err)
|
||||
}
|
||||
}
|
||||
for _, b := range []string{`{"event":`, `{"batch":[`, `not json`, `[{"event":"a"},`} {
|
||||
if _, err := decodeIngest([]byte(b)); err == nil {
|
||||
t.Fatalf("malformed %q want error, got nil", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── net invariant: SAME warehouse row + SAME tenant across every wire ──────────
|
||||
|
||||
// TestUnifiedIngest_SameRowSameTenant is THE unification proof at the row layer: one
|
||||
// logical event, expressed as every wire shape the canonical door and its aliases
|
||||
// accept, decoded through the ONE decoder and normalized with the SAME server org,
|
||||
// yields a byte-identical warehouse row (modulo the randomly-minted id) whose tenant
|
||||
// is that org. Since buildEventsInsert binds eventRow.args() positionally, identical
|
||||
// args() == identical warehouse row.
|
||||
func TestUnifiedIngest_SameRowSameTenant(t *testing.T) {
|
||||
const org = "acme"
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Every wire shape carrying the SAME logical event. bareObject/bareArray are the
|
||||
// canonical Event wire; batchEnv/eventsEnv are the CaptureBatch envelope the
|
||||
// Segment/beacon/publishable paths speak.
|
||||
bodies := map[string]string{
|
||||
"bareObject": `{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}`,
|
||||
"bareArray": `[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]`,
|
||||
"batchEnv": `{"batch":[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]}`,
|
||||
"eventsEnv": `{"events":[{"event":"signup_completed","distinctId":"u1","properties":{"plan":"pro"}}]}`,
|
||||
}
|
||||
|
||||
rows := map[string][]any{}
|
||||
for name, body := range bodies {
|
||||
evs, err := decodeIngest([]byte(body))
|
||||
if err != nil || len(evs) != 1 {
|
||||
t.Fatalf("%s: decodeIngest evs=%v err=%v", name, evs, err)
|
||||
}
|
||||
// foldException is a no-op for non-error events — part of the ONE core path.
|
||||
row, ok := normalizeEvent(org, now, foldException(evs[0]))
|
||||
if !ok {
|
||||
t.Fatalf("%s: normalize dropped a routable event", name)
|
||||
}
|
||||
rows[name] = row.args()
|
||||
}
|
||||
|
||||
// The tenant column (index 2) is the server org for EVERY wire — never the body.
|
||||
for name, a := range rows {
|
||||
if a[2] != org {
|
||||
t.Fatalf("%s: tenant arg = %v, want %q (server-stamped)", name, a[2], org)
|
||||
}
|
||||
}
|
||||
// Every wire yields the identical warehouse row, comparing all columns except the
|
||||
// minted id (index 0) — the only non-deterministic column when messageId is absent.
|
||||
ref := rows["bareObject"]
|
||||
for name, a := range rows {
|
||||
assertRowArgsEqualExceptID(t, name, ref, a)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRowArgsEqualExceptID compares two positional row-arg slices column-by-column,
|
||||
// skipping index 0 (the randomly-minted id). A mismatch names the differing column.
|
||||
func assertRowArgsEqualExceptID(t *testing.T, name string, want, got []any) {
|
||||
t.Helper()
|
||||
if len(want) != len(got) || len(got) != len(eventColumns) {
|
||||
t.Fatalf("%s: arg width = %d, want %d", name, len(got), len(eventColumns))
|
||||
}
|
||||
for i := 1; i < len(got); i++ {
|
||||
if !reflect.DeepEqual(want[i], got[i]) {
|
||||
t.Fatalf("%s: column %q differs: %v (%T) != %v (%T)",
|
||||
name, eventColumns[i], got[i], got[i], want[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pluggable auth on the ONE door: pk_ folded into /v1/event ─────────────────
|
||||
|
||||
// TestEvent_PkKeyAdmitted proves the write-only publishable key (pk_…) is a
|
||||
// first-class auth mode ON the canonical door: a pk_ bearer for org acme is ADMITTED
|
||||
// (503, datastore down), so a pk_ caller uses /v1/event directly — no separate
|
||||
// /v1/ingest door required.
|
||||
func TestEvent_PkKeyAdmitted(t *testing.T) {
|
||||
t.Setenv(ingestKeySecretEnv, testSecret)
|
||||
app := mountApp(t)
|
||||
key, ok := mintPublishableKey(testSecret, "acme")
|
||||
if !ok {
|
||||
t.Fatal("mint pk_ failed")
|
||||
}
|
||||
code := postKeyed(t, app, "/v1/event", "", `{"batch":[{"type":"event","event":"signup_completed"}]}`,
|
||||
map[string]string{"Authorization": "Bearer " + key})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("pk_ on /v1/event want 503 (admitted, datastore down), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvent_PkKeyForgedOrgIgnored: the tenant a pk_ writes into is the SIGNED org,
|
||||
// never the body/header claim — a pk_ for acme with a forged X-Org-Id + body org is
|
||||
// still admitted (as acme), proving the key's org wins.
|
||||
func TestEvent_PkKeyForgedOrgIgnored(t *testing.T) {
|
||||
t.Setenv(ingestKeySecretEnv, testSecret)
|
||||
app := mountApp(t)
|
||||
key, _ := mintPublishableKey(testSecret, "acme")
|
||||
code := postKeyed(t, app, "/v1/event", "hanzo.ai",
|
||||
`{"batch":[{"type":"pageview"}],"org":"attacker"}`,
|
||||
map[string]string{"Authorization": "Bearer " + key, "X-Org-Id": "attacker"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("pk_ door with forged org want 503 (ingested as key org), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvent_BadPkKeyFailsClosed: a malformed/forged pk_ that does not verify, with no
|
||||
// other auth, is refused 403 — the canonical door fails closed (no brand-host escape).
|
||||
func TestEvent_BadPkKeyFailsClosed(t *testing.T) {
|
||||
t.Setenv(ingestKeySecretEnv, testSecret)
|
||||
app := mountApp(t)
|
||||
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`,
|
||||
map[string]string{"Authorization": "Bearer pk_deadbeef.deadbeef"})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("unverifiable pk_ on /v1/event want 403 (fail closed), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── /v1/ingest is now a THIN ALIAS of the ONE handler ─────────────────────────
|
||||
|
||||
// TestIngestAlias_DelegatesToEventHandler proves /v1/ingest is the SAME
|
||||
// implementation as /v1/event: a pk_ caller is admitted (unchanged), AND — because it
|
||||
// delegates to eventHandle — it now ALSO admits an IAM bearer, while no-auth still
|
||||
// fails closed. One implementation, reached through two routes.
|
||||
func TestIngestAlias_DelegatesToEventHandler(t *testing.T) {
|
||||
t.Setenv(ingestKeySecretEnv, testSecret)
|
||||
app := mountApp(t)
|
||||
key, _ := mintPublishableKey(testSecret, "acme")
|
||||
|
||||
// pk_ — the historical /v1/ingest auth — still works.
|
||||
if code := postKeyed(t, app, "/v1/ingest", "", `{"batch":[{"type":"pageview"}]}`,
|
||||
map[string]string{"Authorization": "Bearer " + key}); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("pk_ on /v1/ingest want 503 (admitted), got %d", code)
|
||||
}
|
||||
// IAM bearer — admitted too, because the alias IS eventHandle now.
|
||||
if code, _ := doBody(t, app, http.MethodPost, "/v1/ingest", "user-dave", "acme",
|
||||
`[{"event":"signup_completed","distinctId":"u1"}]`); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("bearer on /v1/ingest want 503 (admitted via eventHandle), got %d", code)
|
||||
}
|
||||
// No auth — fail closed, exactly like the canonical door.
|
||||
if code, _ := doBody(t, app, http.MethodPost, "/v1/ingest", "", "",
|
||||
`{"batch":[{"type":"pageview"}]}`); code != http.StatusForbidden {
|
||||
t.Fatalf("no-auth /v1/ingest want 403 (fail closed), got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/base/core"
|
||||
)
|
||||
|
||||
// ErrNotEmbedded is returned by EnsureSpace when the base embed is disabled
|
||||
// (CLOUD_BASE_EMBED off). It is a fail-soft sentinel: a caller provisioning a
|
||||
// project's data space treats it as "deferred", never as a failure — the space
|
||||
// is re-ensured on the next call once the embed is on.
|
||||
var ErrNotEmbedded = errors.New("base: embed disabled")
|
||||
|
||||
// SubmissionsCollection is the ONE collection every project's Base space carries
|
||||
// for generic data collection — the form, forum, and data submissions a deployed
|
||||
// site POSTs to /v1/base/collections/submissions/records. Anyone may CREATE (so
|
||||
// an anonymous published page can submit out of the box); reads stay
|
||||
// superuser-only (submissions are private by default).
|
||||
const SubmissionsCollection = "submissions"
|
||||
|
||||
// EnsureSpace provisions an org's Base data space idempotently and in-process
|
||||
// (no HTTP): it opens+migrates the org's per-org Base app so its SQLite exists,
|
||||
// then ensures the default submissions collection exists. Safe to call
|
||||
// repeatedly — an existing collection is left untouched. Returns ErrNotEmbedded
|
||||
// when the embed is off so the caller can fail soft. This is the ONE entrypoint
|
||||
// other subsystems (projects) use to wire a project's data space by default.
|
||||
func EnsureSpace(_ context.Context, org string) error {
|
||||
s := mounted
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrNotEmbedded
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" {
|
||||
return fmt.Errorf("base: empty org")
|
||||
}
|
||||
app, err := s.pool.appFor(org)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base: open org app: %w", err)
|
||||
}
|
||||
if _, err := app.FindCollectionByNameOrId(SubmissionsCollection); err == nil {
|
||||
return nil // already provisioned — idempotent
|
||||
}
|
||||
col := core.NewBaseCollection(SubmissionsCollection)
|
||||
// Empty create rule = public: anyone (incl. an anonymous deployed page) may
|
||||
// submit. List/View/Update/Delete rules stay nil (superuser-only), so
|
||||
// submissions are readable only by the space owner.
|
||||
open := ""
|
||||
col.CreateRule = &open
|
||||
col.Fields.Add(
|
||||
&core.TextField{Name: "form", Max: 128}, // which form/forum/namespace posted
|
||||
&core.JSONField{Name: "data", MaxSize: 1 << 20}, // the submission payload
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
)
|
||||
if err := app.Save(col); err != nil {
|
||||
return fmt.Errorf("base: create %q collection: %w", SubmissionsCollection, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+25
-15
@@ -16,8 +16,10 @@
|
||||
//
|
||||
// Projects are owned by Hanzo IAM (hanzo.id), the ONE source of truth for the
|
||||
// org-scoped (Owner,Name) Project resource. This plane REFLECTS them read-only via
|
||||
// the in-process object store (embedded IAM, no HTTP hop) — mirroring
|
||||
// clients/platform/projects.go — and never persists a CD-side project row.
|
||||
// the clean iam's in-process project store (github.com/hanzoai/iam/pkg/store over
|
||||
// the embedded IAM's orm.DB, no HTTP hop) — mirroring clients/platform/projects.go —
|
||||
// and never persists a CD-side project row. The retired Casdoor iam-v1 object store
|
||||
// is GONE.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
@@ -25,15 +27,18 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
iamobj "github.com/hanzoai/iam-v1/object"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
iamclient "github.com/hanzoai/cloud/clients/iam"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/provisioning"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
iamstore "github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// Operator App CRs carry two labels this plane READS (never writes): clients/platform
|
||||
@@ -204,20 +209,25 @@ func (sc scope) runningByNamespace(s *cloud.Service[state], ctx context.Context)
|
||||
|
||||
// ── IAM-owned project reflection ─────────────────────────────────────────────
|
||||
|
||||
// iamStore runs an embedded-IAM object-store call, converting a nil-store panic into a
|
||||
// clean 503 rather than a nil-deref crash. The store's engine (iamobj.ormer) is a package
|
||||
// global that is nil until the co-resident IAM subsystem initializes it; a project call
|
||||
// against a nil engine would otherwise nil-deref. Mirrors clients/platform.iamStore — a
|
||||
// deployment enabling "deploy" is meant to co-mount "iam" (single-binary co-residents).
|
||||
// Never masks a real error.
|
||||
func iamStore[T any](fn func() (T, error)) (out T, err error) {
|
||||
// iamStore runs an in-process clean-iam project-store call against db (the embedded
|
||||
// IAM's orm.DB, sourced from clients/iam.DB()): it short-circuits an absent store (IAM
|
||||
// not mounted → db nil) into a clean 503, and recovers any unexpected nil-deref as the
|
||||
// same 503 rather than a crash. The embedded IAM's DB is nil until the co-resident IAM
|
||||
// subsystem mounts it. Mirrors clients/platform.iamStore — a deployment enabling
|
||||
// "deploy" is meant to co-mount "iam" (single-binary co-residents). Never masks a real
|
||||
// error.
|
||||
func iamStore[T any](db orm.DB, fn func(db orm.DB) (T, error)) (out T, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = zip.Errorf(http.StatusServiceUnavailable,
|
||||
"deploy requires the co-resident IAM store, which is not initialized")
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
if db == nil {
|
||||
return out, zip.Errorf(http.StatusServiceUnavailable,
|
||||
"deploy requires the co-resident IAM store, which is not initialized")
|
||||
}
|
||||
return fn(db)
|
||||
}
|
||||
|
||||
// iamProjects reflects the IAM-owned projects VISIBLE to this scope into projected argo
|
||||
@@ -227,11 +237,11 @@ func iamStore[T any](fn func() (T, error)) (out T, err error) {
|
||||
// the ONE source; this NEVER persists a CD-side project. A nil/absent embedded IAM store
|
||||
// yields nil (the caller's synthesized-default fallback keeps the projection populated).
|
||||
func (sc scope) iamProjects() []argoProject {
|
||||
list, err := iamStore(func() ([]*iamobj.Project, error) {
|
||||
list, err := iamStore(iamclient.DB(), func(db orm.DB) ([]*model.Project, error) {
|
||||
if sc.superAdmin {
|
||||
return iamobj.GetProjects("") // empty owner → every org's projects
|
||||
return iamstore.GetProjects(db, "") // empty owner → every org's projects
|
||||
}
|
||||
return iamobj.GetOrganizationProjects(sc.org)
|
||||
return iamstore.GetOrganizationProjects(db, sc.org)
|
||||
})
|
||||
if err != nil || len(list) == 0 {
|
||||
return nil
|
||||
@@ -248,7 +258,7 @@ func (sc scope) iamProjects() []argoProject {
|
||||
// Description, and an hanzo.ai/org label carrying the tenant. Project scoping on this
|
||||
// platform is IAM/Org, not argocd RBAC, so the projected spec is permissive — and ONLY
|
||||
// these fields are surfaced (never Tags/Metadata), so nothing unintended leaks.
|
||||
func projectFromIAM(p *iamobj.Project) argoProject {
|
||||
func projectFromIAM(p *model.Project) argoProject {
|
||||
proj := synthProject(p.Name)
|
||||
proj.Spec.Description = firstNonEmpty(p.DisplayName, p.Description)
|
||||
if org := provisioning.SanitizeOrg(firstNonEmpty(p.Organization, p.Owner)); org != "" {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iamobj "github.com/hanzoai/iam-v1/object"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
@@ -354,7 +354,7 @@ func TestDashProjects_OrgNeverSeesCrossOrgAppProjects(t *testing.T) {
|
||||
// argo AppProject — name = Project.Name, description from DisplayName, an org label — and
|
||||
// surfaces NONE of Tags/Metadata.
|
||||
func TestProjectFromIAM_Reflects(t *testing.T) {
|
||||
p := &iamobj.Project{
|
||||
p := &model.Project{
|
||||
Owner: "acme", Name: "storefront", Organization: "acme",
|
||||
DisplayName: "Storefront", Description: "the shop", IsDefault: false,
|
||||
Tags: []string{"secret-tag"}, Metadata: `{"secret":"x"}`,
|
||||
|
||||
@@ -23,8 +23,7 @@ func init() {
|
||||
{Key: "public_signup", Category: "Signup", Label: "Public open signup", Desc: "Allow anyone to create an account (off = invite / waitlist only).", Type: TypeBool, Default: "false"},
|
||||
|
||||
// ── Subsystem activation (boot-time; applying a flip needs an operator reconcile) ──
|
||||
{Key: "subsystem_iam_active", Category: "Subsystems", Label: "IAM (canary auth cutover)", Desc: "Serve identity from the embedded IAM. CANARY-GATED staged auth cutover; applied at boot via CLOUD_ENABLE.", Type: TypeBool, Default: "false", ReadOnly: true},
|
||||
{Key: "subsystem_iam2_active", Category: "Subsystems", Label: "IAM v2 (clean-room, beego-free)", Desc: "Serve identity from the clean-room iam2 instead of the beego/Casdoor embed. Selected at boot via CLOUD_IAM_IMPL=iam2 (one selector, applied on the next reconcile). Gate before flipping: the IAM cutover parity suite (universe e2e/50-iam-cutover-parity) must be green against the iam2 shadow.", Type: TypeBool, Default: "false", ReadOnly: true},
|
||||
{Key: "subsystem_iam_active", Category: "Subsystems", Label: "IAM (canary auth cutover)", Desc: "Serve identity from the embedded clean-room iam-v2 (zip-native, beego-free; the Casdoor iam-v1 embed is retired). CANARY-GATED staged auth cutover; applied at boot via CLOUD_ENABLE. Gate before flipping: the IAM cutover parity suite (universe e2e/50-iam-cutover-parity) must be green.", Type: TypeBool, Default: "false", ReadOnly: true},
|
||||
{Key: "subsystem_ingress_active", Category: "Subsystems", Label: "Ingress edge", Desc: "Serve the embedded ingress edge (routes/TLS/ACME). Applied at boot via CLOUD_ENABLE.", Type: TypeBool, Default: "false", ReadOnly: true},
|
||||
{Key: "subsystem_pubsub_active", Category: "Subsystems", Label: "PubSub (NATS+JetStream)", Desc: "Serve the embedded messaging plane. Applied at boot via CLOUD_PUBSUB_ENABLED.", Type: TypeBool, Env: "CLOUD_PUBSUB_ENABLED", Default: "false", ReadOnly: true},
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestZAPControlPlaneRoundTrip(t *testing.T) {
|
||||
defer c.CloseNow()
|
||||
|
||||
// 1) createRepo over ZAP.
|
||||
rep := zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "zapsvc"}, 1)
|
||||
rep := zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "zap"}, 1)
|
||||
if !rep.ok || rep.status != http.StatusOK {
|
||||
t.Fatalf("createRepo: ok=%v status=%d err=%s", rep.ok, rep.status, rep.errorJSON)
|
||||
}
|
||||
@@ -190,13 +190,13 @@ func TestZAPControlPlaneRoundTrip(t *testing.T) {
|
||||
if err := json.Unmarshal(unwrapResult(rep.result), &created); err != nil {
|
||||
t.Fatalf("decode createRepo result: %v (%s)", err, rep.result)
|
||||
}
|
||||
if created.Org != "acme" || created.Name != "zapsvc" {
|
||||
if created.Org != "acme" || created.Name != "zap" {
|
||||
t.Fatalf("unexpected repo: %+v", created)
|
||||
}
|
||||
if created.CloneURL != "https://api.hanzo.test/v1/git/acme/zapsvc.git" {
|
||||
if created.CloneURL != "https://api.hanzo.test/v1/git/acme/zap.git" {
|
||||
t.Fatalf("unexpected cloneUrl: %q", created.CloneURL)
|
||||
}
|
||||
if !strings.HasPrefix(created.SSHURL, "git@git.hanzo.test:acme/zapsvc.git") {
|
||||
if !strings.HasPrefix(created.SSHURL, "git@git.hanzo.test:acme/zap.git") {
|
||||
t.Fatalf("unexpected sshUrl: %q", created.SSHURL)
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestZAPControlPlaneRoundTrip(t *testing.T) {
|
||||
if err := json.Unmarshal(unwrapResult(rep.result), &listed); err != nil {
|
||||
t.Fatalf("decode listRepos: %v (%s)", err, rep.result)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].Name != "zapsvc" {
|
||||
if len(listed) != 1 || listed[0].Name != "zap" {
|
||||
t.Fatalf("listRepos over ZAP = %+v", listed)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestZAPControlPlaneRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("core list: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Name != "zapsvc" {
|
||||
if len(out) != 1 || out[0].Name != "zap" {
|
||||
t.Fatalf("REST/core view of ZAP-created repo = %+v", out)
|
||||
}
|
||||
|
||||
|
||||
+133
-186
@@ -2,160 +2,178 @@
|
||||
// in-process subsystem (HIP-0106) — the LAST binary-consolidation piece:
|
||||
// "one Go binary (hanzoai/cloud) embeds IAM + KMS + o11y".
|
||||
//
|
||||
// WRAP, DON'T REWRITE. IAM is a Beego app (~150 routes registered in
|
||||
// hanzoai/iam/routers.InitAPI over controllers.ApiController/RootController).
|
||||
// iamserver.InitEmbed() runs the ENTIRE IAM identity runtime — config, SQLite
|
||||
// store, KMS signing keys, controllers, authz filters, background sync/monitor
|
||||
// loops — as an in-process embed: it is standalone iamd's bootstrap MINUS the
|
||||
// standalone-daemon side effects that would crash or endanger this shared
|
||||
// process (no StopOldInstance `lsof`/SIGKILL, no LDAP/RADIUS listeners, no
|
||||
// export/os.Exit), binds NO HTTP listener, and RETURNS AN ERROR instead of
|
||||
// panicking. (The standalone `hanzo iam` / iamd path still uses iamserver.Init +
|
||||
// web.Run, byte-for-byte unchanged.) After a successful InitEmbed the full IAM
|
||||
// http.Handler is web.BeeApp.Handlers; this subsystem mounts THAT verbatim on
|
||||
// cloud's shared zip.App at every path prefix IAM owns. No auth logic is
|
||||
// reimplemented — the same controllers answer, so hanzo.id's OAuth/OIDC semantics
|
||||
// (authorize clientId org-resolution, JWT audiences, SuperAdmin owner=="admin",
|
||||
// argon2id password hashing) are preserved byte-for-byte.
|
||||
// CLEAN IAM (v2), NOT CASDOOR. This subsystem embeds github.com/hanzoai/iam —
|
||||
// the clean-room identity rewrite on the native Hanzo stack (zip + hanzoai/orm +
|
||||
// hanzoai/sqlite). The retired Casdoor/Beego fork (github.com/hanzoai/iam-v1) is
|
||||
// GONE from cloud's graph: there is no beego process-global to corrupt, no
|
||||
// InitEmbed, no session-manager hook, no shared-AppConfig co-residence hazard with
|
||||
// the sibling `ai` casdoor fork. iamserver.Mount registers the whole IAM v2 surface
|
||||
// (OIDC discovery/JWKS, oauth authorize/token/userinfo/introspect/revoke,
|
||||
// get-app-login, signin, the v2 entity CRUD, and the Casdoor verb-alias compat
|
||||
// layer) ZIP-NATIVELY onto cloud's shared app — no net/http adaptor round-trip. The
|
||||
// specific self-service routes layered in front (account, agentskills) still win by
|
||||
// Fiber's in-order match, so the fold is collision-free.
|
||||
//
|
||||
// The one hook web.Run() performs that the embed bootstrap omits is Beego
|
||||
// session-manager registration; this subsystem fires it explicitly (initSessions),
|
||||
// mirroring the sanctioned iam.Embed path, else every session-touching request
|
||||
// (login, authorize) nil-derefs in the router.
|
||||
// The store is embedded SQLite under {DataDir}/iam (server.OpenSQLite, WAL) — this
|
||||
// embed owns its OWN orm.DB outright, so the old Casdoor-fork "ai bootstrap unable to
|
||||
// open database file (14)" crash is gone. Config (orgs/apps/providers/signing certs) is
|
||||
// seeded from the same init_data.json the deployment already provides (server.Seed,
|
||||
// new-only + idempotent), so hanzo.id's OAuth/OIDC semantics are preserved.
|
||||
//
|
||||
// IN-PROCESS STORE ACCESS. DB() exposes the opened orm.DB to sibling subsystems that
|
||||
// REFLECT the IAM-owned Project resource in-process (clients/platform, clients/deploy)
|
||||
// via github.com/hanzoai/iam/pkg/store — no HTTP hop to /v1/iam. It is nil until
|
||||
// Mount runs (the same lifecycle the retired iam-v1 object-store global ormer had),
|
||||
// so those callers guard a nil DB and degrade to a clean 503 until IAM is mounted.
|
||||
//
|
||||
// FAIL-CLOSED, NOT FAIL-LOUD. A broken/misconfigured IAM does NOT crash the
|
||||
// consolidated binary: InitEmbed's error (or a session/handler failure) degrades
|
||||
// THIS subsystem to a 503 fail-closed on every IAM prefix (mountFailClosed) while
|
||||
// every co-resident subsystem (KMS, o11y, …) stays up — the blast-radius
|
||||
// isolation the whole consolidation exists for, mirroring the KMS
|
||||
// "no master key → health-only" pattern.
|
||||
// consolidated binary: an open/seed/mount failure degrades THIS subsystem to a 503
|
||||
// fail-closed on every IAM prefix (mountFailClosed) while every co-resident
|
||||
// subsystem (KMS, o11y, …) stays up — the blast-radius isolation the whole
|
||||
// consolidation exists for, mirroring the KMS "no master key → health-only" pattern.
|
||||
//
|
||||
// Mounted in-process (whole Beego handler, full request path preserved):
|
||||
// Mounted in-process (the whole IAM v2 surface, registered at its canonical paths):
|
||||
//
|
||||
// /v1/iam/* API + OAuth (/v1/iam/oauth/{authorize,token,userinfo,introspect,
|
||||
// revoke,...}) + OIDC (/v1/iam/.well-known/{openid-configuration,
|
||||
// jwks,...}) + login/logout/signup + userinfo + me/* + cap/* +
|
||||
// cert/saml/tokens + the full admin surface
|
||||
// /.well-known/* legacy root OIDC discovery + JWKS (relying-party compatibility)
|
||||
// /v1/iam/* OIDC/OAuth2 (/v1/iam/oauth/{authorize,token,userinfo,introspect,
|
||||
// revoke,...}) + OIDC discovery (/v1/iam/.well-known/*) + signin +
|
||||
// get-app-login + the v2 entity CRUD + the Casdoor verb-alias compat
|
||||
// /login/oauth/* browser authorize surface (the /v1/iam/oauth/authorize 302 target)
|
||||
// /_/iam/* login UI SPA assets
|
||||
// /cas/* CAS 1.0/2.0/3.0 ticket validation
|
||||
// /scim/* SCIM 2.0 user/group provisioning
|
||||
//
|
||||
// STAGING (security-critical): activation is the standard enable-list gate — the
|
||||
// operator adds "iam" to the cloud deployment's --enable only AFTER IAM's config
|
||||
// (Beego app.conf + env + KMS signing keys) is present in the cloud runtime and
|
||||
// the fold is verified (login/authorize/token/jwks + the operator SSO chain). Until
|
||||
// then hanzo.id is served by the standalone iam pod via ingress. A cloud pod that
|
||||
// enables "iam" MUST run at replicas=1 (Config.Validate enforces this): IAM's
|
||||
// session store is Beego's process-local "memory" provider, so a horizontally
|
||||
// scaled app tier would mint a login/authorize session on one replica and lose it
|
||||
// on the next. If a broken config slips through, the subsystem serves 503
|
||||
// fail-closed (above) rather than crashing cloud.
|
||||
// operator adds "iam" to the cloud deployment's --enable only AFTER the v2 config
|
||||
// (init_data + KMS signing keys) is present and the fold is verified
|
||||
// (login/authorize/token/jwks + the operator SSO chain). Until then hanzo.id is
|
||||
// served by the standalone iam pod via ingress. If a broken config slips through,
|
||||
// the subsystem serves 503 fail-closed rather than crashing cloud.
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hanzoai/beego/v2/server/web"
|
||||
"github.com/hanzoai/beego/v2/server/web/session"
|
||||
"github.com/hanzoai/iam-v1/iamserver"
|
||||
iamserver "github.com/hanzoai/iam/server"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// iamPrefixes is every root path prefix the IAM Beego handler owns. The whole
|
||||
// handler is mounted at each via app.All(prefix+"/*", zip.AdaptNetHTTP(h)), which
|
||||
// preserves the full request path. The handler answers only paths IAM
|
||||
// registered; an unknown path under a prefix 404s from Beego exactly as
|
||||
// standalone IAM does, so a broad mount cannot leak another subsystem's surface.
|
||||
// iamPrefixes are the canonical absolute prefixes the IAM identity surface owns,
|
||||
// used ONLY for the fail-closed 503 — on success iamserver.Mount registers the real
|
||||
// routes itself. The bare /healthz is deliberately excluded — it is a shared-liveness
|
||||
// path, not an auth surface, so 503-ing it would mask the binary's own health rather
|
||||
// than an identity outage.
|
||||
var iamPrefixes = []string{
|
||||
"/v1/iam", // API + oauth + .well-known + me + cap + saml + tokens + admin
|
||||
"/.well-known", // legacy root OIDC discovery + JWKS (RP compatibility)
|
||||
"/v1/iam", // OIDC/OAuth2 + entity CRUD + the Casdoor verb-alias compat layer
|
||||
"/login/oauth", // browser authorize surface (the /v1/iam/oauth/authorize 302 target)
|
||||
"/_/iam", // login UI SPA assets
|
||||
"/cas", // CAS ticket validation
|
||||
"/scim", // SCIM 2.0
|
||||
}
|
||||
|
||||
// Mount boots the in-process IAM Beego server and attaches its http.Handler to
|
||||
// cloud's shared zip.App. Called once by cloud.MountAll when "iam" is enabled.
|
||||
//
|
||||
// Beego keeps process-global singletons (web.BeeApp, GlobalSessions, logger/flag
|
||||
// registration), so the bootstrap is inherently once-per-process; MountAll calls
|
||||
// each subsystem's Mount exactly once, which satisfies that.
|
||||
// embeddedDB is the orm.DB Mount opens for the embedded IAM store, published to
|
||||
// sibling subsystems via DB(). nil until a successful Mount — the same lifecycle the
|
||||
// retired iam-v1 object store's package-global ormer had, so in-process readers guard
|
||||
// a nil DB the way they used to guard a nil ormer.
|
||||
var embeddedDB orm.DB
|
||||
|
||||
// DB returns the embedded IAM store's orm.DB for in-process readers (clients/platform,
|
||||
// clients/deploy) that reflect the IAM-owned Project resource via
|
||||
// github.com/hanzoai/iam/pkg/store. It is nil until Mount has run (IAM not enabled, or
|
||||
// a boot failure that fail-closed the subsystem); callers MUST nil-guard and degrade to
|
||||
// 503 rather than dereference it.
|
||||
func DB() orm.DB { return embeddedDB }
|
||||
|
||||
// Mount opens IAM's embedded store, seeds config from the same init_data.json the
|
||||
// deployment provides (non-fatal), and registers the whole IAM v2 surface onto cloud's
|
||||
// shared zip.App. Called once by cloud.MountAll when "iam" is enabled.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
log := deps.Logger.New("subsystem", "iam")
|
||||
|
||||
// IAM persists its SQLite store under <DataDir>/iam, matching the sanctioned
|
||||
// iam.Embed default. Set before InitEmbed reads config — Beego's conf resolves
|
||||
// env ahead of app.conf, so this wins. Setenv only fails on a malformed key,
|
||||
// which is impossible here, so the error is ignored (mirrors iam.Embed).
|
||||
dataDir := filepath.Join(deps.DataDir, "iam")
|
||||
if deps.DataDir != "" {
|
||||
_ = os.Setenv("IAM_DATA_DIR", dataDir)
|
||||
dbPath, initDataPath := paths(deps)
|
||||
|
||||
// SHARED-GLOBAL ISOLATION (the reason "iam" was staged): both this Beego
|
||||
// identity fork AND the sibling `ai` casibase/casdoor fork resolve their
|
||||
// SQLite handle from the SAME process-global keys — env `dataSourceName`
|
||||
// (checked first by both forks' conf.GetConfigString) and, failing that, the
|
||||
// one beego web.AppConfig. A deployment sets `dataSourceName` for `ai`; with
|
||||
// IAM enabled, IAM's bootstrap would resolve that SAME value and xorm-open —
|
||||
// then auto-migrate its casdoor tables INTO — ai's database file. That is the
|
||||
// documented co-residence crash ("ai: bootstrap: unable to open database file
|
||||
// (14)") that pinned every post-embed release. IAM's conf already honors an
|
||||
// IAM-scoped override (conf.GetConfigDataSourceName → IAM_DATABASE_URL wins
|
||||
// over the shared `dataSourceName`), so pinning it here to IAM's OWN sqlite
|
||||
// file under DataDir gives the two forks independent stores, order-independent
|
||||
// and with NO fork edit. driverName ("sqlite") is shared harmlessly — both
|
||||
// forks want the one Hanzo sqlite driver. An operator-set IAM_DATABASE_URL
|
||||
// (explicit external DSN) is respected — only the default is filled in.
|
||||
isolateDatabase(dataDir)
|
||||
}
|
||||
|
||||
// cloud owns process shutdown, not Beego's graceful runner.
|
||||
web.BConfig.Listen.Graceful = false
|
||||
|
||||
// EMBED-MODE bootstrap (see package doc): the full IAM runtime minus the
|
||||
// standalone-daemon side effects, returning an error instead of panicking. A
|
||||
// broken IAM therefore degrades THIS subsystem to fail-closed health-only while
|
||||
// every co-resident subsystem stays up — the fold's blast-radius isolation.
|
||||
if err := iamserver.InitEmbed(); err != nil {
|
||||
log.Error("iam bootstrap failed — serving fail-closed 503 (cloud stays up; standalone iam pod unaffected)", "err", err)
|
||||
// SQLite does not create parent dirs; ensure it exists (0700 — identity data).
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
|
||||
log.Error("iam data dir create failed — serving fail-closed 503 (cloud stays up)", "err", err, "dir", filepath.Dir(dbPath))
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
// web.Run() normally registers the Beego session manager; the embed path skips
|
||||
// web.Run, so fire that one hook here. Without it every session-touching
|
||||
// request (login, authorize) nil-derefs in the router.
|
||||
if err := initSessions(); err != nil {
|
||||
log.Error("iam session init failed — serving fail-closed 503", "err", err)
|
||||
db, err := iamserver.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
log.Error("iam store open failed — serving fail-closed 503 (cloud stays up; standalone iam pod unaffected)", "err", err, "path", dbPath)
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
// Publish the opened store for in-process readers (DB()) — set only after a clean
|
||||
// open so DB() is nil whenever the subsystem is fail-closed.
|
||||
embeddedDB = db
|
||||
|
||||
// Seed is NON-FATAL: new-only + idempotent config bootstrap (orgs/apps/providers/
|
||||
// certs) from the SAME init_data.json the standalone iam seeds from. A missing or
|
||||
// partial file leaves iam mounted-but-unseeded (honest degrade) rather than blocking
|
||||
// the identity plane; an already-seeded store simply skips everything.
|
||||
if sum, serr := iamserver.Seed(context.Background(), db, initDataPath); serr != nil {
|
||||
log.Warn("iam seed skipped (non-fatal)", "err", serr, "init_data", initDataPath)
|
||||
} else if sum != nil {
|
||||
log.Info("iam seed applied", "created", sum.Created, "skipped", sum.Skipped, "init_data", initDataPath)
|
||||
}
|
||||
|
||||
// iamserver.Mount registers the whole surface at the canonical absolute paths. It
|
||||
// PANICS only if a registered enterprise feature fails to mount (none today);
|
||||
// recover so a future boot-misconfig degrades to fail-closed 503 instead of crashing
|
||||
// the shared binary — the same blast-radius isolation the whole fold gives.
|
||||
if err := safeMount(app, db); err != nil {
|
||||
log.Error("iam mount failed — serving fail-closed 503 (cloud stays up)", "err", err)
|
||||
embeddedDB = nil // fail-closed: no half-mounted store leaks to in-process readers
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := web.BeeApp.Handlers // *web.ControllerRegister implements http.Handler
|
||||
if handler == nil {
|
||||
log.Error("iam produced a nil Beego handler — serving fail-closed 503")
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
mountHandler(app, handler)
|
||||
|
||||
log.Info("iam embedded in-process (Beego handler mounted)", "data_dir", dataDir, "prefixes", iamPrefixes)
|
||||
log.Info("iam embedded in-process (clean iam-v2, zip-native + hanzoai/orm — Casdoor iam-v1 retired)", "db", dbPath, "prefixes", iamPrefixes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountFailClosed serves an honest JSON 503 on every IAM prefix when the embed
|
||||
// cannot boot, so /v1/iam/* answers "iam unavailable" instead of falling through
|
||||
// to the console SPA catch-all (which would return HTML 200 for an auth path).
|
||||
// cloud and every other subsystem stay up — the fold's blast-radius isolation.
|
||||
// During staged rollout hanzo.id is still served by the standalone iam pod via
|
||||
// ingress, so clients never see this path until cutover.
|
||||
// paths derives IAM's SQLite file and init_data.json path from cloud.Deps. The store
|
||||
// lives under {DataDir}/iam — its OWN dir. DataDir empty falls back to CWD, exactly as
|
||||
// the standalone iam default does. init_data.json is CWD-relative "init_data.json" (the
|
||||
// standalone iam conf default), honoring the same `initDataFile` env override so a
|
||||
// deployment points BOTH the embedded and standalone iam at one file (DRY, one source
|
||||
// of seed truth).
|
||||
func paths(deps cloud.Deps) (dbPath, initDataPath string) {
|
||||
root := deps.DataDir
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
dbPath = filepath.Join(root, "iam", "iam.db")
|
||||
|
||||
initDataPath = os.Getenv("initDataFile")
|
||||
if initDataPath == "" {
|
||||
initDataPath = "init_data.json"
|
||||
}
|
||||
return dbPath, initDataPath
|
||||
}
|
||||
|
||||
// safeMount runs iamserver.Mount under a recover so its only panic path — a registered
|
||||
// enterprise feature failing to mount — becomes an error the caller fail-closes on,
|
||||
// never a crash of the shared cloud binary. With zero features registered today it
|
||||
// always returns nil.
|
||||
func safeMount(app *zip.App, db orm.DB) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("iam mount panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
iamserver.Mount(app, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountFailClosed serves an honest JSON 503 on every identity prefix when IAM cannot
|
||||
// boot, so /v1/iam/* answers "iam unavailable" instead of falling through to the
|
||||
// console SPA catch-all (which would 200 an auth path). cloud and every other subsystem
|
||||
// stay up — the fold's blast-radius isolation. During staged rollout hanzo.id is still
|
||||
// served by the standalone iam pod via ingress, so clients never see this path until
|
||||
// cutover.
|
||||
func mountFailClosed(app *zip.App) {
|
||||
failed := zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -166,74 +184,3 @@ func mountFailClosed(app *zip.App) {
|
||||
app.All(p+"/*", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// mountHandler attaches the IAM http.Handler at every prefix IAM owns. Split from
|
||||
// Mount so the routing plumbing — app.All(prefix+"/*", zip.AdaptNetHTTP(h))
|
||||
// dispatching to the handler with the ORIGINAL request path preserved (Beego
|
||||
// routes on the full path) — is unit-testable without booting the full Beego runtime.
|
||||
func mountHandler(app *zip.App, handler http.Handler) {
|
||||
for _, p := range iamPrefixes {
|
||||
app.All(p+"/*", zip.AdaptNetHTTP(handler))
|
||||
}
|
||||
}
|
||||
|
||||
// initSessions mirrors the Beego session-manager registration that web.Run()
|
||||
// performs via its unexported initBeforeHTTPRun hook (which the embed path
|
||||
// bypasses). Config is read straight from web.BConfig.WebConfig.Session, which
|
||||
// iamserver.Init() has already populated (memory provider, cookie
|
||||
// "iam_session_id", lax SameSite), so there is no second source of session truth
|
||||
// — this only wires the manager IAM already configured. Idempotent.
|
||||
func initSessions() error {
|
||||
if web.GlobalSessions != nil {
|
||||
return nil
|
||||
}
|
||||
s := web.BConfig.WebConfig.Session
|
||||
mgr, err := session.NewManager(s.SessionProvider, &session.ManagerConfig{
|
||||
CookieName: s.SessionName,
|
||||
EnableSetCookie: s.SessionAutoSetCookie,
|
||||
Gclifetime: s.SessionGCMaxLifetime,
|
||||
// Secure is PINNED true, not derived from Listen.EnableHTTPS. The binary
|
||||
// listens plain :8000 behind the TLS-terminating ingress, so EnableHTTPS is
|
||||
// false and the derived value would ship a non-Secure session cookie — and
|
||||
// the embed console's identity bridge (middleware_identity.sessionAccessToken)
|
||||
// turns that opaque sid into a money bearer (hk- mint, balance/top-up). A
|
||||
// non-Secure cookie is capturable off any plaintext leg and replayable, so it
|
||||
// MUST be Secure. The deployed edge is always HTTPS; a plain-HTTP local embed
|
||||
// is not a supported prod topology. (RED H2.)
|
||||
Secure: true,
|
||||
CookieLifeTime: s.SessionCookieLifeTime,
|
||||
ProviderConfig: filepath.ToSlash(s.SessionProviderConfig),
|
||||
DisableHTTPOnly: s.SessionDisableHTTPOnly,
|
||||
Domain: s.SessionDomain,
|
||||
EnableSidInHTTPHeader: s.SessionEnableSidInHTTPHeader,
|
||||
SessionNameInHTTPHeader: s.SessionNameInHTTPHeader,
|
||||
EnableSidInURLQuery: s.SessionEnableSidInURLQuery,
|
||||
CookieSameSite: s.SessionCookieSameSite,
|
||||
SessionIDPrefix: s.SessionIDPrefix,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
web.GlobalSessions = mgr
|
||||
go mgr.GC()
|
||||
return nil
|
||||
}
|
||||
// isolateDatabase pins IAM's SQLite handle to its OWN file under dataDir via the
|
||||
// IAM-scoped IAM_DATABASE_URL override, so the embedded IAM never resolves the
|
||||
// shared `dataSourceName` (which a deployment sets for the sibling `ai` fork) and
|
||||
// the two casdoor-derived forks get independent stores. See the call site for the
|
||||
// full rationale (this is the co-residence unblock). An operator-set
|
||||
// IAM_DATABASE_URL is respected; only the default is filled in. Idempotent.
|
||||
func isolateDatabase(dataDir string) {
|
||||
if _, ok := os.LookupEnv("IAM_DATABASE_URL"); ok {
|
||||
return
|
||||
}
|
||||
_ = os.Setenv("IAM_DATABASE_URL", defaultIAMDatabaseURL(dataDir))
|
||||
}
|
||||
|
||||
// defaultIAMDatabaseURL is IAM's default embedded SQLite DSN: its own iam.db under
|
||||
// the IAM data dir, WAL + a busy-timeout so a co-resident reader never trips
|
||||
// SQLITE_BUSY. Pure (no env/IO) so the isolation contract is unit-testable.
|
||||
func defaultIAMDatabaseURL(dataDir string) string {
|
||||
return "file:" + filepath.ToSlash(filepath.Join(dataDir, "iam.db")) + "?cache=shared&_busy_timeout=5000&_journal_mode=WAL"
|
||||
}
|
||||
|
||||
+53
-92
@@ -3,78 +3,41 @@ package iam
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/beego/v2/server/web"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
"github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// TestPrefixesCoverAuthCritical guards that the mount set never loses an
|
||||
// auth-critical surface hanzo.id serves — the operator SSO chain and every
|
||||
// relying party depend on these exact prefixes being served in-process.
|
||||
// TestPrefixesCoverAuthCritical guards that the fail-closed prefix set never loses an
|
||||
// auth-critical surface hanzo.id serves — the operator SSO chain and every relying
|
||||
// party depend on these exact prefixes being served in-process.
|
||||
func TestPrefixesCoverAuthCritical(t *testing.T) {
|
||||
have := map[string]bool{}
|
||||
for _, p := range iamPrefixes {
|
||||
have[p] = true
|
||||
}
|
||||
for _, n := range []string{"/v1/iam", "/.well-known", "/login/oauth"} {
|
||||
for _, n := range []string{"/v1/iam", "/login/oauth"} {
|
||||
if !have[n] {
|
||||
t.Errorf("iamPrefixes missing auth-critical prefix %q", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountHandlerPreservesFullPath verifies the routing plumbing the embedded
|
||||
// IAM handler relies on: zip.App.Mount(prefix, h) must dispatch prefix/* to h
|
||||
// with the ORIGINAL request path intact. Beego routes on the full path
|
||||
// (/v1/iam/oauth/token, not a stripped /oauth/token), so a prefix-stripping mount
|
||||
// would 404 every OAuth/OIDC call. This drives the exact mountHandler call Mount
|
||||
// uses, with a stub handler, so it runs without the full Beego runtime.
|
||||
func TestMountHandlerPreservesFullPath(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
|
||||
var gotPath string
|
||||
stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mountHandler(app, stub)
|
||||
|
||||
for _, want := range []string{
|
||||
"/v1/iam/oauth/token",
|
||||
"/v1/iam/oauth/authorize",
|
||||
"/v1/iam/.well-known/jwks",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/jwks",
|
||||
"/login/oauth/authorize",
|
||||
} {
|
||||
gotPath = ""
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, want, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Test(%s): %v", want, err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if gotPath != want {
|
||||
t.Errorf("embedded handler saw path %q, want full %q (prefix stripped?)", gotPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountFailClosed503 proves the fail-soft path: when the embed cannot boot,
|
||||
// mountFailClosed serves an honest JSON 503 on every IAM prefix instead of
|
||||
// letting /v1/iam/* fall through to the console SPA (HTML 200). cloud and every
|
||||
// co-resident subsystem stay up — the blast-radius isolation the consolidation
|
||||
// exists for.
|
||||
// mountFailClosed serves an honest JSON 503 on every IAM prefix instead of letting
|
||||
// /v1/iam/* fall through to the console SPA (HTML 200). cloud and every co-resident
|
||||
// subsystem stay up — the blast-radius isolation the consolidation exists for.
|
||||
func TestMountFailClosed503(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
mountFailClosed(app)
|
||||
for _, p := range []string{
|
||||
"/v1/iam/oauth/token",
|
||||
"/v1/iam/.well-known/jwks",
|
||||
"/.well-known/openid-configuration",
|
||||
"/login/oauth/authorize",
|
||||
} {
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, p, nil))
|
||||
@@ -88,56 +51,54 @@ func TestMountFailClosed503(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolateDatabase proves the co-residence unblock: isolateDatabase pins IAM's
|
||||
// SQLite handle to its OWN iam.db under the IAM data dir via IAM_DATABASE_URL —
|
||||
// the IAM-scoped key that conf.GetConfigDataSourceName honors ABOVE the shared
|
||||
// `dataSourceName` a deployment sets for the sibling `ai` fork. Without this the
|
||||
// embedded IAM opens ai's database and the binary crashes at boot (SQLITE_CANTOPEN
|
||||
// / casdoor-table auto-migration into ai's store). It must (1) set an iam-owned
|
||||
// DSN, (2) NOT collide with ai's dataSourceName, and (3) respect an operator
|
||||
// override.
|
||||
func TestIsolateDatabase(t *testing.T) {
|
||||
const dataDir = "/data/iam"
|
||||
const aiDSN = "file:/data/ai.db?cache=shared" // what a deployment sets for `ai`
|
||||
|
||||
// (1)+(2): default fills an iam-owned DSN that is NOT ai's.
|
||||
t.Setenv("dataSourceName", aiDSN)
|
||||
os.Unsetenv("IAM_DATABASE_URL")
|
||||
isolateDatabase(dataDir)
|
||||
got := os.Getenv("IAM_DATABASE_URL")
|
||||
if got == "" {
|
||||
t.Fatal("IAM_DATABASE_URL unset after isolateDatabase — IAM would resolve ai's dataSourceName")
|
||||
// TestPaths covers the store-path derivation: the SQLite file lands under {DataDir}/iam,
|
||||
// and init_data.json resolves the standalone-iam default unless `initDataFile` overrides.
|
||||
func TestPaths(t *testing.T) {
|
||||
dbPath, initData := paths(cloud.Deps{DataDir: "/var/data"})
|
||||
if dbPath != "/var/data/iam/iam.db" {
|
||||
t.Errorf("dbPath = %q, want /var/data/iam/iam.db", dbPath)
|
||||
}
|
||||
if got == aiDSN {
|
||||
t.Fatalf("IAM_DATABASE_URL == ai's dataSourceName (%q) — the collision is NOT isolated", got)
|
||||
if initData != "init_data.json" {
|
||||
t.Errorf("initData = %q, want the CWD-relative default", initData)
|
||||
}
|
||||
if !strings.Contains(got, "/data/iam/iam.db") {
|
||||
t.Errorf("IAM_DATABASE_URL = %q, want IAM's own iam.db under the data dir", got)
|
||||
}
|
||||
|
||||
// (3): an operator-set IAM_DATABASE_URL (e.g. an external DSN) is respected.
|
||||
const override = "file:/mnt/custom/iam.db?cache=shared"
|
||||
t.Setenv("IAM_DATABASE_URL", override)
|
||||
isolateDatabase(dataDir)
|
||||
if os.Getenv("IAM_DATABASE_URL") != override {
|
||||
t.Errorf("isolateDatabase clobbered operator override: got %q, want %q", os.Getenv("IAM_DATABASE_URL"), override)
|
||||
t.Setenv("initDataFile", "/etc/iam/init_data.json")
|
||||
if _, initData := paths(cloud.Deps{DataDir: "/var/data"}); initData != "/etc/iam/init_data.json" {
|
||||
t.Errorf("initData override not honored, got %q", initData)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitSessionsIdempotent proves the session-manager hook is safe to call more
|
||||
// than once (Beego's GlobalSessions is a process singleton) and wires the memory
|
||||
// provider IAM configures. Beego defaults SessionProvider to "memory" and
|
||||
// auto-registers it, so this runs without iamserver.Init.
|
||||
func TestInitSessionsIdempotent(t *testing.T) {
|
||||
if web.GlobalSessions == nil {
|
||||
if err := initSessions(); err != nil {
|
||||
t.Fatalf("initSessions: %v", err)
|
||||
}
|
||||
// TestDBLifecycleAndStore is the end-to-end contract for the in-process store accessor:
|
||||
// DB() is nil until Mount runs (the nil-guard contract sibling subsystems rely on), and
|
||||
// after a successful Mount DB() returns the live orm.DB that pkg/store reads/writes the
|
||||
// SAME project rows through — the whole reason Layer 3 (clients/platform, clients/deploy)
|
||||
// can drop iam-v1's in-process object store.
|
||||
func TestDBLifecycleAndStore(t *testing.T) {
|
||||
embeddedDB = nil // assert the pre-Mount nil-guard contract from a known state
|
||||
if DB() != nil {
|
||||
t.Fatal("DB() must be nil before Mount")
|
||||
}
|
||||
if web.GlobalSessions == nil {
|
||||
t.Fatal("GlobalSessions still nil after initSessions")
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}
|
||||
if err := Mount(app, deps); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
if err := initSessions(); err != nil {
|
||||
t.Fatalf("initSessions second call (must be a no-op): %v", err)
|
||||
if DB() == nil {
|
||||
t.Fatal("DB() must be non-nil after a successful Mount")
|
||||
}
|
||||
|
||||
// The embedded store is the ONE project store: write via pkg/store over DB(), read it
|
||||
// back by its owner/name id, and confirm tenant-scoped listing sees exactly it.
|
||||
ok, err := store.AddProject(DB(), &model.Project{Owner: "hanzo", Name: "alpha", DisplayName: "Alpha"})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("AddProject over DB(): ok=%v err=%v", ok, err)
|
||||
}
|
||||
got, err := store.GetProject(DB(), "hanzo/alpha")
|
||||
if err != nil || got == nil || got.Name != "alpha" {
|
||||
t.Fatalf("GetProject over DB(): got=%+v err=%v", got, err)
|
||||
}
|
||||
rows, err := store.GetOrganizationProjects(DB(), "hanzo")
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("GetOrganizationProjects over DB(): rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// Package iam2 mounts the clean-room Hanzo IAM v2 (zip-native, beego-free) into the
|
||||
// unified hanzoai/cloud binary as the identity plane, selected OVER the legacy beego
|
||||
// Casdoor embed (clients/iam) by CLOUD_IAM_IMPL=iam2. It is the either/or twin of
|
||||
// clients/iam: both own the SAME absolute prefixes (/v1/iam/*, /login/oauth/*), so
|
||||
// they CANNOT co-mount — EXACTLY ONE is wired per boot (apps.Wire → identitySpec).
|
||||
// Default (CLOUD_IAM_IMPL unset) keeps the beego embed, so this package is completely
|
||||
// inert until the flag flips — safe to land in a churning main.
|
||||
//
|
||||
// WHY iam2 is NOT staged like iam. "iam" is staged because iamserver.InitEmbed boots
|
||||
// the WHOLE Beego runtime and mutates process-global Beego state (web.BeeApp / the
|
||||
// shared AppConfig), which corrupts the sibling `ai` casdoor fork under mount-all.
|
||||
// iam2 carries NO such process-global: it opens its OWN orm.DB and registers
|
||||
// zip-native routes, so the shared-global hazard that pins iam to staged does not
|
||||
// exist here. The deliberate CLOUD_IAM_IMPL=iam2 opt-in is itself the gate.
|
||||
//
|
||||
// FAIL-CLOSED, NOT FAIL-LOUD. A store-open or mount failure degrades THIS subsystem
|
||||
// to a 503 on the identity prefixes (mountFailClosed) while every co-resident
|
||||
// subsystem (KMS, o11y, ...) stays up — the fold's blast-radius isolation. It never
|
||||
// panics the shared binary (iam2server.Mount's only panic path — a registered
|
||||
// enterprise feature failing to mount — is recovered in safeMount).
|
||||
package iam2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
iam2server "github.com/hanzoai/iam/server"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// iam2Prefixes are the canonical absolute prefixes the iam2 identity surface owns,
|
||||
// used ONLY for the fail-closed 503 — on success iam2server.Mount registers the real
|
||||
// routes itself. Mirrors the identity subset of clients/iam.iamPrefixes; iam2's bare
|
||||
// /healthz is deliberately excluded — it is a shared-liveness path, not an auth
|
||||
// surface, so 503-ing it would mask the binary's own health rather than an identity
|
||||
// outage.
|
||||
var iam2Prefixes = []string{
|
||||
"/v1/iam", // OIDC/OAuth2 + entity CRUD + the Casdoor verb-alias compat layer
|
||||
"/login/oauth", // browser authorize surface (the /v1/iam/oauth/authorize 302 target)
|
||||
}
|
||||
|
||||
// Mount opens iam2's embedded store, seeds config from the same init_data.json the
|
||||
// beego iam uses (non-fatal), and registers the whole iam2 surface onto cloud's shared
|
||||
// zip.App. It matches the cloud.Typed contract (func(*zip.App, cloud.Deps) error) so
|
||||
// apps.Wire references it via cloud.Typed exactly like clients/iam.Mount — cloud hands
|
||||
// subsystems a cloud.Deps, not an orm.DB, so iam2 opens its own store here.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
log := deps.Logger.New("subsystem", "iam2")
|
||||
|
||||
dbPath, initDataPath := paths(deps)
|
||||
|
||||
// SQLite does not create parent dirs; ensure it exists (0700 — identity data).
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
|
||||
log.Error("iam2 data dir create failed — serving fail-closed 503 (cloud stays up)", "err", err, "dir", filepath.Dir(dbPath))
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := iam2server.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
log.Error("iam2 store open failed — serving fail-closed 503 (cloud stays up)", "err", err, "path", dbPath)
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seed is NON-FATAL: new-only + idempotent config bootstrap (orgs/apps/providers/
|
||||
// certs) from the SAME init_data.json the beego iam seeds from. A missing or partial
|
||||
// file leaves iam2 mounted-but-unseeded (honest degrade) rather than blocking the
|
||||
// identity plane; an already-seeded store simply skips everything.
|
||||
if sum, serr := iam2server.Seed(context.Background(), db, initDataPath); serr != nil {
|
||||
log.Warn("iam2 seed skipped (non-fatal)", "err", serr, "init_data", initDataPath)
|
||||
} else {
|
||||
log.Info("iam2 seed applied", "created", sum.Created, "skipped", sum.Skipped, "init_data", initDataPath)
|
||||
}
|
||||
|
||||
// TODO(iam2 enterprise): feature.Register(scim.New()); feature.Register(saml.New());
|
||||
// feature.Register(ldap.New()) — the hanzoiam/{scim,saml,ldap} modules land in a
|
||||
// parallel lane. iam2server.Mount already calls feature.MountAll, so enabling them is
|
||||
// these 3 register lines + a go.mod bump; do NOT add the imports until the modules
|
||||
// are pushed (an unresolved import breaks the build).
|
||||
|
||||
// iam2server.Mount registers the whole surface at the canonical absolute paths. It
|
||||
// PANICS only if a registered enterprise feature fails to mount (none today); recover
|
||||
// so a future boot-misconfig degrades to fail-closed 503 instead of crashing the
|
||||
// shared binary — the same blast-radius isolation clients/iam gives.
|
||||
if err := safeMount(app, db); err != nil {
|
||||
log.Error("iam2 mount failed — serving fail-closed 503 (cloud stays up)", "err", err)
|
||||
mountFailClosed(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("iam2 embedded in-process (zip-native, beego-free)", "db", dbPath, "prefixes", iam2Prefixes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// paths derives iam2's SQLite file and init_data.json path from cloud.Deps, mirroring
|
||||
// how clients/iam sources them. The store lives under {DataDir}/iam2 — its OWN dir,
|
||||
// parallel to the beego embed's {DataDir}/iam, so the two impls (either/or, never
|
||||
// co-resident) keep independent stores and iam2 never opens beego's casdoor-schema
|
||||
// file. DataDir empty falls back to CWD exactly as the beego default does.
|
||||
// init_data.json is CWD-relative "init_data.json" — the beego iam's conf default —
|
||||
// honoring the same `initDataFile` env override, so a deployment points BOTH impls at
|
||||
// one file (DRY, one source of seed truth).
|
||||
func paths(deps cloud.Deps) (dbPath, initDataPath string) {
|
||||
root := deps.DataDir
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
dbPath = filepath.Join(root, "iam2", "iam.db")
|
||||
|
||||
initDataPath = os.Getenv("initDataFile")
|
||||
if initDataPath == "" {
|
||||
initDataPath = "init_data.json"
|
||||
}
|
||||
return dbPath, initDataPath
|
||||
}
|
||||
|
||||
// safeMount runs iam2server.Mount under a recover so its only panic path — a registered
|
||||
// enterprise feature failing to mount — becomes an error the caller fail-closes on,
|
||||
// never a crash of the shared cloud binary. With zero features registered today it
|
||||
// always returns nil.
|
||||
func safeMount(app *zip.App, db orm.DB) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("iam2 mount panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
iam2server.Mount(app, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mountFailClosed serves an honest JSON 503 on every identity prefix when iam2 cannot
|
||||
// boot, so /v1/iam/* answers "iam unavailable" instead of falling through to the
|
||||
// console SPA catch-all (which would 200 an auth path). cloud and every other
|
||||
// subsystem stay up — the fold's blast-radius isolation. Byte-identical error contract
|
||||
// to clients/iam.mountFailClosed so clients see one shape regardless of impl.
|
||||
func mountFailClosed(app *zip.App) {
|
||||
failed := zip.AdaptNetHTTP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":"iam unavailable","code":503}`))
|
||||
}))
|
||||
for _, p := range iam2Prefixes {
|
||||
app.All(p+"/*", failed)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -584,7 +584,8 @@ func k8sErr(s *cloud.Service[state], c *zip.Ctx, k resourceKind, op string, err
|
||||
// dedicated cloud-ml identity — decomplected from the pod's own cloud-api SA — so
|
||||
// ML's KServe/Kubeflow cluster reach is never inherited by the product-API path
|
||||
// (least privilege; blast-radius separation). See universe
|
||||
// infra/k8s/cloud/ml-rbac.yaml (ClusterRoleBinding cloud-mlsvc -> cloud-ml).
|
||||
// infra/k8s/cloud/ml-rbac.yaml (the cloud-ml ClusterRoleBinding grants the
|
||||
// cloud-ml ServiceAccount its ML cluster role).
|
||||
const mlTokenFileEnv = "HANZO_ML_TOKEN_FILE"
|
||||
|
||||
// dynForOrg returns the client ML operations should target for an org+project: its
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
// column is the project NAME), but create/list/get/delete/exists of the bare
|
||||
// project delegate here.
|
||||
//
|
||||
// IAM is embedded in the SAME cloud binary (clients/iam mounts the whole Beego
|
||||
// handler; iamserver.InitEmbed wires the shared object store), so the reference
|
||||
// is an IN-PROCESS call into github.com/hanzoai/iam-v1/object — no HTTP hop to
|
||||
// /v1/iam, and IAM's canonical *object.Project is used verbatim, never cloned
|
||||
// into a platform-local struct. This couples platform to the embedded IAM
|
||||
// runtime: a cloud deployment that enables "platform" MUST also enable "iam"
|
||||
// (both are single-binary co-residents by design), else the object store's
|
||||
// engine is nil and a project call fails.
|
||||
// IAM is embedded in the SAME cloud binary (clients/iam mounts the clean iam-v2
|
||||
// zip-natively and opens its orm.DB), so the reference is an IN-PROCESS call into
|
||||
// the clean iam's project store (github.com/hanzoai/iam/pkg/store over
|
||||
// clients/iam.DB()) — no HTTP hop to /v1/iam, and IAM's canonical model.Project is
|
||||
// used verbatim, never cloned into a platform-local struct. This couples platform to
|
||||
// the embedded IAM runtime: a cloud deployment that enables "platform" MUST also
|
||||
// enable "iam" (both are single-binary co-residents by design), else clients/iam.DB()
|
||||
// is nil and a project call fails closed (503). The retired Casdoor iam-v1 object
|
||||
// store is GONE.
|
||||
package platform
|
||||
|
||||
import (
|
||||
@@ -20,58 +21,66 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
iamobj "github.com/hanzoai/iam-v1/object"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
iamclient "github.com/hanzoai/cloud/clients/iam"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
iamstore "github.com/hanzoai/iam/pkg/store"
|
||||
)
|
||||
|
||||
// iamStore runs an embedded-IAM object-store call, converting a nil-store panic
|
||||
// into a clean 503. The store's engine (iamobj.ormer) is a package global that
|
||||
// is nil until the co-resident IAM subsystem initializes it; a project call
|
||||
// against a nil engine would otherwise nil-deref and surface as a 500 "runtime
|
||||
// error: invalid memory address". A deployment that enables "platform" is meant
|
||||
// to co-mount "iam" (see the package doc) — until it does, this reports the
|
||||
// honest "IAM not available" instead of panicking. Never masks a real error.
|
||||
func iamStore[T any](fn func() (T, error)) (out T, err error) {
|
||||
// iamStore runs an in-process IAM project-store call against db (the embedded IAM's
|
||||
// orm.DB, sourced from clients/iam.DB()): it short-circuits an absent store (IAM not
|
||||
// mounted → db nil) into a clean 503, and recovers any unexpected nil-deref as the
|
||||
// same 503 rather than a 500. The embedded IAM's DB is nil until the co-resident IAM
|
||||
// subsystem mounts it; a deployment that enables "platform" is meant to co-mount "iam"
|
||||
// (see the package doc) — until it does, this reports the honest "IAM not available".
|
||||
// Mirrors the old iam-v1 nil-ormer guard, now guarding a nil DB. Never masks a real
|
||||
// error.
|
||||
func iamStore[T any](db orm.DB, fn func(db orm.DB) (T, error)) (out T, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = zip.Errorf(http.StatusServiceUnavailable,
|
||||
"platform requires the co-resident IAM store, which is not initialized")
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
if db == nil {
|
||||
return out, zip.Errorf(http.StatusServiceUnavailable,
|
||||
"platform requires the co-resident IAM store, which is not initialized")
|
||||
}
|
||||
return fn(db)
|
||||
}
|
||||
|
||||
// ProjectStore is platform's org-scoped view of the IAM-owned project lifecycle.
|
||||
// Every method is scoped to org (the validated owner) and keyed by the project
|
||||
// name — there is no platform-minted project id; the IAM identity is (org,name),
|
||||
// and that name is the app-scope key AND the operator CR `part-of` label. The
|
||||
// value type is IAM's canonical *object.Project, so there is exactly ONE project
|
||||
// value type is IAM's canonical *model.Project, so there is exactly ONE project
|
||||
// model across the binary.
|
||||
type ProjectStore interface {
|
||||
List(ctx context.Context, org string) ([]*iamobj.Project, error)
|
||||
List(ctx context.Context, org string) ([]*model.Project, error)
|
||||
// Get returns nil (no error) when the project does not exist — IAM's convention.
|
||||
Get(ctx context.Context, org, name string) (*iamobj.Project, error)
|
||||
Create(ctx context.Context, org, name, display, description string) (*iamobj.Project, error)
|
||||
Get(ctx context.Context, org, name string) (*model.Project, error)
|
||||
Create(ctx context.Context, org, name, display, description string) (*model.Project, error)
|
||||
Delete(ctx context.Context, org, name string) (bool, error)
|
||||
Exists(ctx context.Context, org, name string) (bool, error)
|
||||
}
|
||||
|
||||
// iamProjects backs ProjectStore with the in-process IAM object store — the SAME
|
||||
// embedded IAM that serves /v1/iam. It maps platform's (org,name) to IAM's
|
||||
// iamProjects backs ProjectStore with the in-process clean-iam project store — the
|
||||
// SAME embedded IAM that serves /v1/iam. It maps platform's (org,name) to IAM's
|
||||
// (Owner,Name) and delegates; no auth or project logic is reimplemented.
|
||||
type iamProjects struct{}
|
||||
|
||||
func (iamProjects) List(_ context.Context, org string) ([]*iamobj.Project, error) {
|
||||
return iamStore(func() ([]*iamobj.Project, error) { return iamobj.GetProjects(org) })
|
||||
func (iamProjects) List(_ context.Context, org string) ([]*model.Project, error) {
|
||||
return iamStore(iamclient.DB(), func(db orm.DB) ([]*model.Project, error) { return iamstore.GetProjects(db, org) })
|
||||
}
|
||||
|
||||
func (iamProjects) Get(_ context.Context, org, name string) (*iamobj.Project, error) {
|
||||
return iamStore(func() (*iamobj.Project, error) { return iamobj.GetProject(org + "/" + name) })
|
||||
func (iamProjects) Get(_ context.Context, org, name string) (*model.Project, error) {
|
||||
return iamStore(iamclient.DB(), func(db orm.DB) (*model.Project, error) { return iamstore.GetProject(db, org+"/"+name) })
|
||||
}
|
||||
|
||||
func (p iamProjects) Create(ctx context.Context, org, name, display, description string) (*iamobj.Project, error) {
|
||||
func (p iamProjects) Create(ctx context.Context, org, name, display, description string) (*model.Project, error) {
|
||||
// Pre-check existence so a duplicate name is a clean 409 (errConflict) rather
|
||||
// than a driver-specific unique-constraint error surfacing as a 500.
|
||||
existing, err := p.Get(ctx, org, name)
|
||||
@@ -81,12 +90,12 @@ func (p iamProjects) Create(ctx context.Context, org, name, display, description
|
||||
if existing != nil {
|
||||
return nil, errConflict
|
||||
}
|
||||
proj := &iamobj.Project{
|
||||
proj := &model.Project{
|
||||
Owner: org, Name: name, Organization: org,
|
||||
DisplayName: display, Description: description,
|
||||
IsDefault: principal.IsDefaultProject(name),
|
||||
}
|
||||
ok, err := iamStore(func() (bool, error) { return iamobj.AddProject(proj) })
|
||||
ok, err := iamStore(iamclient.DB(), func(db orm.DB) (bool, error) { return iamstore.AddProject(db, proj) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -97,8 +106,8 @@ func (p iamProjects) Create(ctx context.Context, org, name, display, description
|
||||
}
|
||||
|
||||
func (iamProjects) Delete(_ context.Context, org, name string) (bool, error) {
|
||||
return iamStore(func() (bool, error) {
|
||||
return iamobj.DeleteProject(&iamobj.Project{Owner: org, Name: name})
|
||||
return iamStore(iamclient.DB(), func(db orm.DB) (bool, error) {
|
||||
return iamstore.DeleteProject(db, &model.Project{Owner: org, Name: name})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +128,7 @@ type projectView struct {
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func toProjectView(p *iamobj.Project, apps int) projectView {
|
||||
func toProjectView(p *model.Project, apps int) projectView {
|
||||
return projectView{
|
||||
Org: p.Owner, Slug: p.Name, Name: firstNonEmpty(p.DisplayName, p.Name),
|
||||
Description: p.Description, Applications: apps, CreatedAt: projectCreatedAt(p),
|
||||
@@ -128,7 +137,7 @@ func toProjectView(p *iamobj.Project, apps int) projectView {
|
||||
|
||||
// projectCreatedAt converts IAM's RFC3339 CreatedTime to a unix timestamp; an
|
||||
// absent/unparseable value yields 0 (never a fabricated time).
|
||||
func projectCreatedAt(p *iamobj.Project) int64 {
|
||||
func projectCreatedAt(p *model.Project) int64 {
|
||||
if t, err := time.Parse(time.RFC3339, p.CreatedTime); err == nil {
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
@@ -5,33 +5,60 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
iamobj "github.com/hanzoai/iam-v1/object"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
iamserver "github.com/hanzoai/iam/server"
|
||||
)
|
||||
|
||||
// When the co-resident IAM object store is not initialized, iamobj.GetProjects
|
||||
// nil-derefs (ormer.Engine == nil). The iamStore guard must convert that panic
|
||||
// into a clean 503 — never a 500 "runtime error: invalid memory address" that
|
||||
// the live /v1/platform surface returned. Regression gate for the platform panic.
|
||||
func TestIAMStore_NilStorePanicBecomes503(t *testing.T) {
|
||||
// Simulate a store call that nil-derefs (exactly what GetProjects does with a
|
||||
// nil ormer.Engine): dereference a nil pointer inside the guarded call.
|
||||
var engine *struct{ n int }
|
||||
_, err := iamStore(func() (int, error) { return engine.n, nil }) // nil deref
|
||||
if err == nil {
|
||||
t.Fatal("iamStore swallowed a nil-store panic — must surface a 503 error")
|
||||
// realDB opens a throwaway embedded IAM store so the guard tests can exercise the
|
||||
// non-nil-db path (fn actually runs) — the SAME store type production sources from
|
||||
// clients/iam.DB().
|
||||
func realDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
db, err := iamserver.OpenSQLite(filepath.Join(t.TempDir(), "iam.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open iam sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// When the co-resident IAM store is not initialized, clients/iam.DB() is nil. The
|
||||
// iamStore guard must convert that into a clean 503 WITHOUT invoking fn — never a 500
|
||||
// "runtime error: invalid memory address" that the live /v1/platform surface returned.
|
||||
// Regression gate for the platform panic.
|
||||
func TestIAMStore_NilDBBecomes503(t *testing.T) {
|
||||
called := false
|
||||
_, err := iamStore(nil, func(orm.DB) (int, error) { called = true; return 0, nil })
|
||||
if called {
|
||||
t.Fatal("iamStore must not invoke fn when the IAM store db is nil")
|
||||
}
|
||||
var he *zip.HTTPError
|
||||
if !errors.As(err, &he) || he.Status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("iamStore error = %v, want a 503 zip.HTTPError", err)
|
||||
t.Fatalf("iamStore(nil) error = %v, want a 503 zip.HTTPError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil-deref inside the guarded call (defense in depth) also becomes a clean 503.
|
||||
func TestIAMStore_PanicBecomes503(t *testing.T) {
|
||||
_, err := iamStore(realDB(t), func(orm.DB) (int, error) {
|
||||
var engine *struct{ n int }
|
||||
return engine.n, nil // nil deref
|
||||
})
|
||||
var he *zip.HTTPError
|
||||
if !errors.As(err, &he) || he.Status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("iamStore panic error = %v, want a 503 zip.HTTPError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard is transparent when the call succeeds — no false 503.
|
||||
func TestIAMStore_PassthroughOnSuccess(t *testing.T) {
|
||||
got, err := iamStore(func() (string, error) { return "ok", nil })
|
||||
got, err := iamStore(realDB(t), func(orm.DB) (string, error) { return "ok", nil })
|
||||
if err != nil || got != "ok" {
|
||||
t.Fatalf("iamStore passthrough = (%q,%v), want (ok,nil)", got, err)
|
||||
}
|
||||
@@ -41,22 +68,22 @@ func TestIAMStore_PassthroughOnSuccess(t *testing.T) {
|
||||
// never masks a genuine error as a 503.
|
||||
func TestIAMStore_RealErrorPassthrough(t *testing.T) {
|
||||
sentinel := errors.New("db offline")
|
||||
_, err := iamStore(func() (int, error) { return 0, sentinel })
|
||||
_, err := iamStore(realDB(t), func(orm.DB) (int, error) { return 0, sentinel })
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("iamStore masked a real error: got %v, want %v", err, sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListProjects_NilIAMStore_ServesEmpty200 reproduces the live console-init
|
||||
// 500 and pins the fix. With the REAL iamProjects store and a co-resident IAM
|
||||
// object store that is NOT initialized in-process (ormer == nil — exactly the
|
||||
// deployed condition), List nil-derefs → the iamStore guard returns a typed 503
|
||||
// → the listProjects handler used to re-stamp it as a 500 that broke
|
||||
// console.hanzo.ai dashboard init. The dashboard's first authenticated read must
|
||||
// instead degrade to an empty project set (a new org genuinely has zero).
|
||||
// TestListProjects_NilIAMStore_ServesEmpty200 reproduces the live console-init 500
|
||||
// and pins the fix. With the REAL iamProjects store and a co-resident IAM store that
|
||||
// is NOT initialized in-process (clients/iam.DB() == nil — exactly the deployed
|
||||
// condition before "iam" is enabled), List returns the iamStore 503 → the
|
||||
// listProjects handler used to re-stamp it as a 500 that broke console.hanzo.ai
|
||||
// dashboard init. The dashboard's first authenticated read must instead degrade to an
|
||||
// empty project set (a new org genuinely has zero).
|
||||
func TestListProjects_NilIAMStore_ServesEmpty200(t *testing.T) {
|
||||
app, s := mountSvcK8s(t, &k8sClient{initErr: "no cluster (test)", limits: testLimits()})
|
||||
s.State.projects = iamProjects{} // real store; the in-process IAM engine is nil here
|
||||
s.State.projects = iamProjects{} // real store; the in-process IAM db is nil here
|
||||
|
||||
code, body := do(t, app, http.MethodGet, "/v1/platform/projects", "maxpower", nil)
|
||||
if code != http.StatusOK {
|
||||
@@ -75,11 +102,11 @@ func TestListProjects_NilIAMStore_ServesEmpty200(t *testing.T) {
|
||||
// store outage sitting behind the dashboard's first read.
|
||||
type errProjects struct{ err error }
|
||||
|
||||
func (e errProjects) List(context.Context, string) ([]*iamobj.Project, error) { return nil, e.err }
|
||||
func (errProjects) Get(context.Context, string, string) (*iamobj.Project, error) {
|
||||
func (e errProjects) List(context.Context, string) ([]*model.Project, error) { return nil, e.err }
|
||||
func (errProjects) Get(context.Context, string, string) (*model.Project, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (errProjects) Create(context.Context, string, string, string, string) (*iamobj.Project, error) {
|
||||
func (errProjects) Create(context.Context, string, string, string, string) (*model.Project, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (errProjects) Delete(context.Context, string, string) (bool, error) { return false, nil }
|
||||
|
||||
@@ -7,32 +7,32 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
iamobj "github.com/hanzoai/iam-v1/object"
|
||||
model "github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// fakeProjects is an in-memory, org-scoped ProjectStore standing in for the
|
||||
// embedded IAM object store in tests. It proves the project lifecycle is
|
||||
// DELEGATED (platform owns no project rows) and records create/delete calls so a
|
||||
// test can assert the delegation actually happened. It returns IAM's canonical
|
||||
// *object.Project — the SAME type the production adapter returns — so no
|
||||
// embedded IAM store in tests. It proves the project lifecycle is DELEGATED
|
||||
// (platform owns no project rows) and records create/delete calls so a test can
|
||||
// assert the delegation actually happened. It returns IAM's canonical
|
||||
// *model.Project — the SAME type the production adapter returns — so no
|
||||
// platform-local project model is introduced anywhere.
|
||||
type fakeProjects struct {
|
||||
mu sync.Mutex
|
||||
byKey map[string]*iamobj.Project // "<org>/<name>"
|
||||
byKey map[string]*model.Project // "<org>/<name>"
|
||||
creates []string
|
||||
deletes []string
|
||||
}
|
||||
|
||||
func newFakeProjects() *fakeProjects {
|
||||
return &fakeProjects{byKey: map[string]*iamobj.Project{}}
|
||||
return &fakeProjects{byKey: map[string]*model.Project{}}
|
||||
}
|
||||
|
||||
func (f *fakeProjects) key(org, name string) string { return org + "/" + name }
|
||||
|
||||
func (f *fakeProjects) List(_ context.Context, org string) ([]*iamobj.Project, error) {
|
||||
func (f *fakeProjects) List(_ context.Context, org string) ([]*model.Project, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := []*iamobj.Project{}
|
||||
out := []*model.Project{}
|
||||
for _, p := range f.byKey {
|
||||
if p.Owner == org {
|
||||
out = append(out, p)
|
||||
@@ -41,19 +41,19 @@ func (f *fakeProjects) List(_ context.Context, org string) ([]*iamobj.Project, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeProjects) Get(_ context.Context, org, name string) (*iamobj.Project, error) {
|
||||
func (f *fakeProjects) Get(_ context.Context, org, name string) (*model.Project, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.byKey[f.key(org, name)], nil
|
||||
}
|
||||
|
||||
func (f *fakeProjects) Create(_ context.Context, org, name, display, description string) (*iamobj.Project, error) {
|
||||
func (f *fakeProjects) Create(_ context.Context, org, name, display, description string) (*model.Project, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.byKey[f.key(org, name)]; ok {
|
||||
return nil, errConflict
|
||||
}
|
||||
p := &iamobj.Project{
|
||||
p := &model.Project{
|
||||
Owner: org, Name: name, Organization: org, DisplayName: display,
|
||||
Description: description, CreatedTime: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ read/written by **hanzo.app** (the builder) and **console.hanzo.ai** (the
|
||||
Projects module). Both call this surface through the gateway; there is no second
|
||||
copy of project state.
|
||||
|
||||
Owner: `hanzoai/cloud` → `clients/projectsvc`. Mounted into the unified cloud
|
||||
Owner: `hanzoai/cloud` → `clients/projects`. Mounted into the unified cloud
|
||||
binary (HIP-0106), reachable at `https://api.hanzo.ai/v1/projects`.
|
||||
|
||||
## Auth & tenancy (HIP-0111)
|
||||
@@ -71,6 +71,7 @@ type Deployment = {
|
||||
| `PATCH` | `/v1/projects/:slug` | `UpdateProject` | `200 Project` |
|
||||
| `DELETE` | `/v1/projects/:slug` | — | `204` (also purges the live S3 site) |
|
||||
| `POST` | `/v1/projects/:slug/deploy` | artifact **or** `GitDeploy` | `200 Deployment` (upload) / `202 Deployment` (git) |
|
||||
| `POST` | `/v1/projects/:slug/purge` | — | `200 Project` (flush the edge cache-tag; no redeploy) |
|
||||
| `GET` | `/v1/projects/:slug/deployments` | — | `200 Deployment[]` (newest version first) |
|
||||
| `GET` | `/v1/projects/:slug/deployments/:id` | — | `200 Deployment` / `404` |
|
||||
| `POST` | `/v1/projects/:slug/deployments/:id/complete` | `Complete` | `200 Deployment` (CI hook, git path) |
|
||||
@@ -119,6 +120,15 @@ or `https://<sites-host>/<org>/<slug>/` when the `hanzoai/static` container
|
||||
(the static-app image) is configured to serve the bucket behind the gateway.
|
||||
GitHub export is an optional, separate step — going live never requires it.
|
||||
|
||||
## Edge cache purge (no redeploy)
|
||||
|
||||
`POST /v1/projects/:slug/purge` flushes the project's edge cache-tag
|
||||
(`site-<org>-<slug>` — the SAME tag deploy purges) so already-published content is
|
||||
re-fetched from the S3 origin at the edge. It **never** writes or deletes the S3
|
||||
origin: the live build keeps serving; only stale edge copies drop. It stamps
|
||||
`lastPurgeAt` and returns the updated `Project`. An unconfigured/failing edge (CF)
|
||||
is non-fatal — the purge is a no-op and still returns `200`.
|
||||
|
||||
## Console module notes
|
||||
|
||||
- List view → `GET /v1/projects`. Status badge from `status`; "Open" links to
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSetProjectDefaults pins the pure default-application logic — the ONE place
|
||||
// a new project's wired-by-default settings are decided: analytics ON unless the
|
||||
// caller opted out, and the Base data-space namespace "<org>/<slug>".
|
||||
func TestSetProjectDefaults(t *testing.T) {
|
||||
f := false
|
||||
tr := true
|
||||
cases := []struct {
|
||||
name string
|
||||
optOut *bool
|
||||
wantAnal bool
|
||||
}{
|
||||
{"absent defaults ON", nil, true},
|
||||
{"explicit true stays ON", &tr, true},
|
||||
{"explicit false opts OUT", &f, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := Project{Org: "acme", Slug: "landing"}
|
||||
setProjectDefaults(&p, tc.optOut)
|
||||
if p.Analytics != tc.wantAnal {
|
||||
t.Fatalf("analytics=%v want %v", p.Analytics, tc.wantAnal)
|
||||
}
|
||||
if p.SpaceId != "acme/landing" {
|
||||
t.Fatalf("space=%q want acme/landing", p.SpaceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProject_AnalyticsDefaultOn is the wire proof that a freshly created
|
||||
// project is analytics-ON and space-wired with NO opt-in: POST /v1/projects with
|
||||
// just a name returns a project whose analytics is true and whose Base data space
|
||||
// is "<org>/<slug>". The default base embed is OFF, so this also proves the
|
||||
// fail-soft path — space provisioning returns ErrNotEmbedded yet create is 201.
|
||||
func TestCreateProject_AnalyticsDefaultOn(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme", map[string]any{"name": "Landing"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var p projectView
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
t.Fatalf("json: %v (%s)", err, body)
|
||||
}
|
||||
if !p.Analytics {
|
||||
t.Fatalf("analytics want true by default, got false")
|
||||
}
|
||||
if p.Space != "acme/"+p.Slug {
|
||||
t.Fatalf("space want acme/%s, got %q", p.Slug, p.Space)
|
||||
}
|
||||
// The default persists: a fresh GET reports the same wired defaults.
|
||||
_, gb := do(t, app, http.MethodGet, "/v1/projects/"+p.Slug, "acme", nil)
|
||||
var got projectView
|
||||
_ = json.Unmarshal(gb, &got)
|
||||
if !got.Analytics || got.Space != p.Space {
|
||||
t.Fatalf("persisted defaults drift: analytics=%v space=%q", got.Analytics, got.Space)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProject_AnalyticsOptOut proves the escape hatch: analytics:false at
|
||||
// create opts the project out. Default-ON, but overridable.
|
||||
func TestCreateProject_AnalyticsOptOut(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme",
|
||||
map[string]any{"name": "Private", "analytics": false})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var p projectView
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
t.Fatalf("json: %v (%s)", err, body)
|
||||
}
|
||||
if p.Analytics {
|
||||
t.Fatalf("analytics want false after opt-out, got true")
|
||||
}
|
||||
if p.Space != "acme/"+p.Slug {
|
||||
t.Fatalf("space still wired: want acme/%s, got %q", p.Slug, p.Space)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProject_ProvisionsSpace proves the Base data space is provisioned on
|
||||
// create through the ONE default-application path: the wired provisioner is
|
||||
// called exactly once with the project's org, and the persisted project carries
|
||||
// its space namespace.
|
||||
func TestCreateProject_ProvisionsSpace(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
var gotOrg string
|
||||
calls := 0
|
||||
mounted.State.ensureSpace = func(_ context.Context, org string) error {
|
||||
calls++
|
||||
gotOrg = org
|
||||
return nil
|
||||
}
|
||||
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme", map[string]any{"name": "Shop"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create want 201, got %d (%s)", code, body)
|
||||
}
|
||||
if calls != 1 || gotOrg != "acme" {
|
||||
t.Fatalf("ensureSpace calls=%d org=%q want 1/acme", calls, gotOrg)
|
||||
}
|
||||
var p projectView
|
||||
_ = json.Unmarshal(body, &p)
|
||||
if p.Space != "acme/"+p.Slug {
|
||||
t.Fatalf("space want acme/%s, got %q", p.Slug, p.Space)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProject_ProvisionFailureIsFailSoft locks the graceful-degradation
|
||||
// contract: a Base provisioning error must NOT fail project creation. The project
|
||||
// is still created (201) and persisted with its wired defaults; the side effect
|
||||
// is logged and swallowed, exactly like the edge cache purge.
|
||||
func TestCreateProject_ProvisionFailureIsFailSoft(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
mounted.State.ensureSpace = func(_ context.Context, _ string) error {
|
||||
return errors.New("base down")
|
||||
}
|
||||
code, body := do(t, app, http.MethodPost, "/v1/projects", "acme", map[string]any{"name": "Resilient"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("provisioning failure must not fail create: want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var p projectView
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
t.Fatalf("json: %v (%s)", err, body)
|
||||
}
|
||||
if !p.Analytics || p.Space == "" {
|
||||
t.Fatalf("defaults must still apply on fail-soft: analytics=%v space=%q", p.Analytics, p.Space)
|
||||
}
|
||||
// The project really persisted despite the provisioning error.
|
||||
if gc, _ := do(t, app, http.MethodGet, "/v1/projects/"+p.Slug, "acme", nil); gc != http.StatusOK {
|
||||
t.Fatalf("project must persist on fail-soft, GET got %d", gc)
|
||||
}
|
||||
}
|
||||
+60
-14
@@ -30,30 +30,76 @@ import (
|
||||
// which has always asserted bare-host first-come.
|
||||
func siteHost(org, slug string) string { return slug }
|
||||
|
||||
// onPublish runs the go-live side effects for a project whose new build just
|
||||
// landed at its S3 prefix: it claims the org-scoped public host (first-come per
|
||||
// (org,slug), idempotent for the owner) and purges the Cloudflare edge by
|
||||
// cache-tag so the new publish is instantly live at the edge. Both are
|
||||
// best-effort — an unconfigured/failing CF token must NOT fail the deploy (the
|
||||
// site is already live at its S3 URL). It stamps LastPurgeAt on the project (the
|
||||
// caller persists it in the same UpdateProject that flips status to live).
|
||||
// onPublish runs the go-live side effects after a deployment's build lands at the
|
||||
// project's S3 origin prefix: it claims the org-scoped public host (first-come per
|
||||
// (org,slug), idempotent for the owner) and purges the edge cache-tag so the new
|
||||
// build serves at the edge immediately. Both are best-effort — a taken/reserved
|
||||
// host or an unconfigured/failing edge purge must NOT fail the deploy; the project
|
||||
// still serves from its S3 origin. purgeEdge stamps LastPurgeAt; the caller
|
||||
// persists it in the same UpdateProject that flips status to live.
|
||||
func onPublish(s *cloud.Service[state], ctx context.Context, org string, p *Project) {
|
||||
now := time.Now().Unix()
|
||||
host := siteHost(org, p.Slug)
|
||||
if err := s.State.store.BindHost(ctx, host, org, p.Slug, now); err != nil {
|
||||
if err := s.State.store.BindHost(ctx, host, org, p.Slug, time.Now().Unix()); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, errHostTaken):
|
||||
s.Log.Warn("subdomain already claimed by another project (serving at S3 URL only)", "org", org, "slug", p.Slug, "host", host)
|
||||
s.Log.Warn("subdomain already claimed by another project (serving from S3 origin only)", "org", org, "slug", p.Slug, "host", host)
|
||||
case errors.Is(err, errReservedHost):
|
||||
s.Log.Warn("subdomain is a reserved label; not bound (serving at S3 URL only)", "org", org, "slug", p.Slug, "host", host)
|
||||
s.Log.Warn("subdomain is a reserved label; not bound (serving from S3 origin only)", "org", org, "slug", p.Slug, "host", host)
|
||||
default:
|
||||
s.Log.Warn("bind host failed (continuing)", "org", org, "slug", p.Slug, "host", host, "err", err)
|
||||
}
|
||||
}
|
||||
if err := s.State.cf.PurgeTags(ctx, sites.CacheTag(org, p.Slug)); err != nil {
|
||||
s.Log.Warn("cloudflare purge failed (continuing)", "org", org, "slug", p.Slug, "err", err)
|
||||
purgeEdge(s, ctx, org, p)
|
||||
}
|
||||
|
||||
// purgeTag flushes the edge cache-tag site-<org>-<slug> for a project's site. It is
|
||||
// the ONE place that derives the cache-tag and issues the purge, so every purge
|
||||
// site — deploy (onPublish), domain-bind (setDomains), delete (del), and the
|
||||
// dedicated POST /v1/projects/:slug/purge — shares one tag and one failure policy.
|
||||
// Best-effort by construction: PurgeTags is a warn-only no-op when the edge (CF) is
|
||||
// unconfigured, and a purge miss is logged, never fatal — the S3 origin keeps
|
||||
// serving and the edge self-heals when the short HTML TTL lapses.
|
||||
func purgeTag(s *cloud.Service[state], ctx context.Context, org, slug string) {
|
||||
if err := s.State.cf.PurgeTags(ctx, sites.CacheTag(org, slug)); err != nil {
|
||||
s.Log.Warn("edge cache-tag purge failed (continuing)", "org", org, "slug", slug, "err", err)
|
||||
}
|
||||
p.LastPurgeAt = now
|
||||
}
|
||||
|
||||
// purgeEdge purges the project's edge cache-tag and stamps LastPurgeAt = now. It is
|
||||
// the shared core of the two content-freshness paths — the go-live side effects
|
||||
// (onPublish) and the dedicated purge handler — so purge + stamp happen ONE way. It
|
||||
// does NOT persist: the caller writes p in its own UpdateProject.
|
||||
func purgeEdge(s *cloud.Service[state], ctx context.Context, org string, p *Project) {
|
||||
purgeTag(s, ctx, org, p.Slug)
|
||||
p.LastPurgeAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
// purge is POST /v1/projects/:slug/purge: a first-class edge cache purge with NO
|
||||
// redeploy. It flushes the project's edge cache-tag site-<org>-<slug> and stamps
|
||||
// LastPurgeAt, but NEVER writes or deletes the S3 origin — the live build keeps
|
||||
// serving from S3; only stale edge copies drop. Org-scoped exactly like deploy: the
|
||||
// tenant is the gateway-minted X-Org-Id (403 without one); an unknown (org,slug) is
|
||||
// 404. An edge purge miss (unconfigured/failing CF token) is non-fatal — LastPurgeAt
|
||||
// is still stamped and the response is 200. Returns the updated Project view so the
|
||||
// caller sees the new lastPurgeAt.
|
||||
func purge(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.State.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
purgeEdge(s, c.Context(), org, &p)
|
||||
p.UpdatedAt = time.Now().Unix()
|
||||
if err := s.State.store.UpdateProject(c.Context(), p); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toProjectView(p))
|
||||
}
|
||||
|
||||
// siteURL is the canonical public URL of a deployed site: the pretty bare host
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -102,10 +101,9 @@ func setDomains(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
bound = append(bound, host)
|
||||
}
|
||||
// Purge the edge so the newly-bound host serves the current content immediately.
|
||||
if err := s.State.cf.PurgeTags(c.Context(), sites.CacheTag(org, p.Slug)); err != nil {
|
||||
s.Log.Warn("cloudflare purge failed after domain bind (continuing)", "org", org, "slug", p.Slug, "err", err)
|
||||
}
|
||||
// Purge the edge cache-tag so the newly-bound host serves the current build
|
||||
// from the edge immediately.
|
||||
purgeTag(s, c.Context(), org, p.Slug)
|
||||
hosts, _ := s.State.store.ListHostsForProject(c.Context(), org, p.Slug)
|
||||
return c.JSON(http.StatusOK, map[string]any{"slug": p.Slug, "org": org, "bound": bound, "domains": hosts})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// PATCH /v1/projects/:slug update
|
||||
// DELETE /v1/projects/:slug delete (+ purge S3 site)
|
||||
// POST /v1/projects/:slug/deploy deploy (tar body | git json)
|
||||
// POST /v1/projects/:slug/purge purge the edge cache-tag (no redeploy)
|
||||
// GET /v1/projects/:slug/deployments deploy history
|
||||
// GET /v1/projects/:slug/deployments/:id one deployment
|
||||
// POST /v1/projects/:slug/deployments/:id/complete CI completion hook
|
||||
@@ -37,6 +38,7 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -47,6 +49,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/base"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -96,6 +99,12 @@ type state struct {
|
||||
// canonical live URL of every deployed site is https://<slug>.<apex>, the pretty
|
||||
// host the sites edge (clients/sites) serves — never a raw S3 URL.
|
||||
apex string
|
||||
// ensureSpace provisions a NEW project's Base data space (its form/forum/data
|
||||
// submissions collection) so it accepts submissions at /v1/base out of the box.
|
||||
// Wired at Mount to clients/base.EnsureSpace; overridable in tests. Best-effort:
|
||||
// see provisionSpace — a failure NEVER fails project creation. nil disables the
|
||||
// side effect entirely (space is provisioned lazily on first real use).
|
||||
ensureSpace func(ctx context.Context, org string) error
|
||||
}
|
||||
|
||||
// mounted is the active service so Shutdown can release the store. The unified
|
||||
@@ -126,8 +135,14 @@ type projectView struct {
|
||||
// in effect (TTL) and the last edge-purge time, so a console can show freshness.
|
||||
CacheControl string `json:"cacheControl,omitempty"`
|
||||
LastPurgeAt int64 `json:"lastPurgeAt,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
// Analytics is the wired-by-default web-analytics flag (default true). It is the
|
||||
// value the app's static-builder reads as deployment.analytics to inject the
|
||||
// beacon. Space is the project's Base data space ("<org>/<slug>") a deployed
|
||||
// site posts form/forum/data submissions to under /v1/base.
|
||||
Analytics bool `json:"analytics"`
|
||||
Space string `json:"space,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func toProjectView(p Project) projectView {
|
||||
@@ -136,6 +151,7 @@ func toProjectView(p Project) projectView {
|
||||
Repo: repoView{URL: p.RepoURL, Branch: p.RepoBranch, Provider: p.RepoProvider},
|
||||
Framework: p.Framework, Status: p.Status, LiveURL: p.LiveURL, Bucket: p.Bucket,
|
||||
CurrentDeploymentID: p.CurrentDeploy, CacheControl: p.CacheControl, LastPurgeAt: p.LastPurgeAt,
|
||||
Analytics: p.Analytics, Space: p.SpaceId,
|
||||
CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -195,6 +211,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
ai: deps.AI, // may be nil (no gateway) — buildSite degrades to 503.
|
||||
bill: cloud.NewResourceMeter(deps, hostingProvider),
|
||||
apex: env("CLOUD_SITES_APEX", "hanzo.app"), // the pretty <slug>.<apex> the sites edge serves.
|
||||
ensureSpace: base.EnsureSpace, // wired-by-default Base data space (fail-soft).
|
||||
}}
|
||||
mounted = s
|
||||
|
||||
@@ -228,6 +245,7 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Delete("/v1/projects/:slug", cloud.Handle(s, del))
|
||||
|
||||
app.Post("/v1/projects/:slug/deploy", cloud.Handle(s, deploy))
|
||||
app.Post("/v1/projects/:slug/purge", cloud.Handle(s, purge))
|
||||
app.Get("/v1/projects/:slug/deployments", cloud.Handle(s, listDeployments))
|
||||
app.Get("/v1/projects/:slug/deployments/:id", cloud.Handle(s, getDeployment))
|
||||
app.Post("/v1/projects/:slug/deployments/:id/complete", cloud.Handle(s, completeDeployment))
|
||||
@@ -257,6 +275,7 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Patch("/v1/platform/sites/:slug", cloud.Handle(s, update))
|
||||
app.Delete("/v1/platform/sites/:slug", cloud.Handle(s, del))
|
||||
app.Post("/v1/platform/sites/:slug/deploy", cloud.Handle(s, deploy))
|
||||
app.Post("/v1/platform/sites/:slug/purge", cloud.Handle(s, purge))
|
||||
app.Get("/v1/platform/sites/:slug/deployments", cloud.Handle(s, listDeployments))
|
||||
app.Get("/v1/platform/sites/:slug/deployments/:id", cloud.Handle(s, getDeployment))
|
||||
app.Get("/v1/platform/sites/:slug/domains", cloud.Handle(s, listDomains))
|
||||
@@ -274,6 +293,10 @@ type createReq struct {
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
} `json:"repo"`
|
||||
// Analytics is the opt-OUT for the wired-by-default analytics beacon: absent
|
||||
// (nil) ⇒ ON (the default); explicit false ⇒ off. A pointer so "unset" is
|
||||
// distinguishable from "false" — the only way to turn the default off.
|
||||
Analytics *bool `json:"analytics"`
|
||||
}
|
||||
|
||||
func create(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -333,15 +356,55 @@ func createProject(s *cloud.Service[state], c *zip.Ctx, org string, body createR
|
||||
if p.RepoBranch == "" && p.RepoURL != "" {
|
||||
p.RepoBranch = "main"
|
||||
}
|
||||
// The ONE place every create path (POST /v1/projects, /v1/projects/fork,
|
||||
// /v1/sites) applies the wired-by-default subsystems: analytics ON unless the
|
||||
// caller opted out, and the project's Base data-space namespace. Pure, so the
|
||||
// defaults are set deterministically before persist.
|
||||
setProjectDefaults(&p, body.Analytics)
|
||||
if err := s.State.store.CreateProject(c.Context(), p); err != nil {
|
||||
if errors.Is(err, errConflict) {
|
||||
return zip.ErrConflict("project slug already exists in this org")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
// Best-effort provision the Base data space (form/forum/data submissions). Runs
|
||||
// only after a successful persist so a conflicting create provisions nothing;
|
||||
// a Base hiccup is logged and swallowed — it never fails the create.
|
||||
provisionSpace(s, c.Context(), &p)
|
||||
return c.JSON(http.StatusCreated, toProjectView(p))
|
||||
}
|
||||
|
||||
// setProjectDefaults applies the wired-by-default project settings to a NEW
|
||||
// project: analytics ON unless the caller opted out (analytics:false), and the
|
||||
// Base data-space namespace ("<org>/<slug>" — the app's namespace/repoId
|
||||
// convention, same layout as the S3 sitePrefix). Pure (no I/O), so it is the ONE
|
||||
// deterministic place defaults are decided; every create path funnels through it
|
||||
// via createProject. Default-ON but overridable: a nil analytics ⇒ ON, an
|
||||
// explicit false ⇒ off.
|
||||
func setProjectDefaults(p *Project, analytics *bool) {
|
||||
p.Analytics = analytics == nil || *analytics
|
||||
p.SpaceId = sitePrefix(p.Org, p.Slug)
|
||||
}
|
||||
|
||||
// provisionSpace best-effort-provisions a new project's Base data space (the
|
||||
// submissions collection its deployed site POSTs form/forum/data to). It is
|
||||
// FAIL-SOFT by construction: a disabled embed (ErrNotEmbedded) or any transient
|
||||
// Base error is logged and swallowed, so it can NEVER fail project creation — the
|
||||
// same graceful-degradation policy as the edge cache purge (onPublish). The space
|
||||
// is idempotent and org-level, so a later deploy or first submission re-ensures it.
|
||||
func provisionSpace(s *cloud.Service[state], ctx context.Context, p *Project) {
|
||||
if s.State.ensureSpace == nil {
|
||||
return
|
||||
}
|
||||
if err := s.State.ensureSpace(ctx, p.Org); err != nil {
|
||||
if errors.Is(err, base.ErrNotEmbedded) {
|
||||
s.Log.Info("base embed disabled; project data space deferred (set CLOUD_BASE_EMBED=1)", "org", p.Org, "space", p.SpaceId)
|
||||
return
|
||||
}
|
||||
s.Log.Warn("provision base space failed (continuing)", "org", p.Org, "space", p.SpaceId, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func list(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := org(c)
|
||||
if !ok {
|
||||
@@ -471,10 +534,9 @@ func del(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Purge the Cloudflare edge so the deleted site stops serving from cache.
|
||||
if pErr := s.State.cf.PurgeTags(c.Context(), sites.CacheTag(org, p.Slug)); pErr != nil {
|
||||
s.Log.Warn("cloudflare purge failed on delete (continuing)", "org", org, "slug", p.Slug, "err", pErr)
|
||||
}
|
||||
// Purge the edge cache-tag so the deleted project stops serving stale copies
|
||||
// from the edge; its metadata and S3 origin are already gone.
|
||||
purgeTag(s, c.Context(), org, p.Slug)
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package projects
|
||||
|
||||
// Tests for POST /v1/projects/:slug/purge — the dedicated edge cache purge that
|
||||
// flushes a project's edge cache-tag WITHOUT a redeploy. They prove the contract:
|
||||
// org-scoped (403 no principal, 404 wrong org / unknown slug), stamps LastPurgeAt,
|
||||
// 200 even when the edge (CF) is unconfigured, and — critically — the S3 origin is
|
||||
// never written or deleted (only the edge is flushed). Driven over HTTP through the
|
||||
// REAL Mount + zip stack against the in-memory S3 double, exactly like the /v1/sites
|
||||
// tests; CF is unconfigured in this harness (no CF_API_TOKEN/CF_ZONE_ID), so the
|
||||
// purge is a warn-only no-op that must still succeed.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPurge_StampsLastPurgeAt_DraftProject: a never-deployed project (LastPurgeAt
|
||||
// == 0) purges to 200 and returns a non-zero lastPurgeAt. CF is unconfigured, so
|
||||
// this also proves an unconfigured edge purge is non-fatal.
|
||||
func TestPurge_StampsLastPurgeAt_DraftProject(t *testing.T) {
|
||||
startFakeS3(t)
|
||||
bs := &billServer{available: 1000000}
|
||||
app := mountSites(t, &fakeAI{content: okManifest()}, bs.start(t))
|
||||
|
||||
if code, _ := doSite(t, app, http.MethodPost, "/v1/projects", "acme",
|
||||
map[string]any{"name": "Draft Flush", "slug": "draftflush"}); code != http.StatusCreated {
|
||||
t.Fatalf("create want 201, got %d", code)
|
||||
}
|
||||
|
||||
code, body := doSite(t, app, http.MethodPost, "/v1/projects/draftflush/purge", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("purge want 200 (unconfigured edge is non-fatal), got %d (%s)", code, body)
|
||||
}
|
||||
var pv struct {
|
||||
Slug string `json:"slug"`
|
||||
LastPurgeAt int64 `json:"lastPurgeAt"`
|
||||
}
|
||||
mustJSON(t, body, &pv)
|
||||
if pv.Slug != "draftflush" || pv.LastPurgeAt <= 0 {
|
||||
t.Fatalf("purge must stamp lastPurgeAt: %+v", pv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurge_S3OriginUntouched: purging a LIVE deployed project flushes the edge but
|
||||
// never writes or deletes the S3 origin — the exact stored bytes and the object
|
||||
// count under the site prefix are unchanged, and no new PUT is issued.
|
||||
func TestPurge_S3OriginUntouched(t *testing.T) {
|
||||
f := startFakeS3(t)
|
||||
bs := &billServer{available: 1000000}
|
||||
app := mountSites(t, &fakeAI{content: okManifest()}, bs.start(t))
|
||||
|
||||
if url := deployVia(t, app, "acme", "flushme"); url != "https://flushme.hanzo.app" {
|
||||
t.Fatalf("deploy url=%q", url)
|
||||
}
|
||||
const prefix = "hanzo-sites/acme/flushme/"
|
||||
putsBefore := f.puts
|
||||
countBefore := f.count(prefix)
|
||||
f.mu.Lock()
|
||||
indexBefore := string(f.objects[prefix+"index.html"])
|
||||
f.mu.Unlock()
|
||||
if countBefore == 0 || indexBefore == "" {
|
||||
t.Fatalf("precondition: site not written to S3 (count=%d)", countBefore)
|
||||
}
|
||||
|
||||
code, body := doSite(t, app, http.MethodPost, "/v1/projects/flushme/purge", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("purge want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var pv struct {
|
||||
LastPurgeAt int64 `json:"lastPurgeAt"`
|
||||
}
|
||||
mustJSON(t, body, &pv)
|
||||
if pv.LastPurgeAt <= 0 {
|
||||
t.Fatalf("purge must stamp lastPurgeAt, got %d", pv.LastPurgeAt)
|
||||
}
|
||||
|
||||
if f.puts != putsBefore {
|
||||
t.Fatalf("purge must not write S3: puts %d → %d", putsBefore, f.puts)
|
||||
}
|
||||
if got := f.count(prefix); got != countBefore {
|
||||
t.Fatalf("purge must not add/remove S3 objects: count %d → %d", countBefore, got)
|
||||
}
|
||||
f.mu.Lock()
|
||||
indexAfter := string(f.objects[prefix+"index.html"])
|
||||
f.mu.Unlock()
|
||||
if indexAfter != indexBefore {
|
||||
t.Fatal("purge must not modify the S3 origin object")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurge_OrgScoped: no principal → 403; a different org or an unknown slug → 404.
|
||||
// The tenant boundary is the gateway-minted X-Org-Id, exactly like every other
|
||||
// projects route.
|
||||
func TestPurge_OrgScoped(t *testing.T) {
|
||||
startFakeS3(t)
|
||||
bs := &billServer{available: 1000000}
|
||||
app := mountSites(t, &fakeAI{content: okManifest()}, bs.start(t))
|
||||
|
||||
deployVia(t, app, "acme", "flushme") // owned by acme
|
||||
|
||||
if code, _ := doSite(t, app, http.MethodPost, "/v1/projects/flushme/purge", "", nil); code != http.StatusForbidden {
|
||||
t.Fatalf("no-principal purge want 403, got %d", code)
|
||||
}
|
||||
if code, _ := doSite(t, app, http.MethodPost, "/v1/projects/flushme/purge", "other", nil); code != http.StatusNotFound {
|
||||
t.Fatalf("wrong-org purge want 404, got %d", code)
|
||||
}
|
||||
if code, _ := doSite(t, app, http.MethodPost, "/v1/projects/nope/purge", "acme", nil); code != http.StatusNotFound {
|
||||
t.Fatalf("unknown-slug purge want 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -483,11 +483,16 @@ func ensureProject(s *cloud.Service[state], ctx context.Context, org, slug, name
|
||||
ID: id, Org: org, Slug: slug, Name: name, Framework: "static",
|
||||
Status: "draft", Bucket: s.State.blob.bucket, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
// Same wired-by-default settings as POST /v1/projects — analytics ON and the
|
||||
// Base data-space namespace — so the /v1/sites create path is not a second
|
||||
// place defaults are decided. A generated site has no opt-out knob (nil ⇒ ON).
|
||||
setProjectDefaults(&np, nil)
|
||||
if err := s.State.store.CreateProject(ctx, np); err != nil {
|
||||
if errors.Is(err, errConflict) {
|
||||
return s.State.store.GetProject(ctx, org, slug)
|
||||
}
|
||||
return Project{}, zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
provisionSpace(s, ctx, &np)
|
||||
return np, nil
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
"github.com/hanzoai/cloud/clients/sites"
|
||||
|
||||
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver: it registers
|
||||
// the "sqlite" database/sql name under both build tags (cgo →
|
||||
@@ -70,6 +70,18 @@ type Project struct {
|
||||
LastPurgeAt int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
// Analytics is the per-project web-analytics flag, wired ON by default: a
|
||||
// freshly created project collects analytics unless the caller opts out
|
||||
// (analytics:false at create). It is the source of truth the app's
|
||||
// static-builder reads as deployment.analytics, so the beacon is injected with
|
||||
// no opt-in. Mutable via update (read-modify-write); immutable columns are
|
||||
// org/slug/id/created_at.
|
||||
Analytics bool
|
||||
// SpaceId is the project's Base data space — the "<org>/<slug>" namespace under
|
||||
// which its deployed site's form/forum/data submissions live in Hanzo Base
|
||||
// (/v1/base). Set once at create (the app's namespace/repoId convention);
|
||||
// immutable thereafter. A Base space is provisioned best-effort at create.
|
||||
SpaceId string
|
||||
}
|
||||
|
||||
// Deployment is one deploy attempt for a project, versioned monotonically per
|
||||
@@ -142,7 +154,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
bucket TEXT NOT NULL DEFAULT '',
|
||||
current_deploy TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
updated_at INTEGER NOT NULL,
|
||||
analytics INTEGER NOT NULL DEFAULT 1,
|
||||
space_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_projects_org_slug ON projects(org, slug);
|
||||
CREATE INDEX IF NOT EXISTS ix_projects_org_updated ON projects(org, updated_at);
|
||||
@@ -191,6 +205,11 @@ CREATE INDEX IF NOT EXISTS ix_site_hosts_org_slug ON site_hosts(org, slug);
|
||||
for _, alter := range []string{
|
||||
`ALTER TABLE projects ADD COLUMN cache_control TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN last_purge_at INTEGER NOT NULL DEFAULT 0`,
|
||||
// analytics is wired ON by default (DEFAULT 1), so existing projects that
|
||||
// predate the column also collect analytics — "wired by default" applies to
|
||||
// them too. space_id backfills empty; the deploy/serve paths re-derive it.
|
||||
`ALTER TABLE projects ADD COLUMN analytics INTEGER NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE projects ADD COLUMN space_id TEXT NOT NULL DEFAULT ''`,
|
||||
} {
|
||||
if _, err := s.db.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return fmt.Errorf("migrate alter: %w", err)
|
||||
@@ -202,14 +221,15 @@ CREATE INDEX IF NOT EXISTS ix_site_hosts_org_slug ON site_hosts(org, slug);
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
const projectCols = `id,org,slug,name,description,repo_url,repo_branch,repo_provider,framework,status,live_url,bucket,current_deploy,cache_control,last_purge_at,created_at,updated_at`
|
||||
const projectCols = `id,org,slug,name,description,repo_url,repo_branch,repo_provider,framework,status,live_url,bucket,current_deploy,cache_control,last_purge_at,created_at,updated_at,analytics,space_id`
|
||||
|
||||
func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
|
||||
var p Project
|
||||
err := sc.Scan(&p.ID, &p.Org, &p.Slug, &p.Name, &p.Description,
|
||||
&p.RepoURL, &p.RepoBranch, &p.RepoProvider, &p.Framework,
|
||||
&p.Status, &p.LiveURL, &p.Bucket, &p.CurrentDeploy,
|
||||
&p.CacheControl, &p.LastPurgeAt, &p.CreatedAt, &p.UpdatedAt)
|
||||
&p.CacheControl, &p.LastPurgeAt, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.Analytics, &p.SpaceId)
|
||||
return p, err
|
||||
}
|
||||
|
||||
@@ -217,11 +237,12 @@ func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
|
||||
// errConflict.
|
||||
func (s *Store) CreateProject(ctx context.Context, p Project) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO projects (`+projectCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
`INSERT INTO projects (`+projectCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Org, p.Slug, p.Name, p.Description,
|
||||
p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
|
||||
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy,
|
||||
p.CacheControl, p.LastPurgeAt, p.CreatedAt, p.UpdatedAt)
|
||||
p.CacheControl, p.LastPurgeAt, p.CreatedAt, p.UpdatedAt,
|
||||
p.Analytics, p.SpaceId)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return errConflict
|
||||
@@ -268,10 +289,10 @@ func (s *Store) ListProjects(ctx context.Context, org string) ([]Project, error)
|
||||
// reads-modifies-writes the whole Project; org+slug+id+created_at are immutable.
|
||||
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE projects SET name=?,description=?,repo_url=?,repo_branch=?,repo_provider=?,framework=?,status=?,live_url=?,bucket=?,current_deploy=?,cache_control=?,last_purge_at=?,updated_at=?
|
||||
`UPDATE projects SET name=?,description=?,repo_url=?,repo_branch=?,repo_provider=?,framework=?,status=?,live_url=?,bucket=?,current_deploy=?,cache_control=?,last_purge_at=?,analytics=?,updated_at=?
|
||||
WHERE org=? AND slug=?`,
|
||||
p.Name, p.Description, p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
|
||||
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CacheControl, p.LastPurgeAt, p.UpdatedAt, p.Org, p.Slug)
|
||||
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CacheControl, p.LastPurgeAt, p.Analytics, p.UpdatedAt, p.Org, p.Slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package sites
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// captureHostHandler records the org + path every invocation is handed, so a test
|
||||
// can prove the site-host carve FORCES the tenant from the resolved Site (never the
|
||||
// caller/body) and routes only the intended POST paths. It is the analytics twin of
|
||||
// fakeResolver's recording discipline.
|
||||
type captureHostHandler struct {
|
||||
mu sync.Mutex
|
||||
orgs []string
|
||||
paths []string
|
||||
kind string // header value emitted so a test can see which carve fired
|
||||
}
|
||||
|
||||
func (h *captureHostHandler) handle(org string, c *zip.Ctx) error {
|
||||
h.mu.Lock()
|
||||
h.orgs = append(h.orgs, org)
|
||||
h.paths = append(h.paths, c.Path())
|
||||
h.mu.Unlock()
|
||||
c.SetHeader("X-Carve", h.kind)
|
||||
return c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func (h *captureHostHandler) seen() ([]string, []string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
o := append([]string(nil), h.orgs...)
|
||||
p := append([]string(nil), h.paths...)
|
||||
return o, p
|
||||
}
|
||||
|
||||
// beaconBody is a Segment/PostHog-shaped payload that ALSO carries a foreign org
|
||||
// claim (properties.space + a body org) — the values the carve must ignore in
|
||||
// favour of the resolved Site.Org.
|
||||
const beaconBody = `{"batch":[{"type":"pageview"}],"org":"attacker-org","properties":{"space":"attacker-org"}}`
|
||||
|
||||
func postReq(host, path, body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "http://"+host+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", "attacker-org") // forged identity header — must be ignored
|
||||
return req
|
||||
}
|
||||
|
||||
// TestIsAnalyticsPath pins the ingest-path predicate: the CANONICAL door /v1/event
|
||||
// and the deprecated beacon ingest paths match (the read lenses match the
|
||||
// /v1/analytics prefix too, but the Middleware carve gates on POST — they are GET on
|
||||
// api.hanzo.ai, never a site host); everything else on a site host falls through to
|
||||
// the static serve.
|
||||
func TestIsAnalyticsPath(t *testing.T) {
|
||||
yes := []string{"/v1/event", "/v1/analytics", "/v1/analytics/batch", "/v1/insights/e",
|
||||
"/v1/analytics/overview", "/v1/analytics/timeseries", "/v1/analytics/top"}
|
||||
for _, p := range yes {
|
||||
if !isAnalyticsPath(p) {
|
||||
t.Errorf("isAnalyticsPath(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
no := []string{"/v1/base", "/v1/base/collections", "/v1/tracker",
|
||||
"/v1/insights", "/v1/insights/events", "/v1/insights/health", "/", "/index.html"}
|
||||
for _, p := range no {
|
||||
if isAnalyticsPath(p) {
|
||||
t.Errorf("isAnalyticsPath(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareAnalyticsCarveSlugHost: a POST beacon to a LIVE slug host on each
|
||||
// ingest path routes to the analytics handler with the tenant = Site.Org — NOT the
|
||||
// attacker-org in the body/header — and never leaks into the API pipeline.
|
||||
func TestMiddlewareAnalyticsCarveSlugHost(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "hanzo", Slug: "yadota", Bucket: "b", Prefix: "hanzo/yadota", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
h := &captureHostHandler{kind: "analytics"}
|
||||
SetAnalyticsHostHandler(h.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
for _, p := range []string{"/v1/event", "/v1/analytics", "/v1/analytics/batch", "/v1/insights/e"} {
|
||||
resp, err := app.Fiber().Test(postReq("yadota.hanzo.app", p, beaconBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", p, err)
|
||||
}
|
||||
if resp.Header.Get("X-Carve") != "analytics" {
|
||||
t.Errorf("POST %s did not route to the analytics carve (X-Carve=%q)", p, resp.Header.Get("X-Carve"))
|
||||
}
|
||||
if resp.Header.Get("X-Sentinel") == "hit" {
|
||||
t.Errorf("POST %s leaked into the API pipeline", p)
|
||||
}
|
||||
}
|
||||
orgs, paths := h.seen()
|
||||
if len(orgs) != 4 {
|
||||
t.Fatalf("analytics handler invoked %d times, want 4 (%v)", len(orgs), paths)
|
||||
}
|
||||
for i, o := range orgs {
|
||||
if o != "hanzo" {
|
||||
t.Fatalf("ingest %d attributed to %q, want hanzo (host-derived, not the body/header claim)", i, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareAnalyticsCarveCustomDomain: the same forced-org carve fires for a
|
||||
// bound LIVE custom domain, resolved by its FULL host, tenant = Site.Org.
|
||||
func TestMiddlewareAnalyticsCarveCustomDomain(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "yadota", Slug: "yadota", Bucket: "b", Prefix: "yadota/yadota", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
h := &captureHostHandler{kind: "analytics"}
|
||||
SetAnalyticsHostHandler(h.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(selfServer())
|
||||
|
||||
resp, err := app.Fiber().Test(postReq("yadota.tech", "/v1/analytics", beaconBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Carve") != "analytics" || resp.Header.Get("X-Sentinel") == "hit" {
|
||||
t.Fatalf("custom-domain beacon did not route to the analytics carve (X-Carve=%q)", resp.Header.Get("X-Carve"))
|
||||
}
|
||||
orgs, _ := h.seen()
|
||||
if len(orgs) != 1 || orgs[0] != "yadota" {
|
||||
t.Fatalf("custom-domain ingest orgs = %v, want [yadota] (host-derived)", orgs)
|
||||
}
|
||||
if got := fr.slugs(); len(got) != 1 || got[0] != "yadota.tech" {
|
||||
t.Fatalf("resolver called with %v, want exactly [yadota.tech] (full host only)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareAnalyticsCarveGetServesStatic: a GET on an ingest path is NOT
|
||||
// hijacked — the POST gate lets it fall to the static serve (X-Hanzo-Site set,
|
||||
// carve never fired). This is what keeps the read-lens surface uninvolved.
|
||||
func TestMiddlewareAnalyticsCarveGetServesStatic(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "hanzo", Slug: "yadota", Bucket: "b", Prefix: "hanzo/yadota", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
h := &captureHostHandler{kind: "analytics"}
|
||||
SetAnalyticsHostHandler(h.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://yadota.hanzo.app/v1/analytics/overview", nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Carve") == "analytics" {
|
||||
t.Fatal("a GET must never route to the analytics ingest carve")
|
||||
}
|
||||
if resp.Header.Get("X-Hanzo-Site") != "yadota" {
|
||||
t.Errorf("GET did not reach the static serve (X-Hanzo-Site=%q)", resp.Header.Get("X-Hanzo-Site"))
|
||||
}
|
||||
if resp.Header.Get("X-Sentinel") == "hit" {
|
||||
t.Error("site host leaked into the API pipeline on GET")
|
||||
}
|
||||
if orgs, _ := h.seen(); len(orgs) != 0 {
|
||||
t.Fatalf("analytics carve fired on a GET (%v)", orgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareBaseCarveStillWins: the base carve stays first and independent — a
|
||||
// /v1/base request on a site host still routes to base, never to the analytics
|
||||
// carve (the two paths are disjoint but this pins the ordering explicitly).
|
||||
func TestMiddlewareBaseCarveStillWins(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "hanzo", Slug: "yadota", Bucket: "b", Prefix: "hanzo/yadota", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
base := &captureHostHandler{kind: "base"}
|
||||
anal := &captureHostHandler{kind: "analytics"}
|
||||
SetBaseHostHandler(base.handle)
|
||||
defer SetBaseHostHandler(nil)
|
||||
SetAnalyticsHostHandler(anal.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://yadota.hanzo.app/v1/base/collections", nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/base: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Carve") != "base" {
|
||||
t.Fatalf("/v1/base did not route to the base carve (X-Carve=%q)", resp.Header.Get("X-Carve"))
|
||||
}
|
||||
if orgs, _ := anal.seen(); len(orgs) != 0 {
|
||||
t.Fatalf("analytics carve fired for a /v1/base request (%v)", orgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareAnalyticsCarveNonSiteHostUnaffected: a self host (api.hanzo.ai) is
|
||||
// never a site — a beacon POST there Continues to the normal pipeline, the resolver
|
||||
// is never consulted, and the carve never fires.
|
||||
func TestMiddlewareAnalyticsCarveNonSiteHostUnaffected(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "x", Slug: "x", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
h := &captureHostHandler{kind: "analytics"}
|
||||
SetAnalyticsHostHandler(h.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(selfServer())
|
||||
|
||||
resp, err := app.Fiber().Test(postReq("api.hanzo.ai", "/v1/analytics", beaconBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
if resp.Header.Get("X-Sentinel") != "hit" {
|
||||
t.Fatalf("self host beacon did not pass through to the API pipeline (status %d)", resp.StatusCode)
|
||||
}
|
||||
if orgs, _ := h.seen(); len(orgs) != 0 {
|
||||
t.Fatalf("analytics carve fired for a self host (%v)", orgs)
|
||||
}
|
||||
if n := len(fr.slugs()); n != 0 {
|
||||
t.Fatalf("resolver consulted %d times for a self host, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareAnalyticsCarveNonLive405: a beacon POST to a NON-live site host is
|
||||
// not ingested — it falls to the static serve, which 405s the POST (unchanged from
|
||||
// today). Only a LIVE site accepts its beacons.
|
||||
func TestMiddlewareAnalyticsCarveNonLive405(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "hanzo", Slug: "yadota", Status: "building"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
h := &captureHostHandler{kind: "analytics"}
|
||||
SetAnalyticsHostHandler(h.handle)
|
||||
defer SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
resp, err := app.Fiber().Test(postReq("yadota.hanzo.app", "/v1/analytics", beaconBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("non-live beacon POST → %d, want 405", resp.StatusCode)
|
||||
}
|
||||
if orgs, _ := h.seen(); len(orgs) != 0 {
|
||||
t.Fatalf("analytics carve fired for a non-live site (%v)", orgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareNoAnalyticsHandlerIs405: with NO handler installed (the default,
|
||||
// e.g. public capture disabled), a site host still 405s a beacon POST — the fix is
|
||||
// inert until analytics.Mount installs the carve.
|
||||
func TestMiddlewareNoAnalyticsHandlerIs405(t *testing.T) {
|
||||
fr := &fakeResolver{found: true, site: Site{Org: "hanzo", Slug: "yadota", Bucket: "b", Prefix: "hanzo/yadota", Status: "live"}}
|
||||
SetResolver(fr)
|
||||
defer SetResolver(nil)
|
||||
SetAnalyticsHostHandler(nil)
|
||||
app := newTestApp(testServer())
|
||||
|
||||
resp, err := app.Fiber().Test(postReq("yadota.hanzo.app", "/v1/analytics", beaconBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("no-handler beacon POST → %d, want 405 (carve inert until installed)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,9 @@ func (p *Purger) PurgeTags(ctx context.Context, tags ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CacheTag is the ONE canonical cache-tag for a site's objects. The site server
|
||||
// stamps it as the Cache-Tag response header; the Purger purges it on redeploy /
|
||||
// delete. Both derive it from server-owned Org+Slug so they always agree.
|
||||
// CacheTag is the ONE canonical edge cache-tag for a project's site objects. The
|
||||
// site server (streamSite) emits it as the Cache-Tag response header on every
|
||||
// served object; the Purger targets it on deploy, domain-bind, delete, and the
|
||||
// dedicated POST .../purge. Both derive it from server-owned Org+Slug so the
|
||||
// emitted tag and the purged tag never diverge.
|
||||
func CacheTag(org, slug string) string { return "site-" + org + "-" + slug }
|
||||
|
||||
+39
-2
@@ -174,6 +174,33 @@ func isBasePath(p string) bool {
|
||||
p == "/_" || strings.HasPrefix(p, "/_/")
|
||||
}
|
||||
|
||||
// analyticsHostHandler ingests a published site's OWN analytics beacon (the
|
||||
// anonymous POST a page emits on unload) on the site host — host-as-project-ref
|
||||
// (HIP-0014), the exact twin of baseHostHandler. Nil (the default) leaves a site
|
||||
// host 405-ing a beacon POST; analytics.Mount installs it. The org comes ONLY from
|
||||
// the resolved Site (the subdomain / bound custom host), never the caller — the
|
||||
// SAME server-supplied tenant key the file plane and the base carve trust, so a
|
||||
// page for yadota.hanzo.app always ingests as that site's Org regardless of any
|
||||
// body/header claim. The authenticated GET read lenses on api.hanzo.ai
|
||||
// (/v1/analytics/overview|timeseries|top) are untouched: this carve is POST-only
|
||||
// and never runs on an API host.
|
||||
var analyticsHostHandler func(org string, c *zip.Ctx) error
|
||||
|
||||
// SetAnalyticsHostHandler installs the per-org analytics ingest handler (see
|
||||
// analyticsHostHandler).
|
||||
func SetAnalyticsHostHandler(h func(org string, c *zip.Ctx) error) { analyticsHostHandler = h }
|
||||
|
||||
// isAnalyticsPath reports whether a path targets the site-host analytics-beacon
|
||||
// ingest: the CANONICAL door at /v1/event, plus the deprecated Segment/beacon wire
|
||||
// at /v1/analytics{,/batch} and the deprecated PostHog wire at /v1/insights/e (kept
|
||||
// so beacons already deployed on published sites don't break mid-migration). The
|
||||
// Middleware carve additionally gates on POST, so the authenticated GET read lenses
|
||||
// (/v1/analytics/overview|timeseries|top) — which live on api.hanzo.ai, never a site
|
||||
// host — are never hijacked.
|
||||
func isAnalyticsPath(p string) bool {
|
||||
return p == "/v1/event" || strings.HasPrefix(p, "/v1/analytics") || p == "/v1/insights/e"
|
||||
}
|
||||
|
||||
func (s *Server) Middleware() zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
raw := c.Fiber().Hostname()
|
||||
@@ -183,6 +210,11 @@ func (s *Server) Middleware() zip.Handler {
|
||||
return baseHostHandler(site.Org, c)
|
||||
}
|
||||
}
|
||||
if analyticsHostHandler != nil && c.Method() == http.MethodPost && isAnalyticsPath(c.Path()) {
|
||||
if site, ok := s.resolveLive(c.Context(), slug); ok {
|
||||
return analyticsHostHandler(site.Org, c)
|
||||
}
|
||||
}
|
||||
return s.serve(c, slug)
|
||||
}
|
||||
if host := hostOnly(raw); s.customCandidate(host) {
|
||||
@@ -190,6 +222,9 @@ func (s *Server) Middleware() zip.Handler {
|
||||
if baseHostHandler != nil && isBasePath(c.Path()) {
|
||||
return baseHostHandler(site.Org, c)
|
||||
}
|
||||
if analyticsHostHandler != nil && c.Method() == http.MethodPost && isAnalyticsPath(c.Path()) {
|
||||
return analyticsHostHandler(site.Org, c)
|
||||
}
|
||||
return s.serveCustom(c, site)
|
||||
}
|
||||
}
|
||||
@@ -339,8 +374,10 @@ func (s *Server) serve(c *zip.Ctx, slug string) error {
|
||||
// only chooses the candidate keys, streams the first hit, and falls back to the
|
||||
// site's own 404.
|
||||
func (s *Server) streamSite(c *zip.Ctx, cli *minio.Client, site Site) error {
|
||||
// The cache tag lets Cloudflare purge exactly this site's assets on redeploy.
|
||||
// It is derived from server-owned values (Org+Slug), never the request.
|
||||
// Emit the edge cache-tag on every served object so a tag-purge
|
||||
// (projects.purgeTag → Purger.PurgeTags) invalidates exactly this project's site
|
||||
// at the edge. Derived from server-owned Org+Slug — the SAME tag the purger
|
||||
// targets — never from the request, so emit and purge never diverge.
|
||||
c.SetHeader("Cache-Tag", CacheTag(site.Org, site.Slug))
|
||||
|
||||
rel := resolveKey(c.Path())
|
||||
|
||||
@@ -23,7 +23,7 @@ bun install
|
||||
bun run build # → apps/admin-tasks/dist (base=/tasks/, api=/v1/tasks)
|
||||
|
||||
# sync into cloud
|
||||
rsync -a --delete apps/admin-tasks/dist/ <cloud>/clients/tasksvc/ui/dist/
|
||||
rsync -a --delete apps/admin-tasks/dist/ <cloud>/clients/tasks/ui/dist/
|
||||
```
|
||||
|
||||
Then `go build ./cmd/cloud` re-embeds it. Do NOT hand-edit files under `dist/` —
|
||||
|
||||
+17
-48
@@ -23,35 +23,14 @@
|
||||
// contract → MountAll → graceful Listen) — that body lives once in cloud.Serve
|
||||
// and is shared with cmd/cloud. No subcommand duplicates boot logic.
|
||||
//
|
||||
// The single exception is `hanzo iam`. The registry's iam Mount (pkg/iam,
|
||||
// order 50) wraps the Beego handler under /v1/iam/* for the fused surface;
|
||||
// the FULL standalone IAM — login UI at /, all ~150 routes at root, LDAP +
|
||||
// RADIUS listeners — is iamserver.Run(), the body of the legacy iamd
|
||||
// main(). `hanzo iam` runs that, so the standalone identity provider is
|
||||
// byte-for-byte what iamd shipped. See the iam case in dispatch().
|
||||
//
|
||||
// THE BEEGO CRUX (and why this binary does not init-panic). iam imports
|
||||
// github.com/hanzoai/beego/v2; that fork carries process-global state
|
||||
// (web.BeeApp singleton, ORM model registry, logger registration). The
|
||||
// fear is that importing iam alongside the other subsystems collides at
|
||||
// package load regardless of subcommand. It does not, for two reasons
|
||||
// this codebase already established:
|
||||
//
|
||||
// 1. iam's ~150 route registrations and ORM table creation are NOT at
|
||||
// package init() — they live inside iamserver.Init() / routers.InitAPI()
|
||||
// / object.CreateTables(), which run only when iam actually serves.
|
||||
// Blank-importing the package is inert: no router, no ORM, no listener.
|
||||
// 2. There is exactly ONE Beego v2 import path in the graph
|
||||
// (hanzoai/beego/v2), so there is exactly one Beego global to
|
||||
// initialize, and it is initialized lazily by whichever path serves
|
||||
// iam. (visor, the other Beego service, pins the *v1* fork
|
||||
// github.com/beego/beego and is intentionally NOT linked here — two
|
||||
// Beego majors in one binary is the collision to avoid, so we don't.)
|
||||
//
|
||||
// The proof is mechanical: the existing cmd/cloud binary already links
|
||||
// this same graph (iam Beego v2 + kms + commerce + gateway + …) and builds
|
||||
// + boots. cmd/hanzo links the same set plus iamserver for the standalone
|
||||
// path; nothing new collides.
|
||||
// Identity is served on the CLEAN github.com/hanzoai/iam (the Casdoor/Beego fork
|
||||
// github.com/hanzoai/iam-v1 is RETIRED, GONE from this binary's graph): the
|
||||
// in-process fold clients/iam (order 50) embeds the clean iam-v2 zip-natively under
|
||||
// /v1/iam/* (+ /login/oauth/*) via iamserver.Mount(app, db) for the fused surface;
|
||||
// the FULL standalone identity provider is the clean iam's own binary. The legacy
|
||||
// `hanzo iam` subcommand (which launched the Casdoor daemon via iamserver.Run) is
|
||||
// gone — nothing here links the dead Beego module, so there is no beego process-global
|
||||
// to collide at package load.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -69,13 +48,9 @@ import (
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
|
||||
// iamserver is the body of the standalone iamd main() — full Beego
|
||||
// server (login UI, all routes, LDAP/RADIUS). `hanzo iam` calls Run().
|
||||
"github.com/hanzoai/iam-v1/iamserver"
|
||||
|
||||
// The subsystem set is defined ONCE in the subsystems bundle (shared with
|
||||
// cmd/cloud). apps.Wire() returns it in mount order; main threads that
|
||||
// slice through dispatch/usage/Serve. Inert at load — see THE BEEGO CRUX.
|
||||
// slice through dispatch/usage/Serve. Inert at load.
|
||||
"github.com/hanzoai/cloud/apps"
|
||||
)
|
||||
|
||||
@@ -83,13 +58,11 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
// nonRegistrySubcommands are the dispatch targets that do NOT correspond to
|
||||
// a single Wire() subsystem entry: the full fused surface, the standalone IAM
|
||||
// boot, and the datastore (a datastore C++ fork with no Go serve target —
|
||||
// see the datastore case in dispatch()). Listed in --help alongside the
|
||||
// registry-backed subcommands.
|
||||
// a single Wire() subsystem entry: the full fused surface and the datastore (a
|
||||
// datastore C++ fork with no Go serve target — see the datastore case in
|
||||
// dispatch()). Listed in --help alongside the registry-backed subcommands.
|
||||
var nonRegistrySubcommands = map[string]string{
|
||||
"cloud": "serve the full unified surface (all enabled subsystems, one listener)",
|
||||
"iam": "serve standalone Hanzo IAM (full Beego server: login UI, OAuth2/OIDC, LDAP/RADIUS)",
|
||||
"datastore": "datastore-fork analytics DB — not a Go serve target (see help text)",
|
||||
}
|
||||
|
||||
@@ -183,15 +156,11 @@ func dispatch(sub string, specs []cloud.MountSpec) error {
|
||||
// Full fused surface: --enable governs the set (empty = all).
|
||||
return cloud.Serve(specs, nil)
|
||||
|
||||
case "iam":
|
||||
// Standalone IAM = the body of iamd's main(). Full Beego server:
|
||||
// login UI at /, ~150 routes at root, LDAP + RADIUS listeners,
|
||||
// background sync loops. iamserver.Run() blocks until the process
|
||||
// is signalled. This is intentionally NOT the /v1/iam/*-wrapped
|
||||
// registry Mount — `hanzo iam` IS the identity provider, not a
|
||||
// route-prefixed subsystem inside the cloud surface.
|
||||
iamserver.Run()
|
||||
return nil
|
||||
// NOTE: the legacy `hanzo iam` subcommand (the standalone Casdoor/Beego
|
||||
// identity daemon, github.com/hanzoai/iam-v1) is RETIRED. iam-v1 is dead; the
|
||||
// standalone identity provider is now the clean github.com/hanzoai/iam binary
|
||||
// (zip-native + hanzoai/orm), and the in-process fold is clients/iam (which
|
||||
// embeds the clean iam). Nothing here links the dead Casdoor module anymore.
|
||||
|
||||
case "datastore":
|
||||
// Hanzo Datastore is a datastore C++ fork. It has no Go
|
||||
|
||||
@@ -33,12 +33,11 @@ type Config struct {
|
||||
|
||||
// Replicas is the app-tier replica count the operator injects (CLOUD_REPLICAS,
|
||||
// mirroring the Deployment's spec.replicas). 0 = unset/unmanaged. It exists to
|
||||
// enforce ONE contract: embedded IAM (clients/iam) uses Beego's
|
||||
// process-local "memory" session store, so an iam-enabled cloud MUST run at a
|
||||
// single replica or login/authorize sessions are lost across replicas.
|
||||
// Validate refuses to boot iam-enabled above 1; the helm chart pins replicas=1
|
||||
// whenever "iam" is in --enable. Migrating IAM sessions to a shared store lifts
|
||||
// this.
|
||||
// enforce ONE contract: embedded IAM (clients/iam) keeps its identity store as a
|
||||
// per-pod embedded SQLite file, so an iam-enabled cloud MUST run at a single
|
||||
// replica or each replica gets its OWN divergent identity store. Validate refuses
|
||||
// to boot iam-enabled above 1; the helm chart pins replicas=1 whenever "iam" is in
|
||||
// --enable. Pointing IAM at a shared external store lifts this.
|
||||
Replicas int
|
||||
|
||||
// Brand is the white-label brand identifier.
|
||||
@@ -543,21 +542,17 @@ func registrableDomain(host string) string {
|
||||
// the empty-Enable "mount everything" default and mount ONLY when named in
|
||||
// CLOUD_ENABLE. This is the HIP-0106 staged-rollout contract, enforced in code.
|
||||
//
|
||||
// "iam" is staged because iam.Mount boots the WHOLE Beego identity runtime via
|
||||
// iamserver.InitEmbed(), which initialises process-global Beego config (web.BConfig
|
||||
// / the shared AppConfig). The `ai` subsystem is a sibling casibase/casdoor fork
|
||||
// linked against the SAME beego module, and reads that same process-global at its
|
||||
// own bootstrap. Booting the IAM embed under the mount-all default corrupts the
|
||||
// shared global so `ai` can no longer open its SQLite store — the binary crashes at
|
||||
// boot with "ai: bootstrap: unable to open database file (14)" (SQLITE_CANTOPEN),
|
||||
// which is exactly why every cloud release since the IAM embed (#142) failed its
|
||||
// boot smoke and the fleet stayed pinned to a pre-embed image. Until that
|
||||
// shared-global isolation is solved AND the fold is verified (login/authorize/
|
||||
// token/jwks + the operator SSO chain), the operator activates IAM by ADDING "iam"
|
||||
// to CLOUD_ENABLE explicitly; until then hanzo.id is served by the standalone iam
|
||||
// pod and cloud runs iam-less exactly as it does in production today (pickIAMClient
|
||||
// falls back to the remote/disabled IAM client — see build.go). ONE activation
|
||||
// mechanism (the enable-list), ONE place.
|
||||
// "iam" is staged as a cutover gate. iam.Mount now embeds the CLEAN iam-v2
|
||||
// (clients/iam — zip-native + hanzoai/orm, the retired Casdoor iam-v1/beego fork is
|
||||
// GONE, so the old shared-Beego-global corruption of the `ai` subsystem is gone too),
|
||||
// but flipping hanzo.id from the standalone iam pod to the in-process embed is a
|
||||
// production identity cutover. Until the fold is verified (login/authorize/token/jwks
|
||||
// + the operator SSO chain) AND the v2 config (init_data + KMS signing keys) is present
|
||||
// in the cloud runtime, the operator activates IAM by ADDING "iam" to CLOUD_ENABLE
|
||||
// explicitly; until then hanzo.id is served by the standalone iam pod and cloud runs
|
||||
// iam-less exactly as it does in production today (pickIAMClient falls back to the
|
||||
// remote/disabled IAM client — see build.go). ONE activation mechanism (the
|
||||
// enable-list), ONE place.
|
||||
//
|
||||
// PHASE 2 (tasks #96, #105): commerce, captable, sign, dataroom are folded
|
||||
// in-process (clients/commerce, clients/captable, clients/sign, clients/dataroom)
|
||||
@@ -572,9 +567,9 @@ func registrableDomain(host string) string {
|
||||
// S3_*/SQUARE_*/HUSD_*/IAM_*/COMMERCE_EDGE_AUTH) exactly as the standalone CR did;
|
||||
// a missing SQL_URL still fails loud (commerce.go) rather than reading $0 balances.
|
||||
// All four are dropped from staging so the mount-all default serves them from the
|
||||
// one binary — retiring their standalone pods. iam and ingress STAY staged (the
|
||||
// IAM embed corrupts its own bootstrap under mount-all; iam is served by the
|
||||
// standalone pod).
|
||||
// one binary — retiring their standalone pods. iam and ingress STAY staged: iam is a
|
||||
// production identity cutover gated on the fold being verified (see above), served by
|
||||
// the standalone pod until the operator flips it on.
|
||||
var stagedSubsystems = map[string]bool{"iam": true, "ingress": true}
|
||||
|
||||
// Enabled reports whether subsystem `name` is enabled in this config.
|
||||
@@ -750,22 +745,22 @@ func (c *Config) Validate() error {
|
||||
if c.DataDir == "" {
|
||||
return fmt.Errorf("data-dir is required")
|
||||
}
|
||||
// Embedded IAM (clients/iam) uses Beego's process-local "memory" session
|
||||
// store, so a horizontally scaled app tier would mint a login/authorize
|
||||
// session on one replica and fail to find it on the next. Refuse to boot an
|
||||
// iam-enabled cloud above a single replica. CLOUD_REPLICAS=0 (unset) is the
|
||||
// unmanaged/dev case and is allowed — the helm chart pins replicas=1 whenever
|
||||
// "iam" is in --enable, so a managed deployment always sets it. Migrate IAM
|
||||
// sessions to a shared store to lift this.
|
||||
// Embedded IAM (clients/iam) keeps its identity store as a per-pod embedded
|
||||
// SQLite file ({DataDir}/iam/iam.db), so a horizontally scaled app tier would give
|
||||
// each replica its OWN divergent identity store — a user/session written on one
|
||||
// replica is absent on the next. Refuse to boot an iam-enabled cloud above a single
|
||||
// replica. CLOUD_REPLICAS=0 (unset) is the unmanaged/dev case and is allowed — the
|
||||
// helm chart pins replicas=1 whenever "iam" is in --enable, so a managed deployment
|
||||
// always sets it. Point IAM at a shared external store to lift this.
|
||||
if c.Enabled("iam") && c.Replicas > 1 {
|
||||
return fmt.Errorf("iam is enabled but CLOUD_REPLICAS=%d > 1: embedded IAM uses a process-local session store and requires replicas=1 (pin the Deployment to 1 replica or migrate IAM sessions to a shared store)", c.Replicas)
|
||||
return fmt.Errorf("iam is enabled but CLOUD_REPLICAS=%d > 1: embedded IAM uses a per-pod SQLite store and requires replicas=1 (pin the Deployment to 1 replica or point IAM at a shared store)", c.Replicas)
|
||||
}
|
||||
// Horizontal shard routing (CLOUD_PEERS names >1 pod). Two fail-closed guards:
|
||||
// 1. THIS pod must be one of the peers, else it owns no shard and would forward
|
||||
// every request away (a silent black-hole) — refuse to boot.
|
||||
// 2. Embedded IAM cannot be sharded: its process-local login/authorize session,
|
||||
// minted on the pod that received the OAuth step, is unreachable on the owner
|
||||
// pod a later request routes to. Disable iam (use external iam.hanzo.svc).
|
||||
// 2. Embedded IAM cannot be sharded: its per-pod SQLite identity store is local to
|
||||
// each pod, so a login/authorize step served on one pod is unreachable on the
|
||||
// owner pod a later request routes to. Disable iam (use external iam.hanzo.svc).
|
||||
// Both are boot errors, never guesses — a wrong shard topology must fail loud.
|
||||
if peers := parsePeers(c.ShardPeers); len(peers) >= 2 {
|
||||
if c.ShardSelf == "" {
|
||||
@@ -775,7 +770,7 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("shard self %q is not in CLOUD_PEERS %q: this pod is not a member of its own ring (it would forward every request away and own no shard)", c.ShardSelf, c.ShardPeers)
|
||||
}
|
||||
if c.Enabled("iam") {
|
||||
return fmt.Errorf("iam is enabled with CLOUD_PEERS shard routing: embedded IAM uses a process-local session store and cannot be sharded; disable iam (use external iam.hanzo.svc) or run a single pod")
|
||||
return fmt.Errorf("iam is enabled with CLOUD_PEERS shard routing: embedded IAM uses a per-pod SQLite store and cannot be sharded; disable iam (use external iam.hanzo.svc) or run a single pod")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -3,7 +3,7 @@ package cloud
|
||||
import "testing"
|
||||
|
||||
// TestValidateIAMSingleReplica locks the single-replica contract embedded IAM
|
||||
// requires (Beego's process-local "memory" session store): an iam-enabled cloud
|
||||
// requires (its per-pod embedded SQLite identity store): an iam-enabled cloud
|
||||
// above 1 replica must refuse to boot, while replicas=1, an unset count, and
|
||||
// iam-disabled-at-any-scale all pass.
|
||||
func TestValidateIAMSingleReplica(t *testing.T) {
|
||||
@@ -22,11 +22,11 @@ func TestValidateIAMSingleReplica(t *testing.T) {
|
||||
{"iam enabled, replicas unset -> ok", []string{"iam"}, 0, false},
|
||||
{"iam disabled, 5 replicas -> ok", []string{"kms", "o11y"}, 5, false},
|
||||
// IAM is a STAGED subsystem (stagedSubsystems["iam"]): the empty-Enable
|
||||
// "mount everything" default deliberately does NOT mount it (booting the
|
||||
// IAM embed under mount-all corrupts the shared Beego global and crashes
|
||||
// the `ai` subsystem — see config.go). So an empty list is iam-DISABLED,
|
||||
// and >1 replica is allowed. The guard only fires when iam is EXPLICITLY
|
||||
// enabled (the cases above), which is the sole way IAM ever runs.
|
||||
// "mount everything" default deliberately does NOT mount it (flipping hanzo.id
|
||||
// to the in-process embed is a production identity cutover the operator makes
|
||||
// explicitly — see config.go). So an empty list is iam-DISABLED, and >1 replica
|
||||
// is allowed. The guard only fires when iam is EXPLICITLY enabled (the cases
|
||||
// above), which is the sole way IAM ever runs.
|
||||
{"empty list is iam-staged/disabled, 4 replicas -> ok", nil, 4, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
+7
-7
@@ -11,13 +11,13 @@ import (
|
||||
// STAGED subsystem (iam, ingress) is NOT enabled — it mounts only when named in
|
||||
// CLOUD_ENABLE explicitly, while every non-staged subsystem still mounts.
|
||||
//
|
||||
// This is the guard that keeps the IAM embed (iamserver.InitEmbed, which boots
|
||||
// process-global Beego config the `ai` subsystem shares) from booting under the
|
||||
// mount-all default and crashing the binary at `ai` bootstrap — the boot-smoke
|
||||
// failure that pinned the fleet to a pre-embed image since #142. commerce was
|
||||
// un-staged in Phase 2 (task #105): it now mounts under the mount-all default
|
||||
// like every other in-process subsystem (the money-path cutover keeps the
|
||||
// authoritative stores in place — see stagedSubsystems in config.go).
|
||||
// This is the gate that keeps the IAM embed (clients/iam — now the clean zip-native
|
||||
// iam-v2, the Casdoor iam-v1/beego fork retired) from taking over hanzo.id under the
|
||||
// mount-all default: flipping identity from the standalone pod to the in-process embed
|
||||
// is a production cutover the operator makes explicitly, not a side effect of the
|
||||
// mount-all default. commerce was un-staged in Phase 2 (task #105): it now mounts under
|
||||
// the mount-all default like every other in-process subsystem (the money-path cutover
|
||||
// keeps the authoritative stores in place — see stagedSubsystems in config.go).
|
||||
func TestEnabled_StagedSubsystemsExcludedFromMountAll(t *testing.T) {
|
||||
// Empty Enable = mount-all default.
|
||||
c := &cloud.Config{}
|
||||
|
||||
@@ -34,14 +34,14 @@ plane (`clients/kms`) and the ONE in-process **tasks durable engine**
|
||||
37 native `clients/*` subsystems already carry their product surface in-process:
|
||||
admin, agents, analytics, bot, cms, console, crm, do, erp, eval, exec, framework,
|
||||
functions, git, graph, help, kms, ml, o11y, paassvc, plan, platform,
|
||||
plugin, pricing, product, projectsvc, prompt(s), provisioning, s3, security,
|
||||
tasksvc, templates, visor, websearch, zt.
|
||||
plugin, pricing, product, projects, prompt(s), provisioning, s3, security,
|
||||
tasks, templates, visor, websearch, zt.
|
||||
|
||||
## Wave 1 — THIS build (tasks + visor)
|
||||
|
||||
| Service | Disposition | Notes |
|
||||
|---|---|---|
|
||||
| **tasks** (`hanzoai/tasks`, tasksd) | **merged** | Engine already embedded in-process by `durable.go` (ONE engine, loopback ZAP :19999, shared with ai ingest). This wave adds the **HTTP + UI surface** (`clients/tasksvc`, order 147) mounting that SAME engine's handlers at `/v1/tasks/*` + `/_/tasks/*` — the "consolidate the UI surface into cloud" follow-up named in `durable.go`. No second Embed. Needed a small `hanzoai/tasks` v1.46.0 (`auth.WithIdentity` in-proc identity seam + single `hanzoai/sqlite` driver). ~180 LOC + `cloud.EmbeddedTasks()` accessor. |
|
||||
| **tasks** (`hanzoai/tasks`, tasksd) | **merged** | Engine already embedded in-process by `durable.go` (ONE engine, loopback ZAP :19999, shared with ai ingest). This wave adds the **HTTP + UI surface** (`clients/tasks`, order 147) mounting that SAME engine's handlers at `/v1/tasks/*` + `/_/tasks/*` — the "consolidate the UI surface into cloud" follow-up named in `durable.go`. No second Embed. Needed a small `hanzoai/tasks` v1.46.0 (`auth.WithIdentity` in-proc identity seam + single `hanzoai/sqlite` driver). ~180 LOC + `cloud.EmbeddedTasks()` accessor. |
|
||||
| **visor** (`hanzoai/visor`) | **surface merged; logic port = own wave** | The compute REST surface (`/v1/machines`, `/v1/gpus`, `/v1/clusters`) is ALREADY native in `clients/visor` (order 133), org-scoped via `principal`. It PROXIES the standalone visor for data. Visor itself is a **5.4k-LOC multi-cloud Beego + xorm + Casdoor monolith** with its own DB and auth — mounting that in-process drags Beego/xorm/Casdoor + a second DB + bare `/v1/*` route collisions into this binary (architecturally wrong, not golf). The genuine native port = replace visor's `object/*` xorm persistence with a Base store and reuse its pure-`godo` `service/{digitalocean,doks}.go` on top of cloud's existing `clients/do` — a bounded **~600–900 LOC wave of its own**, sequenced after wave 2. Visor stays standalone until then. |
|
||||
|
||||
## Wave 2+ — mount queue (sequential; do one, tidy, prove, next)
|
||||
|
||||
@@ -18,8 +18,7 @@ require (
|
||||
github.com/hanzoai/decimal v0.1.1
|
||||
github.com/hanzoai/go-openai v1.41.0
|
||||
github.com/hanzoai/goa v1.0.0
|
||||
github.com/hanzoai/iam v1.32.1
|
||||
github.com/hanzoai/iam-v1 v1.31.36
|
||||
github.com/hanzoai/iam v1.32.2
|
||||
github.com/hanzoai/money v0.2.1
|
||||
github.com/hanzoai/notify v1.6.18
|
||||
github.com/hanzoai/otel-collector v1.2.0
|
||||
@@ -68,7 +67,7 @@ require (
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/MakeNowJust/heredoc v1.0.0 // indirect
|
||||
github.com/beego/beego v1.12.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.53.0 // indirect
|
||||
github.com/btcsuite/btcd/chainhash/v2 v2.0.0 // indirect
|
||||
github.com/chai2010/gettext-go v1.0.3 // indirect
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
|
||||
@@ -78,6 +77,7 @@ require (
|
||||
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
|
||||
github.com/fatih/camelcase v1.0.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
||||
github.com/go-test/deep v1.1.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/google/renameio/v2 v2.0.2 // indirect
|
||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
|
||||
@@ -91,6 +91,7 @@ require (
|
||||
github.com/hanzoai/sqlcipher v0.1.0 // indirect
|
||||
github.com/hanzos3/go-sdk v1.0.2 // indirect
|
||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
|
||||
github.com/linode/linodego v1.67.0 // indirect
|
||||
github.com/luxfi/filesystem v0.0.1 // indirect
|
||||
github.com/mattetti/filebuffer v1.0.1 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.100 // indirect
|
||||
@@ -99,6 +100,7 @@ require (
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
|
||||
github.com/vultr/govultr/v3 v3.30.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
k8s.io/apiserver v0.35.3 // indirect
|
||||
k8s.io/cli-runtime v0.35.3 // indirect
|
||||
@@ -281,13 +283,10 @@ require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/ALTree/bigfloat v0.2.0 // indirect
|
||||
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute v1.0.0 // indirect
|
||||
github.com/Azure/azure-storage-blob-go v0.15.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
@@ -295,40 +294,28 @@ require (
|
||||
github.com/Machiel/slugify v1.0.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20240116134246-a8cbe886bab0 // indirect
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
|
||||
github.com/ThinkInAIXYZ/go-mcp v0.2.24 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/alexedwards/argon2id v1.0.0 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2 // indirect
|
||||
github.com/alibabacloud-go/darabonba-number v1.0.4 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.1.18 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/ecs-20140526/v4 v4.26.10 // indirect
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
|
||||
github.com/alibabacloud-go/facebody-20191230/v5 v5.1.2 // indirect
|
||||
github.com/alibabacloud-go/openapi-util v0.1.2 // indirect
|
||||
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 // indirect
|
||||
github.com/alibabacloud-go/resourcecenter-20221201 v1.5.1 // indirect
|
||||
github.com/alibabacloud-go/tea v1.4.0 // indirect
|
||||
github.com/alibabacloud-go/tea-fileform v1.1.1 // indirect
|
||||
github.com/alibabacloud-go/tea-oss-sdk v1.1.3 // indirect
|
||||
github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect
|
||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||
github.com/alibabacloud-go/vod-20170321/v2 v2.16.10 // indirect
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 // indirect
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible // indirect
|
||||
github.com/aliyun/credentials-go v1.4.7 // indirect
|
||||
github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
|
||||
github.com/aws/aws-sdk-go v1.55.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
|
||||
@@ -354,11 +341,9 @@ require (
|
||||
github.com/aymerick/raymond v2.0.2+incompatible // indirect
|
||||
github.com/baidubce/bce-qianfan-sdk/go/qianfan v0.0.14 // indirect
|
||||
github.com/baidubce/bce-sdk-go v0.9.264 // indirect
|
||||
github.com/beevik/etree v1.6.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/blinkbean/dingtalk v1.1.3 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/btcsuite/btcd v0.25.0 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect
|
||||
@@ -366,11 +351,9 @@ require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/btcsuite/btclog v1.0.0 // indirect
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/bwmarrin/discordgo v0.28.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
|
||||
github.com/carmel/gooxml v0.0.0-20220216072414-40ff56130850 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
@@ -386,26 +369,18 @@ require (
|
||||
github.com/consensys/gnark-crypto v0.20.1 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/corazawaf/coraza/v3 v3.3.3 // indirect
|
||||
github.com/corazawaf/libinjection-go v0.2.2 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
|
||||
github.com/corpix/uarand v0.2.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.9.0 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dghubble/oauth1 v0.7.3 // indirect
|
||||
github.com/dghubble/sling v1.4.2 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/di-wu/parser v0.2.2 // indirect
|
||||
github.com/di-wu/xsd-datetime v1.0.0 // indirect
|
||||
github.com/digitalocean/go-libvirt v0.0.0-20260217163227-273eaa321819 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/diskfs/go-diskfs v1.9.1 // indirect
|
||||
@@ -416,10 +391,8 @@ require (
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
@@ -431,16 +404,12 @@ require (
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gage-technologies/mistral-go v1.1.0 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-acme/alidns-20150109/v4 v4.7.0 // indirect
|
||||
github.com/go-acme/lego/v4 v4.34.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
@@ -449,8 +418,6 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-lark/lark v1.15.1 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.14 // indirect
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -482,10 +449,7 @@ require (
|
||||
github.com/go-python/gpython v0.2.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/webauthn v0.10.2 // indirect
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -493,7 +457,6 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
@@ -502,7 +465,6 @@ require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
@@ -514,38 +476,28 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/gregdel/pushover v1.3.1 // indirect
|
||||
github.com/gtank/merlin v0.1.1 // indirect
|
||||
github.com/gtank/ristretto255 v0.2.0 // indirect
|
||||
github.com/hanzoai/authzstore v0.1.1 // indirect
|
||||
github.com/hanzoai/beego/v2 v2.4.2
|
||||
github.com/hanzoai/builder v0.3.13 // indirect
|
||||
github.com/hanzoai/dashscope-go-sdk v0.0.2 // indirect
|
||||
github.com/hanzoai/dashscopego v0.6.0 // indirect
|
||||
github.com/hanzoai/dbx v1.16.0 // indirect
|
||||
github.com/hanzoai/go-openrouter v1.0.0 // indirect
|
||||
github.com/hanzoai/goauthorizenet v1.0.0 // indirect
|
||||
github.com/hanzoai/gochimp3 v1.0.0 // indirect
|
||||
github.com/hanzoai/iamsdk/v2 v2.1.2 // indirect
|
||||
github.com/hanzoai/idv v1.0.3 // indirect
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0 // indirect
|
||||
github.com/hanzoai/ldapserver v1.2.2 // indirect
|
||||
github.com/hanzoai/notify2 v1.6.3 // indirect
|
||||
github.com/hanzoai/orm v0.6.1
|
||||
github.com/hanzoai/oss v1.8.5 // indirect
|
||||
github.com/hanzoai/pdf v1.2.0 // indirect
|
||||
github.com/hanzoai/pubsub-go v1.53.0 // indirect
|
||||
github.com/hanzoai/search-go v0.36.0 // indirect
|
||||
github.com/hanzoai/sendgrid-go v3.4.2-0.20180724185151-733a05184a8d+incompatible // indirect
|
||||
github.com/hanzoai/tasks v1.51.3
|
||||
github.com/hanzoai/xorm v1.4.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
@@ -555,7 +507,6 @@ require (
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/hsluoyz/modsecurity-go v0.0.7 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/hupe1980/go-huggingface v0.0.15 // indirect
|
||||
github.com/icrowley/fake v0.0.0-20240710202011-f797eb4a99c0 // indirect
|
||||
@@ -564,12 +515,6 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/jessevdk/go-flags v1.6.1 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
@@ -585,19 +530,8 @@ require (
|
||||
github.com/knadh/koanf/v2 v2.3.5 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/jwx v1.2.29 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/leverly/ChatGLM v1.2.0 // indirect
|
||||
github.com/likexian/gokit v0.25.13 // indirect
|
||||
github.com/likexian/whois v1.15.1 // indirect
|
||||
github.com/likexian/whois-parser v1.24.9 // indirect
|
||||
github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect
|
||||
github.com/lithammer/shortuuid v3.0.0+incompatible // indirect
|
||||
github.com/lor00x/goldap v0.0.0-20240304151906-8d785c64d1c8 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/luthermonson/go-proxmox v0.4.0 // indirect
|
||||
github.com/luxfi/accel v1.2.4 // indirect
|
||||
@@ -629,10 +563,6 @@ require (
|
||||
github.com/luxfi/zap v1.2.6
|
||||
github.com/magefile/mage v1.17.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/markbates/going v1.0.0 // indirect
|
||||
github.com/markbates/goth v1.82.0 // indirect
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||
github.com/mattn/go-ieproxy v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
@@ -640,7 +570,6 @@ require (
|
||||
github.com/mholt/binding v0.3.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mileusna/viber v1.0.1 // indirect
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
@@ -648,19 +577,16 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
|
||||
github.com/montanaflynn/stats v0.9.0 // indirect
|
||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/netlify/netlify-go v0.1.11 // indirect
|
||||
github.com/nyaruka/phonenumbers v1.2.2 // indirect
|
||||
github.com/oklog/run v1.2.0 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 // indirect
|
||||
@@ -675,7 +601,6 @@ require (
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.11.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
@@ -692,26 +617,20 @@ require (
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/qiniu/go-sdk/v7 v7.12.1 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/rs/zerolog v1.35.0 // indirect
|
||||
github.com/russellhaering/gosaml2 v0.11.0 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.6.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/samber/lo v1.47.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||
github.com/segmentio/analytics-go/v3 v3.2.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/sendgrid/rest v2.6.9+incompatible // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/sethvargo/go-password v0.2.0 // indirect
|
||||
@@ -723,7 +642,6 @@ require (
|
||||
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/slack-go/slack v0.23.1 // indirect
|
||||
github.com/speps/go-hashids v2.0.0+incompatible // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -740,15 +658,12 @@ require (
|
||||
github.com/swaggest/refl v1.4.0 // indirect
|
||||
github.com/swaggest/rest v0.2.75 // indirect
|
||||
github.com/swaggest/usecase v1.3.1 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.77 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.0.1116 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/hunyuan v1.3.48 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tbaas v1.1.13 // indirect
|
||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||
github.com/thanhpk/randstr v1.0.4 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
@@ -764,8 +679,6 @@ require (
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9 // indirect
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
|
||||
github.com/utahta/go-linenotify v0.5.0 // indirect
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/volcengine/volc-sdk-golang v1.0.241 // indirect
|
||||
@@ -778,7 +691,6 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.mau.fi/util v0.8.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/collector/featuregate v1.54.0 // indirect
|
||||
go.opentelemetry.io/collector/pdata v1.54.0
|
||||
@@ -825,12 +737,9 @@ require (
|
||||
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
|
||||
k8s.io/metrics v0.35.3 // indirect
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
|
||||
layeh.com/radius v0.0.0-20231213012653-1006025d24f8 // indirect
|
||||
maunium.net/go/mautrix v0.22.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/binaryregexp v0.2.0 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/kustomize/api v0.20.1 // indirect
|
||||
sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
|
||||
|
||||
Reference in New Issue
Block a user