Compare commits

...
Author SHA1 Message Date
zeekayandhanzo-dev 13b0b4c20b refactor(analytics): unify event ingest onto ONE canonical door /v1/event
Make POST /v1/event the single canonical ingest door: one handler
implementation, one write core (ingestEvents), serving every wire shape
and every auth context. The other five routes become thin aliases/shims
delegating to it.

- decodeIngest: ONE wire-tolerant decoder accepting a bare Event object,
  a bare [Event] array, AND the {batch:[…]} | {events:[…]} envelope the
  Segment/beacon/publishable paths speak — all yielding the SAME
  []CaptureEvent the write core consumes.
- eventTenant: auth is the orthogonal pluggable concern on the one door —
  (1) IAM bearer, (2) write-only publishable key (pk_, folded in from the
  old /v1/ingest), (3) out-of-band IAM access key; fail-closed 403 with NO
  brand-host fallback on the canonical door.
- ingestBody: the ONE ingest core (tolerant decode → foldException → the
  ONE write core). eventIngest, the site-host carve (eventWithOrg), and the
  /v1/ingest alias all funnel through it; auth is the only per-door
  difference.
- Site-host carve repointed to /v1/event (eventWithOrg forces org from the
  resolved Site); isAnalyticsPath adds /v1/event so a published-site beacon
  POSTing <host>/v1/event lands host-forced. The org-forced-from-host
  adversarial invariant holds for /v1/event too.
- /v1/ingest is now a thin DEPRECATED alias of eventHandle (one-shot
  deprecation log, $source=ingest tag); /v1/ingest/keys minting unchanged.
- /v1/analytics{,/batch}, /v1/tracker, /v1/insights/e kept as thin
  foreign-protocol shims (external Segment/PostHog compat only) funnelling
  through the SAME write core; captureWithOrg now delegates to ingestBody.

Write core and hanzo.events schema unchanged. Tests prove the three wire
shapes land identically, pk_/bearer/host auth contexts resolve the same
tenant into a byte-identical warehouse row, and the exact app beacon
{batch:[ev]} body is accepted on /v1/event via the site-host carve.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 17:33:59 -07:00
zeekayandhanzo-dev 3547155ece feat(analytics): route /v1/analytics beacon ingest on published site hosts
Published sites POST analytics beacons to <site-host>/v1/analytics (and
/v1/insights/e), but that hit the static site server and 405'd — while
/v1/base already routed via the sites base-carve. Mirror the base carve
exactly for analytics ingest, so a page on yadota.hanzo.app (or a bound
custom domain) can send its own beacons into hanzo.events.

sites: add analyticsHostHandler + SetAnalyticsHostHandler + isAnalyticsPath
(prefix /v1/analytics OR ==/v1/insights/e). In Middleware, for both the slug
host and the custom-domain branch, carve the ingest POST to the handler with
org = resolved Site.Org (server-supplied, host-derived — never the caller/
body), gated on method POST so the authenticated GET read lenses
(/v1/analytics/overview|timeseries|top on api.hanzo.ai) are never hijacked.
The base carve stays first and independent.

analytics: extract captureWithOrg / insightsWithOrg — the Segment/beacon and
PostHog decode+ingest cores with an EXPLICIT org — so the deprecated aliases
(org via captureTenant) and the site-host carve (org forced from the Site)
share the ONE write core (ingestEvents), no copy. build() installs the carve
via sites.SetAnalyticsHostHandler, gated by the same already-existing flag the
anonymous ingest path uses (CLOUD_ANALYTICS_PUBLIC_CAPTURE, default ON); off
⇒ a site host 405s a beacon POST, unchanged.

Tests: sites carve routing (slug + custom host force Site.Org, not body/header
claim; GET serves static; /v1/base still routes to base; self host Continues;
non-live 405; no-handler 405; isAnalyticsPath). analytics end-to-end through
Mount+sites.Middleware (forced site org, 503-not-403 discriminator; empty
batch 200; custom domain; GET not hijacked; non-site host uses the normal
gate; disabled when public capture off).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 16:58:58 -07:00
zeekayandhanzo-dev cf6f8245f9 feat(projects): wire analytics + Base data space ON by default for new projects
Every new project now gets analytics collection and a Base data space
(form/forum/data submissions) wired by default — no opt-in — through the ONE
create path.

- createProject applies setProjectDefaults(analytics ON unless the caller passes
  analytics:false; space_id = "<org>/<slug>") then best-effort provisions the
  Base space. /v1/sites (ensureProject) routes through the same helpers, so
  defaults live in exactly one place.
- Fail-soft: a Base provisioning error (incl. embed disabled) is logged and
  swallowed — it never fails project creation, mirroring the CF edge-purge policy.
- clients/base.EnsureSpace: idempotent, in-process (no HTTP) provisioner that
  opens the org's per-org Base app and ensures the public-create "submissions"
  collection exists.
- store: additive analytics/space_id columns (migration matches the existing
  cache_control/last_purge_at ALTER pattern); analytics defaults ON for existing
  rows too. Exposed on the project view as analytics/space.

