projects

package
v1.801.381 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: Apache-2.0 Imports: 41 Imported by: 0

Documentation

Overview

Package projects is where your sites live: create one, deploy a build, roll back to any release.

It is 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.

Index

Constants

View Source
const (
	HostVerified = "verified"
	HostPending  = "pending"
)

Host statuses. A row is either serving or merely held; see the site_hosts DDL.

View Source
const (
	// Public is the default: the project appears in the community
	// catalogue and its source is mirrored to hanzo-community on git.hanzo.ai.
	Public = "public"
	// Private hides a project from the catalogue at the publisher's own
	// request. Paid: see resolve.
	Private = "private"
)

Variables

This section is empty.

Functions

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

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

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.

func Shutdown

func Shutdown() error

Shutdown closes the projects store. Idempotent. Mirrors the provisioning Shutdown contract so the serve layer releases subsystem resources uniformly.

Types

type DeployObserver

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 HostClaim

type HostClaim struct {
	Host       string
	Org        string
	Slug       string
	Status     string
	Token      string
	CreatedAt  int64
	VerifiedAt int64
}

HostClaim is one row of the public-hostname namespace as reported to its owner. Token is the DNS challenge value for a pending claim, and empty once verified — the proof has been consumed and the record can be retired.

type LiveSite

type LiveSite struct {
	Org, Slug, Name, URL string
	Repo, ForkedFrom     string
	UpdatedAt            int64
	// Upstream/License credit the third-party work a demo was published from.
	// Reported exactly as stored — this function never infers provenance, because
	// a guessed credit is worse than no credit at all.
	//
	// There is no authorship field: who published a site is Org, the account that
	// paid for it, which the tenancy boundary enforces and no request can forge.
	Upstream, License string
}

LiveSite is one deployed site as the catalog sees it. Repo and ForkedFrom are the trace back out of the demo: a live URL nobody can get from to the source is a screenshot, not a starting point.

func LiveSites

func LiveSites(ctx context.Context) ([]LiveSite, error)

LiveSites returns every project currently serving at its site host, newest first. Unmounted ⇒ no sites (a deployment that does not host is not an error).

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
	// ForkedFrom is this project's PARENT — the published example it was forked
	// from: "<org>/<slug>" for a live project, or a bare catalog template slug.
	// Empty for an original. Set by the fork path only (never by the caller, so
	// lineage cannot be forged) and immutable after create; it is the attribution
	// edge the gallery credits back to the author.
	ForkedFrom string
	// Visibility is the PUBLISHER's choice, and the only thing that decides
	// whether a project appears in the community catalogue: "public" (the
	// default) or "private". Public means the world can see it, fork it, and
	// find its source mirrored into hanzo-community. There is no application to
	// approve and no badge to be granted — a community that makes you ask
	// permission to appear in it does not grow.
	//
	// Authorship is NOT stored here. Who published a project is its Org, which
	// the tenancy boundary already enforces and which no request can forge; a
	// second field restating it could only ever disagree with it. (It did: an
	// admin-gated `official` flag meant the platform's own apps, published by a
	// script holding an ordinary org token, were filed as somebody else's work.)
	Visibility string
	// Hidden is the platform's MODERATION action, taken from admin.hanzo.ai: it
	// removes a public project from the catalogue without touching the
	// publisher's own visibility choice, so lifting the moderation restores
	// exactly what they asked for.
	//
	// This is the one admin-only field in this struct, and it is safe to be one
	// precisely because it only ever SUBTRACTS. An allowlist an admin must add
	// you to gates growth and rots the moment nobody tends it; a denylist costs
	// nothing until it is used. It is the same shape as Apex's reserved-host
	// list: everyone is in, except what we took out.
	Hidden bool
	// HiddenReason records WHY, so moderation is reviewable rather than a silent
	// disappearance. Empty whenever Hidden is false.
	HiddenReason string
	// Upstream and License are provenance: the third-party work this project was
	// published FROM, and the terms it carries.
	//
	// They are free for the publisher to set, because they can only ever
	// SUBTRACT credit from the publisher — a claim that the work is somebody
	// else's. A platform that lets you claim authorship more easily than you can
	// disclaim it is a platform that launders provenance.
	Upstream string
	License  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

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 — the system namespace's "projects" — 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

