A cloud instance's licence is issued in HQ and written into the control plane by admin/internal/inject, straight to the database. The customer has nothing to paste, so /settings/license offered them a form that could only ever fail — and on an expired cloud instance, failed at the exact moment they were looking for a way out. GET /license now reports `deployment`, and the page swaps the paste form for a short panel saying where the licence comes from, with a link to the portal when HQ_URL is set. That is the same treatment hq-managed members already get in the members table: read-only here, and a pointer to where it is actually managed. POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding a form is a courtesy; this codebase's rule is that the API is the boundary, and the endpoint was reachable regardless of what the page rendered. It cannot break injection, which never goes through HTTP at all. Verified: server builds and vets clean, web builds clean.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-8">
|
||||
<div className="mx-auto max-w-5xl space-y-10">
|
||||
@@ -231,6 +237,24 @@ export default function LicensePage() {
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
{isCloud ? (
|
||||
<Group label="Where this licence comes from">
|
||||
<Card>
|
||||
<p className="max-w-prose text-sm text-text-secondary">
|
||||
This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.
|
||||
</p>
|
||||
{hqUrl && (
|
||||
<div className="mt-5 border-t border-border-soft pt-5">
|
||||
<a href={hqUrl} target="_blank" rel="noreferrer">
|
||||
<Button type="button" variant="secondary">
|
||||
Open Vantage HQ
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Group>
|
||||
) : (
|
||||
<Group label="Add or replace">
|
||||
<Card>
|
||||
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
@@ -276,6 +300,7 @@ export default function LicensePage() {
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -808,6 +808,8 @@ export interface LicenseInfo {
|
||||
features: Record<string, boolean>;
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user