diff --git a/claude.md b/claude.md index 5815aa4..8ca109a 100644 --- a/claude.md +++ b/claude.md @@ -303,10 +303,15 @@ console POST /console/connect · GET /console/tunnel (websocket) audit GET /audit agent GET /agent/latest-version settings GET,PUT /settings · POST /settings/secrets-token (owner|admin) +licence GET /license · POST /license (POST: self-hosted only) org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id GET,PUT /org/oidc (owner|admin) ``` +`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy. + +`POST /license` is also in `licenceExemptPaths`: pasting a valid licence has to work while the current one is expired, because it is the way out of degraded mode. + --- ## Admin REST API (`admin`, :8083) diff --git a/server/internal/api/licence.go b/server/internal/api/licence.go index d6ab498..261dd6c 100644 --- a/server/internal/api/licence.go +++ b/server/internal/api/licence.go @@ -102,6 +102,10 @@ type licenceResponse struct { Features map[string]bool `json:"features"` Usage licenceUsageResponse `json:"usage"` Source string `json:"source"` + // Deployment is what tells the UI whether this instance owns its licence. + // On cloud the licence is injected by admin and there is nothing for a + // customer to paste, so the UI sends them to the portal instead. + Deployment string `json:"deployment"` } type licenceUsageResponse struct { @@ -125,6 +129,7 @@ func getLicence(c *gin.Context) { Features: st.Features, Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels}, Source: st.Source, + Deployment: services.DeploymentMode(), } if st.ExpiresAt != nil { d := int(time.Until(*st.ExpiresAt).Hours() / 24) @@ -163,6 +168,19 @@ func licencePostAllowed(instanceID string) bool { func postLicence(c *gin.Context) { instanceID := auth.InstanceID(c) + + // A cloud instance's licence is written by admin straight into the database + // (admin/internal/inject), never through this endpoint, so refusing here + // cannot break injection. Hiding the form in web/ is a courtesy; this is the + // boundary, the same split as an hq-managed user's role. + if services.DeploymentMode() == license.DeploymentCloud { + c.JSON(http.StatusConflict, gin.H{ + "error": "This instance's licence is managed in Vantage HQ and cannot be set here.", + "reason": "cloud_managed", + }) + return + } + if !licencePostAllowed(instanceID) { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Too many licence attempts. Try again later.", diff --git a/web/app/(app)/settings/license/page.tsx b/web/app/(app)/settings/license/page.tsx index 60f2729..8a91c78 100644 --- a/web/app/(app)/settings/license/page.tsx +++ b/web/app/(app)/settings/license/page.tsx @@ -206,6 +206,12 @@ export default function LicensePage() { ); } + // A cloud instance's licence is injected by admin, so there is nothing for + // a customer to paste. The API refuses the POST either way; this is what + // stops the page offering an action that cannot succeed. + const isCloud = license.deployment === "cloud"; + const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? ""; + return (
@@ -231,6 +237,24 @@ export default function LicensePage() { + {isCloud ? ( + + +

+ This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste. +

+ {hqUrl && ( +
+ + + +
+ )} +
+
+ ) : (
+ )}
); diff --git a/web/lib/api.ts b/web/lib/api.ts index 9ed0b0e..2b70603 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -808,6 +808,8 @@ export interface LicenseInfo { features: Record; usage: { servers: number; secret_groups: number; channels: number }; source: string; + /** "cloud" | "self_hosted". A cloud instance's licence is managed in HQ. */ + deployment: string; } // `request` already prefixes /api, so these paths do not repeat it.