func (s *Store) ActivateRelease(ctx context.Context, org, slug, id string, now int64) error

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) and is dropped before its bytes are reclaimed (PruneReleases), so "pointer set" implies "content complete" by construction. Retention makes that a NECESSARY but no longer SUFFICIENT check — bytes can also vanish out of band (an operator purge, a bucket lifecycle rule, a prune that died mid-purge) — so activate() stats the release's entry point before calling this. 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

func (s *Store) BindHost(ctx context.Context, host, org, slug string, now int64) error

BindHost binds the global public host to (org, slug) as VERIFIED — the structural path: an org's own subdomain, or a custom host an operator has vouched for. First-come; idempotent for the SAME owner (a re-deploy just refreshes updated_at). If host is already held by a DIFFERENT project it returns errHostTaken WITHOUT overwriting — a losing bind must never hijack another org's live subdomain. A pending claim by the SAME owner is promoted.

func (*Store) ClaimHost

func (s *Store) ClaimHost(ctx context.Context, host, org, slug, token string, now int64) error

ClaimHost records a PENDING claim on host for (org, slug), carrying the DNS challenge token the owner must publish. It takes the name against the PK, so a claim blocks anyone else from claiming it, but the row does NOT route until VerifyHost promotes it.

Re-claiming keeps the EXISTING token, so a repeat call returns the same challenge rather than invalidating a record the customer already published. A claim over an ALREADY-VERIFIED row of the same owner is a no-op: verification is never walked backwards, or a re-claim would take a live host off the air.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database.

func (*Store) CreateProject

func (s *Store) CreateProject(ctx context.Context, p Project) error

CreateProject inserts one project. A UNIQUE(org,slug) violation surfaces as errConflict.

func (*Store) DeleteProject

func (s *Store) DeleteProject(ctx context.Context, org, slug string) (Project, bool, error)

DeleteProject removes a project and all its deployment rows. Reports whether a project row was deleted.

func (*Store) DeleteReleases

func (s *Store) DeleteReleases(ctx context.Context, org, slug string) error

DeleteReleases drops every release row for a site. Called on project delete, alongside the purge of the release object space.

func (*Store) GetDeployment

func (s *Store) GetDeployment(ctx context.Context, org, projectID, id string) (Deployment, error)

GetDeployment returns one deployment scoped to (org, project, id).

func (*Store) GetProject

func (s *Store) GetProject(ctx context.Context, org, slug string) (Project, error)

GetProject returns the project for (org,slug) or errNotFound.

func (*Store) GetRelease

func (s *Store) GetRelease(ctx context.Context, org, slug, id string) (Release, error)

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) HostClaimFor

func (s *Store) HostClaimFor(ctx context.Context, host, org, slug string) (HostClaim, error)

HostClaimFor returns (org, slug)'s claim on host. Scoped to the owner, so a host held by another org reads as errNotFound — existence is never confirmed across a tenant boundary.

func (*Store) InsertDeployment

func (s *Store) InsertDeployment(ctx context.Context, d Deployment) error

InsertDeployment writes one deployment row.

func (*Store) ListDeployments

func (s *Store) ListDeployments(ctx context.Context, org, projectID string) ([]Deployment, error)

ListDeployments returns deployments for a project, newest version first.

func (*Store) ListHostClaims

func (s *Store) ListHostClaims(ctx context.Context, org, slug string) ([]HostClaim, error)

ListHostClaims returns every host a project holds, verified and pending alike, for the domains panel. ListHostsForProject is the routing view (verified only); this is the ownership view.