Tests: analytics default-ON, analytics:false opt-out, space provisioned on
create, and provisioning failure is fail-soft (create still 201, persists).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 14:04:51 -07:00
zeekayandhanzo-dev ec6b96f1aa feat(projects): dedicated POST /v1/projects/:slug/purge + emit Cache-Tag; DRY the edge purge
- Add POST /v1/projects/:slug/purge (mirrored at /v1/platform/sites/:slug/purge):
  resolve (org,slug), flush the edge cache-tag site-<org>-<slug>, stamp
  LastPurgeAt, persist, return 200 Project. Never touches the S3 origin. Org-scoped
  exactly like deploy (403 no org, 404 unknown slug). CF-unconfigured/failing purge
  is non-fatal (stamps + 200), matching deploy's behavior.
- DRY: extract purgeTag (the ONE cf.PurgeTags + cache-tag derivation + failure
  policy) and purgeEdge (purge + stamp). deploy(onPublish), setDomains, del, and the
  new purge handler all route through purgeTag — no copy-pasted CF call.
- Cache-Tag on served responses already emitted in sites.streamSite (unchanged);
  tightened its comment + CacheTag doc to state the emit/purge pairing invariant.
- Scrub stale svc suffix from prose (6→0): projectsvc→projects, tasksvc→tasks,
  cloud-mlsvc comment reworded; zapsvc test fixture → zap. msvc (third-party) left.
- Enforce one vocabulary in touched comments (project / deployment / S3 origin /
  edge cache-tag / purge); no behavior change.
- Tests: purge_test.go — stamps LastPurgeAt, CF-unconfigured→200, 403/404 scope,
  S3 origin untouched.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-22 13:45:26 -07:00
zeekay b290ac0088 Merge branch 'main' of https://github.com/hanzoai/cloud into drop-iam-v1-all
# Conflicts:
#	go.sum
2026-07-21 20:17:21 -07:00
zeekay 5907cb70cf Merge branch 'main' of https://github.com/hanzoai/cloud into drop-iam-v1-all
# Conflicts:
#	go.mod
#	go.sum
2026-07-21 17:09:54 -07:00
zeekayandhanzo-dev c00c756229 refactor(iam): drop Casdoor iam-v1 entirely — cloud embeds the clean hanzoai/iam
cloud's last tie to the retired Casdoor/Beego fork (hanzoai/iam-v1) is gone:

- clients/iam: embeds the clean hanzoai/iam (zip-native + hanzoai/orm) via
  iamserver.Mount over its own SQLite store; fail-closed 503 on boot failure.
  Removes the dual-impl identitySpec (CLOUD_IAM_IMPL=iam2 branch) + clients/iam2.
- clients/platform + clients/deploy: read the IAM-owned Project resource via
  iam/pkg/store over the embedded DB() instead of iam-v1's object-store ormer.
- cmd/hanzo: retires the `hanzo iam` Casdoor daemon subcommand.
- go.mod: the last transitive iam-v1 edge was hanzoai/ai/object; bump ai
  v1.829.3 -> v1.829.4 (Casdoor-free cut). go mod tidy drops iam-v1 entirely.

Remaining iam-v1 references are accurate "retired/gone" doc comments only.
Tests green: clients/iam, clients/platform, clients/deploy. Wire frozen-order
golden consistent (iam2->iam collapse).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-21 17:00:56 -07:00
40 changed files with 1954 additions and 1242 deletions
+6 -27
View File
@@ -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},
+54 -13
View File
@@ -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))
+22 -14
View File
@@ -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
View File
@@ -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)
}
+80
View File
@@ -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)
}
}
+174
View File
@@ -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)
}
}
+15 -5
View File
@@ -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")
+9 -27
View File
@@ -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
+236
View File
@@ -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)
}
}
+65
View File
@@ -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
View File
@@ -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 != "" {
+2 -2
View File
@@ -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"}`,
+1 -2
View File
@@ -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},
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
-152
View File
@@ -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
View File
@@ -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
+44 -35
View File
@@ -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()
}
+53 -26
View File
@@ -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 }
+12 -12
View File
@@ -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",
}
+11 -1
View File
@@ -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
+142
View File
@@ -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
View File
@@ -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
+3 -5
View File
@@ -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})
}
+68 -6
View File
@@ -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)
}
+110
View File
@@ -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)
}
}
+5
View File
@@ -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
}
+29 -8
View File
@@ -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)
}
+264
View File
@@ -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)
}
}
+5 -3
View File
@@ -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
View File
@@ -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())
+1 -1
View File
@@ -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
View File
@@ -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
+31 -36
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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{}
+3 -3
View File
@@ -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 **~600900 LOC wave of its own**, sequenced after wave 2. Visor stays standalone until then. |
## Wave 2+ — mount queue (sequential; do one, tidy, prove, next)
+5 -96
View File
@@ -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
+3 -346
View File
File diff suppressed because it is too large Load Diff