Documentation
¶
Overview ¶
Package projects is the Hanzo Cloud projects control plane: the ONE org-scoped store of buildable/deployable sites, shared by every surface that shows a user's projects.
Why it exists: hanzo.app (the builder) and console.hanzo.ai (the Projects module) must show the SAME projects for the same org. They do, because both call this one /v1/projects surface through the gateway, which mints the org (X-Org-Id) from the validated IAM JWT (HIP-0111). There is no second copy of project state anywhere — this SQLite-backed store is the source of truth; the builder keeps only per-project working state (chat, draft files) in Hanzo Base.
Surface (all org-scoped; see CONTRACT.md — the published shape console consumes):
POST /v1/projects create GET /v1/projects list (org) GET /v1/projects/:slug get 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
Sites surface (the surface-agnostic deploy_site capability, shared with agents):
POST /v1/sites generate a responsive site from a brief + deploy POST /v1/sites/deploy deploy a raw file manifest (the deploy_site tool) GET /v1/sites list the org's live sites
Releases (the server-side promote — see release.go; mirrored under /v1/platform/sites/:slug/…):
POST /v1/sites/:slug/publish promote a build output + go live POST /v1/sites/:slug/releases promote only (no flip) GET /v1/sites/:slug/releases rollback menu, newest first POST /v1/sites/:slug/releases/:release/activate flip the pointer (go live / roll back)
Deploy pipeline: a deploy uploads the built static site to OUR S3 (CLOUD_PROJECTS_BUCKET on s3.hanzo.ai) under "<org>/<slug>/", marks the bucket public-read, and records a live URL. The hanzoai/static container (the static-app image) serves the same bucket behind the gateway for a pretty host; GitHub export is an optional second step that never blocks going live.
release.go — how content GETS to a site's serving prefix.
The agentic builder produces build output INSIDE our object store (its code execution writes there). Pushing those bytes back out through an HTTP upload API and in again would be pure waste, so publishing is a SERVER-SIDE PROMOTE: no bytes traverse the API, and no client ever holds an S3 credential.
The model is values plus a pointer (HIP-0014, "Static Sites: Releases and Pointers"): a build output is promoted into a Release, and the site's pointer is flipped to it.
- A Release is a VALUE: an immutable prefix whose id is a digest of the object manifest it was built from. Identical bytes ⇒ identical id, so re-publishing the same build is idempotent by construction rather than by a remembered request key. Different bytes can never reuse an id.
- The pointer is projects.current_release. Serving reads THROUGH it (siteResolver.Resolve), so activation is one atomic UPDATE and rollback is the same flip aimed at an older release — free, because releases are immutable and retained.
TENANT ISOLATION — the make-or-break property, and the reason the source is NOT an S3 URL. A caller names a path RELATIVE to their own org's storage space; the org segment is prepended server-side from the VALIDATED principal (org(c), the one org rule this package already uses for every site prefix), and the bucket is server-owned and never appears in the request at all. So the worst a hostile source string can address is something the caller's own org already owns — "copy an arbitrary prefix" is not a reachable state, and the server-side copy is therefore not an exfiltration primitive. Traversal is killed by safeRel, the SAME rooted-clean rule the artifact walker uses.
Index ¶
- func Mount(app cloud.Router, deps cloud.Deps) error
- func SetDeployObserver(o DeployObserver)
- func Shutdown() error
- type DeployObserver
- type Deployment
- type Project
- type Release
- type Store
- func (s *Store) ActivateRelease(ctx context.Context, org, slug, id string, now int64) error
- func (s *Store) BindHost(ctx context.Context, host, org, slug string, now int64) error
- func (s *Store) Close() error
- func (s *Store) CreateProject(ctx context.Context, p Project) error
- func (s *Store) DeleteProject(ctx context.Context, org, slug string) (Project, bool, error)
- func (s *Store) DeleteReleases(ctx context.Context, org, slug string) error
- func (s *Store) GetDeployment(ctx context.Context, org, projectID, id string) (Deployment, error)
- func (s *Store) GetProject(ctx context.Context, org, slug string) (Project, error)
- func (s *Store) GetRelease(ctx context.Context, org, slug, id string) (Release, error)
- func (s *Store) InsertDeployment(ctx context.Context, d Deployment) error
- func (s *Store) ListDeployments(ctx context.Context, org, projectID string) ([]Deployment, error)
- func (s *Store) ListHostsForProject(ctx context.Context, org, slug string) ([]string, error)
- func (s *Store) ListProjects(ctx context.Context, org string) ([]Project, error)
- func (s *Store) ListReleases(ctx context.Context, org, slug string, limit int) ([]Release, error)
- func (s *Store) MarkLive(ctx context.Context, org, slug, liveURL, bucket string, lastPurgeAt, now int64) error
- func (s *Store) NextVersion(ctx context.Context, projectID string) (int, error)
- func (s *Store) ProjectOwnership(ctx context.Context, org, idOrSlug string) (mine, other bool, err error)
- func (s *Store) PutRelease(ctx context.Context, r Release) error
- func (s *Store) ResolveHost(ctx context.Context, host string) (Project, error)
- func (s *Store) ResolveOrgLiveSlug(ctx context.Context, org, slug string) (Project, error)
- func (s *Store) ResolveUniqueLiveSlug(ctx context.Context, slug string) (Project, error)
- func (s *Store) UnbindHost(ctx context.Context, host, org, slug string) error
- func (s *Store) UpdateDeployment(ctx context.Context, d Deployment) error
- func (s *Store) UpdateProject(ctx context.Context, p Project) error
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Mount ¶
Mount wires the projects surface onto app per HIP-0106. Complex flavour: it keeps a package global (mounted) for Shutdown and registers cross-package resolvers, so it constructs the Service value directly rather than through cloud.Mount.
func SetDeployObserver ¶ added in v1.801.30
func SetDeployObserver(o DeployObserver)
SetDeployObserver registers the package-level deploy observer (nil clears it). It is safe for concurrent use; the last writer wins. Wiring is intentionally package-level (not per-service) because there is one mounted projects surface per binary and the sessions lane wires the observer at startup.
Types ¶
type DeployObserver ¶ added in v1.801.30
type DeployObserver interface {
OnDeploy(ctx context.Context, org, slug, url, deploymentID string)
}
DeployObserver receives one notification whenever a site goes live. It is the seam the (separately-landed) agent-sessions lane hooks to record a "site deployed + URL" session event, WITHOUT projects taking any hard dependency on that lane: projects only ever calls this interface. The default is nil — a no-op — so a deployment that never registers an observer is unaffected.
type Deployment ¶
type Deployment struct {
ID string
ProjectID string
Org string
Version int
Status string
Source string
Commit string
LiveURL string
Bucket string
Prefix string
Files int
Bytes int64
Message string
CreatedAt int64
UpdatedAt int64
}
Deployment is one deploy attempt for a project, versioned monotonically per project. A deploy moves through queued→building→uploading→live (or →error); the upload path (tar body) lands directly in "live", the git/CI path starts "queued" and is flipped by the CI completion call.
type Project ¶
type Project struct {
ID string
Org string
Slug string
Name string
Description string
RepoURL string
RepoBranch string
RepoProvider string
Framework string
Status string
LiveURL string
Bucket string
CurrentDeploy string
// CurrentRelease is the site's serving POINTER — the id of the immutable
// Release whose prefix the site edge reads. Empty means "serve the legacy
// mutable <org>/<slug>/ prefix" (every site published before releases, and
// every site whose last go-live was a full-artifact deploy). Flipping this one
// field is the whole of activation and rollback.
CurrentRelease string
// CacheControl is the per-project override for the HTML/document Cache-Control
// header applied to deployed objects (and honored by the site server). Empty =
// the honest default (public, max-age=60, s-maxage=86400). Content-hashed
// assets are always immutable regardless of this override.
CacheControl string
// LastPurgeAt is the unix time of the last successful (or attempted) Cloudflare
// edge purge for this site. Surfaced on the API so a console can show cache freshness.
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
}
Project is the org-scoped, canonical record of a buildable/deployable site. It is the SAME record whether read from hanzo.app (the builder) or console.hanzo.ai (the Projects module): org isolation is the org column, enforced at the query layer, and the gateway-minted X-Org-Id selects the org. Repo fields are flat columns here; the HTTP surface nests them under "repo" (see projects.go). It never stores a secret.
Distinct from tracker.Project (not a duplicate): this is the Slug-keyed deployable site; a tracker.Project is a KEY-prefixed issue team that lives INSIDE one of these (the IAM/deploy project is the tracker's tenant boundary).
type Release ¶ added in v1.801.186
type Release struct {
ID string
Org string
Slug string
Prefix string
// Source is the org-relative build-output prefix this release was promoted
// from, kept for provenance. It is never re-read to serve.
Source string
Objects int
Bytes int64
CreatedAt int64
}
Release is an IMMUTABLE, content-addressed snapshot of a site's bytes at one S3 prefix. It is a VALUE, not an event: its ID is a digest of the object manifest it was built from, so publishing identical content twice yields the SAME release (idempotence for free) and different content can never reuse an ID. Nothing mutates a release after PutRelease — a rollback is a pointer flip to an older one, never a rewrite. (Distinct from Deployment, which is the EVENT log of deploy attempts: attempts fail and are still recorded; releases only exist once their bytes are fully copied.)
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the projects metadata database. ONE SQLite file ({DataDir}/projects.db) holds every org's records; org-scoping is the org column. MaxOpenConns(1) serializes writes against the file lock without busy retries.
func (*Store) ActivateRelease ¶ added in v1.801.186
ActivateRelease flips a site's serving pointer to a release. This is the whole of activation, and it is ATOMIC in the strongest available sense: ONE statement whose WHERE clause both scopes the project to the tenant AND requires a matching release row to exist in the same tenant. There is no read-then-write window in which the pointer could name a release that was never fully copied, and two concurrent activations serialize into one winner (never a blend) — SQLite runs them one at a time on the single write connection.
A release row exists only after every object was copied (PutRelease), so "pointer set" implies "content complete" by construction. n==0 means the project or the release does not exist FOR THIS TENANT; the caller renders the same 404 for both, so a foreign id yields no signal. Re-activating the already-active release matches a row and succeeds — activation is idempotent.
func (*Store) BindHost ¶
BindHost claims the global public host for (org, slug), first-come. It is idempotent for the SAME owner (a re-deploy just refreshes updated_at). If host is already bound to a DIFFERENT project it returns errHostTaken WITHOUT overwriting — a losing bind must never hijack another org's live subdomain.
func (*Store) CreateProject ¶
CreateProject inserts one project. A UNIQUE(org,slug) violation surfaces as errConflict.
func (*Store) DeleteProject ¶
DeleteProject removes a project and all its deployment rows. Reports whether a project row was deleted.
func (*Store) DeleteReleases ¶ added in v1.801.186
DeleteReleases drops every release row for a site. Called on project delete, alongside the purge of the release object space.
func (*Store) GetDeployment ¶
GetDeployment returns one deployment scoped to (org, project, id).
func (*Store) GetProject ¶
GetProject returns the project for (org,slug) or errNotFound.
func (*Store) GetRelease ¶ added in v1.801.186
GetRelease returns one release scoped to (org, slug, id), or errNotFound. The org is part of the key, not a filter applied afterwards, so a foreign release id is indistinguishable from a nonexistent one — no existence oracle.
func (*Store) InsertDeployment ¶
func (s *Store) InsertDeployment(ctx context.Context, d Deployment) error
InsertDeployment writes one deployment row.
func (*Store) ListDeployments ¶
ListDeployments returns deployments for a project, newest version first.
func (*Store) ListHostsForProject ¶ added in v1.786.165
ListHostsForProject returns every public host bound to (org, slug), oldest first. It powers GET .../domains so a console/user can see which hostnames the site serves — its `<slug>.hanzo.app` subdomain plus any bound custom domains.
func (*Store) ListProjects ¶
ListProjects returns every project for org, most-recently-updated first.
func (*Store) ListReleases ¶ added in v1.801.186
ListReleases returns a site's releases, newest first — the rollback menu.
func (*Store) MarkLive ¶ added in v1.801.186
func (s *Store) MarkLive(ctx context.Context, org, slug, liveURL, bucket string, lastPurgeAt, now int64) error
MarkLive refreshes ONLY the denormalized go-live display fields of a site. It deliberately does NOT touch current_release: ActivateRelease is the SOLE writer of the serving pointer on the activate path, so a slow activation can never lose a race to a newer one and leave the pointer disagreeing with the last atomic flip. (The two full-artifact go-live paths clear the pointer through UpdateProject — that is their intent, not a side effect.)
func (*Store) NextVersion ¶
NextVersion returns the next monotonic deploy version for a project (1-based).
func (*Store) ProjectOwnership ¶
func (s *Store) ProjectOwnership(ctx context.Context, org, idOrSlug string) (mine, other bool, err error)
ProjectOwnership reports whether a project addressed by id-or-slug is owned by org (mine) and/or by some OTHER org (other) — the cross-org impersonation signal the identity trust boundary (cloud.SanitizeIdentity) uses to refuse a forged X-Project-Id. Matched by BOTH slug and id so the check holds whichever addressing the caller used, in one indexed round trip. Org isolation is the org column, exactly as everywhere else in this store.
func (*Store) PutRelease ¶ added in v1.801.186
PutRelease records a fully-copied release. It is INSERT-only and idempotent on the content address: re-publishing identical bytes hits the (org,slug,id) PK and is a no-op rather than a conflict, because the row already describes exactly those bytes at exactly that prefix. Call it ONLY after every object has landed — the existence of the row is the promise that the prefix is complete, and ActivateRelease will not flip to a release that has no row.
func (*Store) ResolveHost ¶
ResolveHost returns the project a public host is bound to, joining the global site_hosts binding to the org-scoped project. This is the authoritative slug→project resolution the site server uses; the org and bucket come ONLY from here, never from the request. Missing binding OR missing project ⇒ errNotFound.
func (*Store) ResolveOrgLiveSlug ¶ added in v1.801.186
ResolveOrgLiveSlug resolves a bare slug PINNED to a specific org — the LIVE project that org owns with that slug. Unlike ResolveUniqueLiveSlug (unique across ALL orgs), this can never return another org's project, so a first-party site host (cd.hanzo.ai → org "hanzo", slug "cd") is served ONLY by OUR project, never shadowed by a customer who named their project "cd". (org,slug) is unique in the store, so LIMIT 1.
func (*Store) ResolveUniqueLiveSlug ¶ added in v1.801.113
ResolveUniqueLiveSlug resolves a BARE subdomain slug (`<slug>.hanzo.app`) to the single LIVE project owning that slug across all orgs. Slugs are only org-unique, so the bare host is servable ONLY when unambiguous: zero or 2+ live owners ⇒ errNotFound (each project still serves at its org-scoped host and its S3 URL). LIMIT 2 — a second row is the whole ambiguity signal; we never enumerate. This is what keeps pre-binding publishes servable with no backfill migration.
func (*Store) UnbindHost ¶
UnbindHost releases a host binding, but only the row owned by (org, slug) — so deleting project A can never drop project B's host even if they somehow shared a row (they cannot, but the scoping makes the guarantee explicit).
func (*Store) UpdateDeployment ¶
func (s *Store) UpdateDeployment(ctx context.Context, d Deployment) error
UpdateDeployment overwrites the mutable fields of a deployment (status flow).