func (*Store) ListHostsForProject

func (s *Store) ListHostsForProject(ctx context.Context, org, slug string) ([]string, error)

ListHostsForProject returns every public host that (org, slug) actually SERVES, oldest first — its `<slug>.hanzo.app` subdomain plus any verified custom domains. This is the routing view, so it excludes pending claims: reporting a name the edge will not answer for as a "domain" would be a lie the customer debugs for an hour. ListHostClaims is the ownership view that shows both.

func (*Store) ListProjects

func (s *Store) ListProjects(ctx context.Context, org string) ([]Project, error)

ListProjects returns every project for org, most-recently-updated first.

func (*Store) ListReleases

func (s *Store) ListReleases(ctx context.Context, org, slug string, limit int) ([]Release, error)

ListReleases returns a site's releases, newest first — the rollback menu.

The tiebreak is rowid DESC, i.e. INSERTION order, because created_at has second granularity and a CI job can publish twice inside one second. It is the same order PruneReleases applies, and that is the point: the menu a caller sees must be exactly the set retention keeps, or a tie would show one release and reclaim another.

func (*Store) MarkLive

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

func (s *Store) NextVersion(ctx context.Context, projectID string) (int, error)

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) PruneReleases

func (s *Store) PruneReleases(ctx context.Context, org, slug string, keep int) ([]Release, error)

PruneReleases drops a site's release rows beyond the newest keep and returns EXACTLY the rows it removed, so the caller frees exactly those bytes and no others. Retention is per site; keep<=0 prunes nothing (a misconfigured depth must not shred a site's history).

The live release is protected TWICE, and the second guard is the one that holds under concurrency:

  • it is excluded from the candidate query (`id <> current_release`), so it never even counts against the keep budget — a rollback to an ancient release keeps that release alive however deep it has sunk;
  • every DELETE additionally requires the row NOT be the project's current_release AT DELETE TIME. So a rollback that activates a doomed release between the scan and the delete makes that delete match zero rows, and the row (with its bytes) survives. The two statements are the serialized SQLite writers of the same pointer, so there is no interleaving in which activation wins the pointer and prune still wins the row.

Rows go FIRST and bytes after (the caller's half), which is exactly promote's ordering run backwards: a row's existence keeps meaning "the prefix is complete", so no reader can ever reach a half-reclaimed release. A crash between the two leaks objects nothing points at — the same convergent failure promote already accepts — never a live 404.

func (*Store) PutRelease

func (s *Store) PutRelease(ctx context.Context, r Release) error

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

func (s *Store) ResolveHost(ctx context.Context, host string) (Project, error)

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.

The status filter is THE hostname-hijack boundary and the only read that enforces it: a pending claim holds its name against the PK but must never serve, or claiming yourco.com would be enough to answer for it. It is the sole routing read of site_hosts — ResolveUniqueLiveSlug resolves bare slugs out of `projects` and never sees this table — so gating it here gates everything.

func (*Store) ResolveOrgLiveSlug

func (s *Store) ResolveOrgLiveSlug(ctx context.Context, org, slug string) (Project, error)

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

func (s *Store) ResolveUniqueLiveSlug(ctx context.Context, slug string) (Project, error)

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

func (s *Store) UnbindHost(ctx context.Context, host, org, slug string) error

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).

func (*Store) UpdateProject

func (s *Store) UpdateProject(ctx context.Context, p Project) error

UpdateProject overwrites the mutable fields of an existing project. The caller reads-modifies-writes the whole Project; org+slug+id+created_at are immutable.

func (*Store) VerifyHost

func (s *Store) VerifyHost(ctx context.Context, host, org, slug string, now int64) error

VerifyHost promotes (org, slug)'s pending claim on host to verified, so it begins routing. Scoped to the owner: another org's row is never touched, and a host that is not claimed by this project is errNotFound. Idempotent — verifying an already-verified host succeeds without moving verified_at.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL