feat: Updated package path to match repo
Agent Release / build (push) Failing after 25s
Agent Release / msi (push) Skipped
Server Deploy / deploy (push) Successful in 2m47s

This commit is contained in:
2026-07-28 10:01:40 +01:00
parent a232c74990
commit dabe6fe3aa
111 changed files with 1988 additions and 1899 deletions
+3 -3
View File
@@ -15,11 +15,11 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"golang.org/x/crypto/bcrypt"
)
+13 -13
View File
@@ -10,20 +10,20 @@ import (
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/lifecycle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/lifecycle"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
sharedmail "github.com/mrhid6/vantage/shared/mail"
)
func main() {
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26
@@ -6,7 +6,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
@@ -52,4 +52,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
+8 -8
View File
@@ -6,16 +6,16 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+11 -11
View File
@@ -9,19 +9,19 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+5 -5
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+8 -8
View File
@@ -6,16 +6,16 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -6,11 +6,11 @@ import (
"log"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
)
// paddleWebhook is the ingress for every Paddle event.
+6 -6
View File
@@ -7,13 +7,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+3 -3
View File
@@ -10,10 +10,10 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
)
func Routes(cfg config.Config) http.Handler {
+11 -11
View File
@@ -6,15 +6,15 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -396,10 +396,10 @@ func staffBillingHealth(c *gin.Context) {
_ = cur.All(ctx, &unlinked)
}
c.JSON(http.StatusOK, gin.H{
"failed_events": failed,
"failed_count": len(failed),
"unlinked_paid": unlinked,
"unlinked_count": len(unlinked),
"failed_events": failed,
"failed_count": len(failed),
"unlinked_paid": unlinked,
"unlinked_count": len(unlinked),
})
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
+5 -5
View File
@@ -10,13 +10,13 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmail "github.com/mrhid6/vantage/shared/mail"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"net/http"
"slices"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
+7 -7
View File
@@ -6,14 +6,14 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -3,10 +3,10 @@ package billing
import (
"context"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
+7 -7
View File
@@ -5,14 +5,14 @@ import (
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"errors"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
var (
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// Item is one Paddle line item: a price and how many of it.
+3 -3
View File
@@ -15,10 +15,10 @@ import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+5 -5
View File
@@ -8,12 +8,12 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+4 -4
View File
@@ -6,11 +6,11 @@ import (
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+4 -4
View File
@@ -11,10 +11,10 @@ import (
"slices"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+1 -1
View File
@@ -7,7 +7,7 @@
// api, auth, billing and lifecycle.
package mail
import "github.com/mrhid6/vantage/shared/mail"
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
// Default is admin's sender. Set once by main; read everywhere else.
var Default mail.Sender
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package models
import (
"context"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"errors"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"encoding/json"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -142,11 +142,11 @@ type Instance struct {
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
// clears it, so the next term starts the sequence again. It is what stops a
// restart re-sending a notice.
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a self-hosted instance row exists only so a
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"os/signal"
"syscall"
"github.com/mrhid6/vantage/agent/internal/config"
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync"
)
var Version = "dev"
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/agent
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
go 1.26
+2 -19
View File
@@ -11,12 +11,9 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
type streamWriter struct {
mu sync.Mutex
seq uint64
@@ -35,21 +32,10 @@ func (w *streamWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func WorkspacePath(workspaceID string) string {
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
}
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
@@ -102,7 +88,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
}
}
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
default:
default:
scriptPath = filepath.Join(dir, "step.sh")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
res.ExitCode = 1
@@ -126,7 +112,6 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
c.Stderr = sw
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[vantage] step timed out"
@@ -141,8 +126,6 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
return res
}
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
+1 -5
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -27,8 +27,6 @@ func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
dialOpts := []grpc.DialOption{
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
@@ -150,8 +148,6 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
return err
}
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
+3 -5
View File
@@ -1,5 +1,3 @@
package inventory
import (
@@ -10,7 +8,7 @@ import (
"syscall"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
@@ -42,11 +40,11 @@ func cpuSample() (idle, total uint64) {
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text())
fields := strings.Fields(sc.Text())
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 {
if i == 3 {
idle = n
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
+4 -12
View File
@@ -1,6 +1,3 @@
package monitors
import (
@@ -9,13 +6,12 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/checker"
"github.com/mrhid6/vantage/agent/internal/config"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
)
const syncInterval = 30 * time.Second
type runner struct {
@@ -23,13 +19,10 @@ type runner struct {
cancel context.CancelFunc
}
func Run(ctx context.Context, cfg *config.Config) {
active := map[string]*runner{}
var mu sync.Mutex
results := make(chan pb.CheckResult, 64)
go reporter(ctx, cfg, results)
@@ -128,7 +121,6 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
}
}
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
+15 -38
View File
@@ -17,14 +17,14 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/config"
agentexec "github.com/mrhid6/vantage/agent/internal/exec"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/inventory"
"github.com/mrhid6/vantage/agent/internal/keys"
"github.com/mrhid6/vantage/agent/internal/monitors"
"github.com/mrhid6/vantage/agent/internal/updates"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
)
func Run(ctx context.Context, cfg *config.Config, version string) error {
@@ -34,7 +34,6 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
defer client.Close()
if cfg.PreRegToken != "" {
log.Println("registering with server...")
hostname, _ := os.Hostname()
@@ -64,22 +63,17 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
return fmt.Errorf("no agent token available registration required")
}
go runCommandStream(ctx, cfg)
go runUpdateCheck(ctx, cfg)
go runInventory(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
@@ -123,8 +117,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
@@ -177,9 +169,6 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
log.Println("command stream connected")
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
sendMu.Lock()
@@ -219,7 +208,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
res := agentexec.RunStep(rc, emit)
res.CommandId = cid
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
@@ -279,8 +268,6 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
}
}
func runInventory(ctx context.Context, cfg *config.Config) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
@@ -298,7 +285,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
}
}
report(true)
report(true)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
tick := 0
@@ -308,7 +295,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
return
case <-ticker.C:
tick++
report(tick%30 == 0)
report(tick%30 == 0)
}
}
}
@@ -321,7 +308,6 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
}
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return
@@ -363,21 +349,19 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
}
u := cmd.UpdateAgent
arch := runtime.GOARCH
arch := runtime.GOARCH
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
tmpBin := "/tmp/vantage-agent-update"
if err := downloadFile(binaryURL, tmpBin); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
}
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
@@ -402,11 +386,6 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
@@ -434,8 +413,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
@@ -444,7 +422,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
resp, err := http.Get(url)
if err != nil {
return err
}
@@ -462,7 +440,7 @@ func downloadFile(url, dest string) error {
}
func httpGetBytes(url string) ([]byte, error) {
resp, err := http.Get(url)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
@@ -554,7 +532,6 @@ func localIP() string {
return ""
}
func GenerateAndUpload(cfg *config.Config, label string) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
+153 -134
View File
@@ -12,13 +12,13 @@
- **No automated Go tests.** Verification is by compiler, `grep`, `lkctl`, and running the built image against scratch databases. Every "confirm" step below is a container command with expected output.
- **Never run `go run` or `npm` on the host.** Everything runs in a container. The wrapper from plans 1 and 2:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
-e LICENSE_SIGNING_KEY="$LICENSE_SIGNING_KEY" golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry" — this exact bug shipped and was caught by the image build in plan 2, not by `go build`.
- Admin is **excluded from the self-hosted deployment**: `deploy/docker-compose.site.yml` only, like sitesvc.
@@ -40,29 +40,29 @@ There is deliberately no automated backfill. Those instances get licensed by han
**Created:**
| Path | Responsibility |
|---|---|
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` |
| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP |
| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface |
| `admin/internal/config/config.go` | env parsing, fail-fast validation |
| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation |
| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
| `admin/internal/models/plans.go` | plan seeding from `shared/license` |
| `admin/internal/licensing/issue.go` | `Issue`, the Free rule, the deployment check, supersession |
| `admin/internal/licensing/link.go` | `LinkInstance`, `Relink` |
| `admin/internal/inject/inject.go` | `InjectCloud`, `Reconcile`, `StartReconciler` |
| `admin/internal/auth/session.go` | Redis sessions, `admin_session` cookie |
| `admin/internal/auth/staff.go` | staff login |
| `admin/internal/auth/cloud.go` | cloud-owner login against the control plane |
| `admin/internal/auth/customer.go` | self-hosted customer login, signup, email verification |
| `admin/internal/auth/ratelimit.go` | per-email and per-IP limiters |
| `admin/internal/auth/middleware.go` | `RequireStaff`, `RequireCustomer`, `AccountID` |
| `admin/internal/api/routes.go` | route table — the single place scoping is guaranteed |
| `admin/internal/api/customer.go` | customer handlers |
| `admin/internal/api/staff.go` | staff handlers |
| `admin/internal/mail/mail.go` | licence delivery and verification email |
| `admin/internal/audit/audit.go` | `admin_audit` writes |
| Path | Responsibility |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` |
| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP |
| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface |
| `admin/internal/config/config.go` | env parsing, fail-fast validation |
| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation |
| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
| `admin/internal/models/plans.go` | plan seeding from `shared/license` |
| `admin/internal/licensing/issue.go` | `Issue`, the Free rule, the deployment check, supersession |
| `admin/internal/licensing/link.go` | `LinkInstance`, `Relink` |
| `admin/internal/inject/inject.go` | `InjectCloud`, `Reconcile`, `StartReconciler` |
| `admin/internal/auth/session.go` | Redis sessions, `admin_session` cookie |
| `admin/internal/auth/staff.go` | staff login |
| `admin/internal/auth/cloud.go` | cloud-owner login against the control plane |
| `admin/internal/auth/customer.go` | self-hosted customer login, signup, email verification |
| `admin/internal/auth/ratelimit.go` | per-email and per-IP limiters |
| `admin/internal/auth/middleware.go` | `RequireStaff`, `RequireCustomer`, `AccountID` |
| `admin/internal/api/routes.go` | route table — the single place scoping is guaranteed |
| `admin/internal/api/customer.go` | customer handlers |
| `admin/internal/api/staff.go` | staff handlers |
| `admin/internal/mail/mail.go` | licence delivery and verification email |
| `admin/internal/audit/audit.go` | `admin_audit` writes |
**Modified:** `go.work`, `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`.
@@ -71,14 +71,16 @@ There is deliberately no automated backfill. Those instances get licensed by han
### Task 1: Module, config and boot
**Files:**
- Create: `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore`, `admin/cmd/main.go`, `admin/internal/config/config.go`, `admin/internal/db/db.go`
- Modify: `go.work`
**Interfaces:**
- Consumes: `shared/models`, `shared/license`
- Produces:
- `config.Config` with `Load() (Config, error)`
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
- `config.Config` with `Load() (Config, error)`
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
- [x] **Step 1: Create the module**
@@ -90,7 +92,7 @@ mkdir -p admin/cmd admin/internal/config admin/internal/db
Create `admin/go.mod`:
```
module github.com/mrhid6/vantage/admin
module gitea.hostxtra.co.uk/mrhid6/vantage/admin
go 1.26.4
@@ -98,13 +100,13 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mrhid6/vantage/shared v0.0.0
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```
Add the module to `go.work`:
@@ -255,7 +257,7 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -323,8 +325,8 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
)
func main() {
@@ -445,16 +447,18 @@ git commit -m "feat(admin): module skeleton, config and two database connections
### Task 2: Models, indexes and the plan seed
**Files:**
- Create: `admin/internal/models/models.go`, `admin/internal/models/plans.go`
- Modify: `admin/internal/db/db.go` (add `EnsureIndexes`), `admin/cmd/main.go` (call it)
**Interfaces:**
- Consumes: `shared/license`
- Produces:
- `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry`
- status and reason constants
- `models.SeedPlans(ctx) error`
- `db.EnsureIndexes(ctx) error`
- `models.Account`, `models.Instance`, `models.License`, `models.Subscription`, `models.Plan`, `models.StaffUser`, `models.CustomerUser`, `models.AuditEntry`
- status and reason constants
- `models.SeedPlans(ctx) error`
- `db.EnsureIndexes(ctx) error`
- [x] **Step 1: Write the documents**
@@ -471,7 +475,7 @@ package models
import (
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -639,8 +643,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -761,7 +765,7 @@ In `admin/cmd/main.go`, after the connect block:
idxCancel()
```
Import `"github.com/mrhid6/vantage/admin/internal/models"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"`.
- [x] **Step 5: Build**
@@ -785,15 +789,17 @@ git commit -m "feat(admin): documents, indexes and the plan seed"
The core of the service. Everything else exists to call this correctly.
**Files:**
- Create: `admin/internal/licensing/issue.go`, `admin/internal/audit/audit.go`
**Interfaces:**
- Consumes: `models`, `db`, `license.Sign`
- Produces:
- `licensing.Issue(ctx, in IssueInput) (*models.License, error)`
- `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }`
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
- `audit.Write(ctx, e models.AuditEntry)`
- `licensing.Issue(ctx, in IssueInput) (*models.License, error)`
- `type IssueInput struct { InstanceID, Tier, Term, Reason, IssuedBy string; ExpiresAt time.Time }`
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
- `audit.Write(ctx, e models.AuditEntry)`
- [x] **Step 1: Write the audit helper**
@@ -809,8 +815,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
@@ -838,10 +844,10 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1016,7 +1022,7 @@ In `admin/cmd/main.go`, after config loads:
licensing.SetSigningKey(cfg.SigningKey)
```
Import `"github.com/mrhid6/vantage/admin/internal/licensing"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"`.
- [x] **Step 4: Build**
@@ -1038,15 +1044,17 @@ git commit -m "feat(admin): licence issuance with plan snapshots and supersessio
### Task 4: Injection and reconciliation
**Files:**
- Create: `admin/internal/inject/inject.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `db.Control`, `models`
- Produces:
- `inject.Cloud(ctx, lic *models.License) error`
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
- `inject.StartReconciler(ctx)`
- `inject.Cloud(ctx, lic *models.License) error`
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
- `inject.StartReconciler(ctx)`
- [x] **Step 1: Write it**
@@ -1066,10 +1074,10 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1215,7 +1223,7 @@ In `admin/cmd/main.go`, before the HTTP server starts:
inject.StartReconciler(reconcileCtx)
```
Import `"github.com/mrhid6/vantage/admin/internal/inject"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"`.
- [x] **Step 3: Build**
@@ -1245,16 +1253,18 @@ git commit -m "feat(admin): cloud injection and the 15-minute reconciler"
### Task 5: Sessions and staff auth
**Files:**
- Create: `admin/internal/auth/session.go`, `admin/internal/auth/staff.go`, `admin/internal/auth/middleware.go`, `admin/cmd/adminctl/main.go`
**Interfaces:**
- Consumes: `db`, Redis
- Produces:
- `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"`
- `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie`
- `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session`
- `auth.HandleStaffLogin`, `auth.HandleLogout`
- `adminctl staff-add`
- `auth.Session{UserID, Kind, Email, AccountID}` where `Kind` is `"staff"` or `"customer"`
- `auth.InitRedis(addr)`, `auth.Save`, `auth.Get`, `auth.Destroy`, `auth.SetCookie`
- `auth.RequireStaff()`, `auth.RequireCustomer()`, `auth.Current(c) *Session`
- `auth.HandleStaffLogin`, `auth.HandleLogout`
- `adminctl staff-add`
- [x] **Step 1: Write sessions**
@@ -1430,9 +1440,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1587,9 +1597,9 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1688,9 +1698,11 @@ git commit -m "feat(admin): sessions, staff auth and adminctl"
### Task 6: Cloud customer auth
**Files:**
- Create: `admin/internal/auth/cloud.go`
**Interfaces:**
- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models`
- Produces: `auth.HandleCloudLogin`
@@ -1706,10 +1718,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
adminmodels "github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
adminmodels "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -1817,9 +1829,11 @@ git commit -m "feat(admin): cloud owner login against the control plane"
### Task 7: Self-hosted customer auth
**Files:**
- Create: `admin/internal/auth/customer.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `db.Admin("customer_users")`, config SMTP
- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense`
@@ -1903,10 +1917,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -2068,16 +2082,18 @@ git commit -m "feat(admin): self-hosted customer accounts with email verificatio
### Task 8: Linking, relink and the customer API
**Files:**
- Create: `admin/internal/licensing/link.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `licensing.Issue`, `inject.Deliver`, `mail.SendLicense`
- Produces:
- `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)`
- `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)`
- `api.Routes(cfg) http.Handler`
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
- `licensing.LinkInstance(ctx, accountID, instanceID, name string) (*models.Instance, error)`
- `licensing.Relink(ctx, accountID, oldID, newID string, staff bool) (*models.License, error)`
- `api.Routes(cfg) http.Handler`
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
- [x] **Step 1: Write linking and relink**
@@ -2093,10 +2109,10 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -2220,13 +2236,13 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -2404,8 +2420,8 @@ import (
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
)
func Routes(cfg config.Config) http.Handler {
@@ -2481,7 +2497,7 @@ In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
Handler: api.Routes(cfg),
```
Import `"github.com/mrhid6/vantage/admin/internal/api"`.
Import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/api"`.
- [x] **Step 5: Build**
@@ -2503,9 +2519,11 @@ git commit -m "feat(admin): linking, relink and the scoped customer API"
### Task 9: Staff API
**Files:**
- Create: `admin/internal/api/staff.go`
**Interfaces:**
- Consumes: `licensing.Issue`, `licensing.Relink`, `models`
- Produces: the handlers named in `routes.go`
@@ -2522,13 +2540,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -2888,6 +2906,7 @@ git commit -m "feat(admin): staff API"
### Task 10: Deployment wiring
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`
- [x] **Step 1: Add the service**
@@ -2895,26 +2914,26 @@ git commit -m "feat(admin): staff API"
In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
```yaml
admin:
admin:
image: ${DOCKER_HOST}/mrhid6/vantage-admin:latest
restart: unless-stopped
ports:
- "8083:8083"
- "8083:8083"
environment:
ADMIN_MONGO_URI: ${ADMIN_MONGO_URI}
CONTROL_MONGO_URI: ${CONTROL_MONGO_URI}
REDIS_ADDR: redis:6379
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY}
PUBLIC_URL: ${ADMIN_PUBLIC_URL}
ADMIN_ORIGIN: ${ADMIN_ORIGIN}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_FROM: ${SMTP_FROM}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
TRUST_PROXY: "true"
ADMIN_MONGO_URI: ${ADMIN_MONGO_URI}
CONTROL_MONGO_URI: ${CONTROL_MONGO_URI}
REDIS_ADDR: redis:6379
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY}
PUBLIC_URL: ${ADMIN_PUBLIC_URL}
ADMIN_ORIGIN: ${ADMIN_ORIGIN}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_FROM: ${SMTP_FROM}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
TRUST_PROXY: "true"
depends_on:
- redis
- redis
```
`LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all.
@@ -3149,14 +3168,14 @@ git commit -m "chore: verify the admin backend end to end" --allow-empty
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped |
| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes |
| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it |
| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error |
| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 |
| Risk | Mitigation |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped |
| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes |
| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it |
| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error |
| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 |
| Existing cloud instances stay read-only longer than intended | Licensing them is the first job the admin UI is used for; Task 11 Step 5 proves the flow before the UI exists |
| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have |
| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have |
@@ -28,14 +28,14 @@
**Created:**
| Path | Responsibility |
|---|---|
| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode |
| Path | Responsibility |
| -------------------------------------------- | ------------------------------------------------------------------------------------ |
| `server/internal/services/licence.go` | `State`, `LicenseState`, cache, `StoreLicense`, deployment mode |
| `server/internal/services/licence_limits.go` | `CheckServerLimit`, `CheckSecretGroupLimit`, `CheckChannelLimit`, `ErrLimitExceeded` |
| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` |
| `web/app/(app)/settings/license/page.tsx` | Licence settings page |
| `web/components/LicenseBanner.tsx` | Persistent banner |
| `web/lib/useLicense.ts` | `useLicense()` hook |
| `server/internal/api/licence.go` | `GET`/`POST /api/license`, `RequireActiveLicense`, `RequireFeature` |
| `web/app/(app)/settings/license/page.tsx` | Licence settings page |
| `web/components/LicenseBanner.tsx` | Persistent banner |
| `web/lib/useLicense.ts` | `useLicense()` hook |
**Modified:** `shared/models/instance.go`, `server/internal/api/handlers.go`, `server/internal/services/servers.go`, `secrets.go`, `channels.go`, `server/internal/auth/oidc.go`, `server/internal/auth/instancehost.go`, `server/internal/api/org.go` (renamed), `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/layout.tsx`, `web/app/setup/page.tsx`.
@@ -46,11 +46,13 @@
Plan 0b's naming map said the rename was total. It is not: 18 private identifiers still say `Org`. None affect the wire format, the database or any route, so nothing is broken — but this plan adds a licence cache next to the instance cache in the same file, and leaving two naming conventions side by side there is how the next person gets confused.
**Files:**
- Modify: `server/internal/auth/instancehost.go`, `server/internal/auth/oidc.go`, `server/internal/auth/session.go`
- Modify: `server/internal/services/instance_oidc.go`
- Rename: `server/internal/api/org.go``server/internal/api/instance.go`
**Interfaces:**
- Consumes: nothing
- Produces: `services.SaveInstanceOIDC` replacing `SaveOrgOIDC`; `auth.SaveStateInstance` / `auth.ConsumeStateInstance` replacing the `...Org` forms
@@ -118,9 +120,11 @@ field or route changes."
### Task 2: Store the licence on the instance
**Files:**
- Modify: `shared/models/instance.go`
**Interfaces:**
- Consumes: nothing
- Produces: `models.Instance` gains `LicenseBlob string`, `LicenseTier string`, `LicenseExpiry *time.Time`
@@ -177,18 +181,20 @@ git commit -m "feat(shared): add licence fields to Instance"
### Task 3: Runtime licence state
**Files:**
- Create: `server/internal/services/licence.go`
**Interfaces:**
- Consumes: `license.Verify`, `license.Result`, `services.GetInstance`
- Produces:
- `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }`
- `func (s LicenseState) Active() bool`
- `func (s LicenseState) Feature(name string) bool`
- `func DeploymentMode() string`
- `func GetLicenseState(instanceID string) LicenseState`
- `func StoreLicense(instanceID, blob string) (LicenseState, error)`
- `func InvalidateLicenseCache(instanceID string)`
- `type LicenseState struct { Status license.State; Reason, Tier string; ExpiresAt *time.Time; Limits license.Limits; Features map[string]bool; Source string }`
- `func (s LicenseState) Active() bool`
- `func (s LicenseState) Feature(name string) bool`
- `func DeploymentMode() string`
- `func GetLicenseState(instanceID string) LicenseState`
- `func StoreLicense(instanceID, blob string) (LicenseState, error)`
- `func InvalidateLicenseCache(instanceID string)`
- [ ] **Step 1: Write it**
@@ -205,8 +211,8 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -391,17 +397,19 @@ git commit -m "feat(server): resolve licence state per instance"
Limits need a count, which middleware does not have.
**Files:**
- Create: `server/internal/services/licence_limits.go`
- Modify: `server/internal/services/servers.go` (`CreateServer`), `secrets.go` (`UpsertSecrets`), `channels.go` (`CreateChannel`)
**Interfaces:**
- Consumes: `GetLicenseState`, `license.WithinLimit`
- Produces:
- `type LimitError struct { Limit string; Current, Max int }` with `Error() string`
- `func CheckServerLimit(instanceID string) error`
- `func CheckSecretGroupLimit(instanceID, group string) error`
- `func CheckChannelLimit(instanceID string) error`
- `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)`
- `type LimitError struct { Limit string; Current, Max int }` with `Error() string`
- `func CheckServerLimit(instanceID string) error`
- `func CheckSecretGroupLimit(instanceID, group string) error`
- `func CheckChannelLimit(instanceID string) error`
- `func LicenseUsage(instanceID string) (servers, secretGroups, channels int)`
- [ ] **Step 1: Write the limit checks**
@@ -415,8 +423,8 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -574,15 +582,17 @@ git commit -m "feat(server): enforce licence limits on servers, secret groups an
### Task 5: The API and the two middlewares
**Files:**
- Create: `server/internal/api/licence.go`
- Modify: `server/internal/api/handlers.go`
**Interfaces:**
- Consumes: `GetLicenseState`, `StoreLicense`, `LicenseUsage`, `LimitError`
- Produces:
- `func RequireActiveLicense() gin.HandlerFunc`
- `func RequireFeature(name string) gin.HandlerFunc`
- `GET /api/license`, `POST /api/license`
- `func RequireActiveLicense() gin.HandlerFunc`
- `func RequireFeature(name string) gin.HandlerFunc`
- `GET /api/license`, `POST /api/license`
- [ ] **Step 1: Write the handlers and middlewares**
@@ -597,10 +607,10 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
@@ -924,10 +934,12 @@ git commit -m "feat(server): gate mutations and features on the licence"
The paths that do not go through gin. This is where "read-only" has to be specific.
**Files:**
- Modify: `server/internal/monitorsched/scheduler.go` (comment only)
- Modify: `server/internal/grpc/server.go` (`Register`)
**Interfaces:**
- Consumes: `services.GetLicenseState`, `services.CheckServerLimit`
- Produces: no new API
@@ -997,10 +1009,12 @@ ability to know their infrastructure is on fire."
A self-hosted operator needs the UUID to activate a licence, and `/setup` is where they first need it.
**Files:**
- Modify: `server/internal/auth/local.go` (`HandleBootstrap` response)
- Modify: `web/app/setup/page.tsx`
**Interfaces:**
- Consumes: nothing
- Produces: `POST /auth/bootstrap` response gains `instance_id`
@@ -1025,11 +1039,7 @@ In `web/app/setup/page.tsx`, in the "Instance created" block, add below the slug
<p className="text-xs text-text-tertiary">Instance ID — needed to activate a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm text-text-primary">{created.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(created.instance_id)}
>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(created.instance_id)}>
Copy
</button>
</div>
@@ -1059,10 +1069,12 @@ git commit -m "feat: show the instance ID after setup"
### Task 8: Frontend licence state
**Files:**
- Create: `web/lib/useLicense.ts`, `web/components/LicenseBanner.tsx`, `web/app/(app)/settings/license/page.tsx`
- Modify: `web/lib/api.ts`, `web/app/(app)/layout.tsx`, `web/components/Sidebar.tsx`
**Interfaces:**
- Consumes: `GET /api/license`, `POST /api/license`
- Produces: `useLicense()`, `<LicenseBanner />`
@@ -1144,9 +1156,10 @@ export function LicenseBanner() {
const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
return (
<div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
Your Vantage licence expired on {when}. Your servers and monitors are still running,
but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">
Add a licence
</Link>
</div>
);
}
@@ -1155,7 +1168,9 @@ export function LicenseBanner() {
return (
<div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
This instance has no valid licence. Changes are disabled.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
<Link href="/settings/license" className="underline">
Add a licence
</Link>
</div>
);
}
@@ -1218,19 +1233,18 @@ export default function LicensePage() {
<section className="rounded border border-border p-4">
<p className="text-sm text-text-secondary">
State: <b>{license.state}</b>
{license.tier ? <> · Tier: <b>{license.tier}</b></> : null}
{license.tier ? (
<>
{" "}
· Tier: <b>{license.tier}</b>
</>
) : null}
{license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
</p>
<p className="mt-2 text-xs text-text-tertiary">
Instance ID — quote this when buying or activating a licence
</p>
<p className="mt-2 text-xs text-text-tertiary">Instance ID — quote this when buying or activating a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{license.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(license.instance_id)}
>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(license.instance_id)}>
Copy
</button>
</div>
@@ -1239,9 +1253,15 @@ export default function LicensePage() {
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Usage</h2>
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
<li>Servers: {license.usage.servers} of {cap(license.limits.max_servers)}</li>
<li>Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}</li>
<li>Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}</li>
<li>
Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
</li>
<li>
Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
</li>
<li>
Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
</li>
<li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
<li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
</ul>
@@ -1265,12 +1285,7 @@ export default function LicensePage() {
}}
/>
{error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
<button
type="button"
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm"
disabled={!blob.trim() || save.isPending}
onClick={() => save.mutate()}
>
<button type="button" className="mt-3 rounded bg-accent px-3 py-1.5 text-sm" disabled={!blob.trim() || save.isPending} onClick={() => save.mutate()}>
{save.isPending ? "Checking…" : "Save licence"}
</button>
</section>
@@ -1308,10 +1323,12 @@ Migration `0005`, cloud only. It cannot sign — the server holds no private key
Clumsy, and correct. The alternative is putting a signing key in the control plane, which is the thing this design most wants to avoid.
**Files:**
- Create: `server/internal/services/migrate_licence.go`
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: `license.Verify`, `DeploymentMode`
- Produces: `func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error`
@@ -1330,8 +1347,8 @@ import (
"os"
"time"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -1551,6 +1568,7 @@ curl -s -X POST localhost:8080/api/license -b /tmp/lic.txt \
```
Expected, in order:
1. `"This licence was issued for a different instance. Your instance ID is <UUID>."`
2. `"This licence is for Vantage Cloud and cannot be used on a self-hosted install."` — **this is the check that makes Free cloud-only**
3. `"This licence key is not valid. Check it was copied in full."`
@@ -1636,11 +1654,11 @@ git commit -m "chore: verify instance licensing end to end" --allow-empty
## Risks
| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions |
| Customer locked out and unable to recover | `POST /api/license` and every `DELETE` are exempt |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Monitoring lost on billing failure | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it |
| Clock wrong on a self-hosted host | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme |
| Risk | Mitigation |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A mutating route added later without a gate | Mounted on the `/api` group, so new routes are covered by default; Task 5 Step 6 audits the exceptions |
| Customer locked out and unable to recover | `POST /api/license` and every `DELETE` are exempt |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch; it refuses to record its marker if any instance was missed |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Monitoring lost on billing failure | Designed out — the scheduler has no licence check, and Task 10 Step 7 verifies it |
| Clock wrong on a self-hosted host | `Verify` reports `ClockSkewed`; surface it in the settings page if it becomes a support theme |
@@ -24,26 +24,26 @@
Apply exactly. Every task references this table.
| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| `services/orgs.go` | `services/instances.go` |
| Today | After |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| `services/orgs.go` | `services/instances.go` |
| `GetOrg`, `GetOrgBySlug`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg` | `GetInstance`, `GetInstanceBySlug`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance` |
| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` |
| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` |
| UI copy "Organisation"/"Organization" (tenant) | "Instance" |
| UI copy "Organisation" (customer, marketing site) | "Account" |
| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` |
| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` |
| UI copy "Organisation"/"Organization" (tenant) | "Instance" |
| UI copy "Organisation" (customer, marketing site) | "Account" |
---
@@ -51,15 +51,15 @@ Apply exactly. Every task references this table.
**Created:**
| Path | Responsibility |
|---|---|
| `shared/models/instance.go` | `Instance` (replaces `org.go`) |
| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) |
| `server/internal/services/instances.go` | replaces `orgs.go` |
| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` |
| `server/internal/auth/instancehost.go` | replaces `orghost.go` |
| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion |
| `server/cmd/rename-rollback/main.go` | one-shot inverse rename |
| Path | Responsibility |
| ---------------------------------------------- | -------------------------------------------------------- |
| `shared/models/instance.go` | `Instance` (replaces `org.go`) |
| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) |
| `server/internal/services/instances.go` | replaces `orgs.go` |
| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` |
| `server/internal/auth/instancehost.go` | replaces `orghost.go` |
| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion |
| `server/cmd/rename-rollback/main.go` | one-shot inverse rename |
**Modified:** `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`, every file in `server/internal/` referencing a renamed symbol, `server/cmd/main.go`, `sitesvc/internal/{models,store,api}`, `sitesvc/cmd/main.go`, `web/` and `site/` sources.
@@ -70,20 +70,22 @@ Apply exactly. Every task references this table.
Everything downstream depends on these names, so they change first.
**Files:**
- Rename: `shared/models/org.go``shared/models/instance.go`
- Rename: `shared/provision/org.go``shared/provision/instance.go`
- Modify: `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: the plan 0a API
- Produces:
- `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"`
- `models.User.InstanceID`, `models.Settings.InstanceID`
- `provision.CreateInstance(ctx, db, name) (*models.Instance, error)`
- `provision.RollbackInstance(ctx, db, instanceID) error`
- `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged
- `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)`
- `indexes.EnsureCoreIndexes` — now indexes `instances.slug`
- `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"`
- `models.User.InstanceID`, `models.Settings.InstanceID`
- `provision.CreateInstance(ctx, db, name) (*models.Instance, error)`
- `provision.RollbackInstance(ctx, db, instanceID) error`
- `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged
- `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)`
- `indexes.EnsureCoreIndexes` — now indexes `instances.slug`
- [ ] **Step 1: Rename the Instance model**
@@ -152,7 +154,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -219,7 +221,7 @@ func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string
}
```
The `ErrNameRejected` text keeps saying "organisation name" — that message is about the *customer's* organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it.
The `ErrNameRejected` text keeps saying "organisation name" — that message is about the _customer's_ organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it.
- [ ] **Step 4: Rename the user provisioning parameter**
@@ -304,13 +306,15 @@ git commit -m "refactor(shared): rename Org to Instance"
A code constant, not a runbook list. A collection missing from it is a collection whose tenant key never gets renamed — and with no test suite, the boot assertion is what catches that.
**Files:**
- Create: `server/internal/services/migrate_instance.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `var ScopedCollections []string`
- `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error`
- `var ScopedCollections []string`
- `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error`
- [ ] **Step 1: Write the list and the assertion**
@@ -428,9 +432,11 @@ git commit -m "chore(server): add ScopedCollections and the boot assertion"
The risky part of the plan.
**Files:**
- Modify: `server/internal/services/migrate_instance.go`
**Interfaces:**
- Consumes: `ScopedCollections`
- Produces: `func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error`
@@ -593,7 +599,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -637,7 +643,7 @@ mongorestore --drop --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dum
```
```javascript
db.adminCommand({renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances"})
db.adminCommand({ renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances" });
```
Then: `cd server && go run ./cmd/migratecheck`
@@ -671,6 +677,7 @@ Keep `/tmp/dump` — Task 8 needs it.
Mechanical, wide, and guarded by the compiler at every step. Work file by file and let `go build` drive.
**Files:**
- Rename: `server/internal/services/orgs.go``instances.go`
- Rename: `server/internal/services/org_oidc.go``instance_oidc.go`
- Rename: `server/internal/auth/orghost.go``instancehost.go`
@@ -679,6 +686,7 @@ Mechanical, wide, and guarded by the compiler at every step. Work file by file a
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: Task 1's shared API, Tasks 23's migration
- Produces: `services.GetInstance`, `GetInstanceBySlug`, `CreateInstance`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance`, `CountInstanceUsers`, `GetUserInInstance` — all with the same signatures as their `Org` predecessors
@@ -746,7 +754,7 @@ Replace `server/internal/models/instance.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
@@ -758,6 +766,7 @@ type Instance = shared.Instance
Run: `cd server && go build ./... 2>&1 | head -40`
Expected initially: a list of errors. Work through them. Common ones:
- A server-only model still declaring `OrgID` — rename the field there too.
- `reservedSlugs` references in `AdoptInstance` — should already point at `provision.ReservedSlugs` from plan 0a.
@@ -835,9 +844,11 @@ git commit -m "refactor(server): rename Org to Instance"
The recovery path. Deliberately a separate one-shot command rather than a migration — the only reason to run it is a decision to revert the release, which is a human decision.
**Files:**
- Create: `server/cmd/rename-rollback/main.go`
**Interfaces:**
- Consumes: `services.ScopedCollections`
- Produces: a binary, not an API
@@ -862,7 +873,7 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -969,9 +980,11 @@ git commit -m "feat(server): add rename-rollback command for migration 0004"
sitesvc writes the same documents. A version skew where it writes `org_id` while the control plane reads `instance_id` creates tenants the application cannot see — so it refuses to start against an unmigrated database.
**Files:**
- Modify: `sitesvc/internal/models/models.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/api/*.go`, `sitesvc/cmd/main.go`
**Interfaces:**
- Consumes: Task 1's shared API
- Produces: `store.RequireMigratedDatabase(ctx context.Context) error`
@@ -1107,10 +1120,12 @@ git commit -m "refactor(sitesvc): rename Org to Instance, refuse an unmigrated d
Breaking route changes ship here, in the same release as Task 4.
**Files:**
- Modify: `web/lib/` API client, every route under `web/app/(app)/`, `web/components/`
- Rename: `web/app/(app)/settings/org/``web/app/(app)/settings/instance/`
**Interfaces:**
- Consumes: the renamed REST API from Task 4
- Produces: no API
@@ -1182,6 +1197,7 @@ git commit -m "refactor(web): rename Organisation to Instance"
The marketing site uses "organisation" for two different things, and only one becomes "Instance". Where it means the customer, it becomes **Account** — a word that now has a specific meaning in this system, and the marketing site is where a customer meets it first.
**Files:**
- Modify: `site/app/`, `site/components/`
- [ ] **Step 1: Find every reference**
@@ -1264,14 +1280,22 @@ Pick three real tenants from `db.orgs.find({}, {org_id: 1})`. For each:
```javascript
["<tenant-1>", "<tenant-2>", "<tenant-3>"].forEach(function (t) {
print(t
+ " servers=" + db.servers.countDocuments({org_id: t})
+ " keys=" + db.keys.countDocuments({org_id: t})
+ " workflows=" + db.workflows.countDocuments({org_id: t})
+ " monitors=" + db.monitors.countDocuments({org_id: t})
+ " secrets=" + db.secrets.countDocuments({org_id: t})
+ " audit=" + db.audit_logs.countDocuments({org_id: t}))
})
print(
t +
" servers=" +
db.servers.countDocuments({ org_id: t }) +
" keys=" +
db.keys.countDocuments({ org_id: t }) +
" workflows=" +
db.workflows.countDocuments({ org_id: t }) +
" monitors=" +
db.monitors.countDocuments({ org_id: t }) +
" secrets=" +
db.secrets.countDocuments({ org_id: t }) +
" audit=" +
db.audit_logs.countDocuments({ org_id: t }),
);
});
```
Save to `/tmp/tenants-before.txt`.
@@ -1307,14 +1331,22 @@ For the same three tenants:
```javascript
["<tenant-1>", "<tenant-2>", "<tenant-3>"].forEach(function (t) {
print(t
+ " servers=" + db.servers.countDocuments({instance_id: t})
+ " keys=" + db.keys.countDocuments({instance_id: t})
+ " workflows=" + db.workflows.countDocuments({instance_id: t})
+ " monitors=" + db.monitors.countDocuments({instance_id: t})
+ " secrets=" + db.secrets.countDocuments({instance_id: t})
+ " audit=" + db.audit_logs.countDocuments({instance_id: t}))
})
print(
t +
" servers=" +
db.servers.countDocuments({ instance_id: t }) +
" keys=" +
db.keys.countDocuments({ instance_id: t }) +
" workflows=" +
db.workflows.countDocuments({ instance_id: t }) +
" monitors=" +
db.monitors.countDocuments({ instance_id: t }) +
" secrets=" +
db.secrets.countDocuments({ instance_id: t }) +
" audit=" +
db.audit_logs.countDocuments({ instance_id: t }),
);
});
```
Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name in the query. **This is the step that proves tenant isolation survived.** Anything else stops the release.
@@ -1323,9 +1355,9 @@ Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name
```javascript
db.getCollectionNames().forEach(function (c) {
var n = db.getCollection(c).countDocuments({org_id: {$exists: true}});
if (n > 0) print("STALE " + c + " " + n);
})
var n = db.getCollection(c).countDocuments({ org_id: { $exists: true } });
if (n > 0) print("STALE " + c + " " + n);
});
```
Expected: no output.
@@ -1390,8 +1422,9 @@ Expected: no output.
- [ ] **Step 13: Clean up and commit**
```javascript
["vantage_snapshot","vantage_dryrun","vantage_guardtest","vantage_unmigrated","vantage_rollback"]
.forEach(function (d) { db.getSiblingDB(d).dropDatabase() })
["vantage_snapshot", "vantage_dryrun", "vantage_guardtest", "vantage_unmigrated", "vantage_rollback"].forEach(function (d) {
db.getSiblingDB(d).dropDatabase();
});
```
```bash
@@ -1422,7 +1455,7 @@ Expect a short API outage during the server restart while the migration runs. Ag
**In-flight signups are lost.** Any unverified signup recorded before the deploy carries `org_name` and will fail verification. There are at most 24 hours' worth. Either accept it, or wait for the collection to drain:
```javascript
db.site_pending_signups.countDocuments({})
db.site_pending_signups.countDocuments({});
```
Post-deploy checks:
@@ -12,7 +12,7 @@
## Global Constraints
- Package path: `github.com/mrhid6/vantage/shared/license`
- Package path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`
- `shared/go.mod` gains **one** dependency: `github.com/hyperboloide/lk`. This is a deliberate exception to plan 0a's three-dependency limit, and the only one. `shared` must still not import gin, redis, guac or the mongo-driver from this package.
- Tier and deployment values are exact strings: `free`, `professional`, `self_hosted`; `cloud`, `self_hosted`.
- Feature keys are exact strings: `console`, `oidc`.
@@ -28,14 +28,14 @@
**Created:**
| Path | Responsibility |
|---|---|
| Path | Responsibility |
| --------------------------- | ------------------------------------------------------ |
| `shared/license/license.go` | `License`, `Limits`, tier/deployment/feature constants |
| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
| `shared/license/plans.go` | The tier seed table used by `lkctl` |
| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
| `shared/license/keys.go` | `trustedPublicKeys`, key lookup |
| `shared/license/sign.go` | `Sign`, build-tagged out of the server |
| `shared/license/verify.go` | `Verify`, `Parse`, `VerifyOpts`, `Result`, `State` |
| `shared/license/plans.go` | The tier seed table used by `lkctl` |
| `shared/cmd/lkctl/main.go` | `keypair`, `issue`, `inspect` |
**Modified:** `shared/go.mod`, `shared/go.sum`.
@@ -46,10 +46,12 @@
The rest of the plan assumes specific `lk` function names. Confirm them first rather than discovering a mismatch three tasks later.
**Files:**
- Modify: `shared/go.mod`, `shared/go.sum`
- Create (temporary): `shared/cmd/lkprobe/main.go`
**Interfaces:**
- Consumes: nothing
- Produces: a pinned `lk` version and a confirmed API surface
@@ -145,20 +147,22 @@ git commit -m "chore(shared): add hyperboloide/lk for licence signing"
### Task 2: The payload and the tier table
**Files:**
- Create: `shared/license/license.go`
- Create: `shared/license/plans.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
- `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
- `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
- `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
- `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
- `func (l License) HasFeature(name string) bool`
- `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
- `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
- `type License struct` with fields `ID, InstanceID, AccountID, InstanceName, Tier, Deployment string`, `IssuedAt, ExpiresAt time.Time`, `Limits Limits`, `Features []string`
- `type Limits struct { MaxServers, MaxSecretGroups, MaxChannels int }`
- `const TierFree = "free"`, `TierProfessional = "professional"`, `TierSelfHosted = "self_hosted"`
- `const DeploymentCloud = "cloud"`, `DeploymentSelfHosted = "self_hosted"`
- `const FeatureConsole = "console"`, `FeatureOIDC = "oidc"`
- `func (l License) HasFeature(name string) bool`
- `func (l Limits) Allows(current, max int) bool` — no; see below for the exact helper
- `type Plan struct` and `func PlanFor(tier string) (Plan, bool)`
- [ ] **Step 1: Write the payload**
@@ -316,15 +320,17 @@ git commit -m "feat(license): add the licence payload and tier seed table"
### Task 3: Signing, and keeping it out of the server
**Files:**
- Create: `shared/license/keys.go`
- Create: `shared/license/sign.go`
**Interfaces:**
- Consumes: `License`
- Produces:
- `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
- `var trustedPublicKeys []string`
- `func publicKeys() ([]*lk.PublicKey, error)`
- `func Sign(l License, privateKeyB32 string) (string, error)` — build tag `!noSign`
- `var trustedPublicKeys []string`
- `func publicKeys() ([]*lk.PublicKey, error)`
- [ ] **Step 1: Write the trusted key list**
@@ -447,17 +453,19 @@ git commit -m "feat(license): add signing and the trusted key list"
The heart of the system. Check order is part of the contract, because the reason drives the message a customer sees.
**Files:**
- Create: `shared/license/verify.go`
**Interfaces:**
- Consumes: `License`, `publicKeys()`
- Produces:
- `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
- `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
- `type Result struct { License License; State State; Reason string }`
- `func Verify(blob string, opts VerifyOpts) Result`
- `func Parse(blob string) (License, error)`
- Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
- `type State string`, `const StateValid = "valid"`, `StateExpired = "expired"`, `StateInvalid = "invalid"`
- `type VerifyOpts struct { InstanceID, Deployment string; Now time.Time }`
- `type Result struct { License License; State State; Reason string }`
- `func Verify(blob string, opts VerifyOpts) Result`
- `func Parse(blob string) (License, error)`
- Reason constants: `ReasonNoLicense`, `ReasonBadSignature`, `ReasonDeploymentMismatch`, `ReasonInstanceMismatch`, `ReasonExpired`
- [ ] **Step 1: Write the verifier**
@@ -624,9 +632,11 @@ git commit -m "feat(license): add offline verification"
The production issuance path until the admin service exists, and the disaster-recovery path forever after — if admin is down and a customer's licence expires, a blob can still be cut by hand.
**Files:**
- Create: `shared/cmd/lkctl/main.go`
**Interfaces:**
- Consumes: `Sign`, `Parse`, `PlanFor`, `License`
- Produces: the `lkctl` binary
@@ -654,7 +664,7 @@ import (
"github.com/google/uuid"
"github.com/hyperboloide/lk"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
func main() {
@@ -834,9 +844,11 @@ git commit -m "feat(license): add lkctl for issuing licences by hand"
This is the step that makes the system real. Do it once, carefully.
**Files:**
- Modify: `shared/license/keys.go`
**Interfaces:**
- Consumes: `lkctl keypair`
- Produces: a populated `trustedPublicKeys`
@@ -891,7 +903,7 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
func main() {
@@ -4,7 +4,7 @@
**Goal:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc.
**Architecture:** A new Go module `github.com/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.
**Architecture:** A new Go module `gitea.hostxtra.co.uk/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.
**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions.
@@ -13,7 +13,7 @@
## Global Constraints
- Go version in every `go.mod`: `go 1.26`
- Module path: `github.com/mrhid6/vantage/shared`
- Module path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared`
- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small.
- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere.
- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default`
@@ -29,38 +29,38 @@
**Created:**
| Path | Responsibility |
|---|---|
| `go.work` | Workspace over shared, server, sitesvc |
| `shared/go.mod`, `shared/go.sum` | Module definition |
| `shared/models/org.go` | `Org` document |
| `shared/models/user.go` | `User` document, role constants, `ValidRole` |
| `shared/models/settings.go` | `Settings` and sub-structs |
| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` |
| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` |
| `shared/indexes/indexes.go` | `EnsureCoreIndexes` |
| Path | Responsibility |
| -------------------------------- | -------------------------------------------------------------------- |
| `go.work` | Workspace over shared, server, sitesvc |
| `shared/go.mod`, `shared/go.sum` | Module definition |
| `shared/models/org.go` | `Org` document |
| `shared/models/user.go` | `User` document, role constants, `ValidRole` |
| `shared/models/settings.go` | `Settings` and sub-structs |
| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` |
| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` |
| `shared/indexes/indexes.go` | `EnsureCoreIndexes` |
**Modified:**
| Path | Change |
|---|---|
| `server/go.mod` | require + replace shared |
| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared |
| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared |
| `server/internal/services/users.go` | `CreateUser` delegates to shared |
| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared |
| `server/Dockerfile` | Build from repo root |
| `sitesvc/go.mod` | require + replace shared |
| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` |
| `sitesvc/internal/store/store.go` | Uses shared provision |
| `sitesvc/Dockerfile` | Build from repo root |
| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images |
| Path | Change |
| --------------------------------------------------------- | --------------------------------------- |
| `server/go.mod` | require + replace shared |
| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared |
| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared |
| `server/internal/services/users.go` | `CreateUser` delegates to shared |
| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared |
| `server/Dockerfile` | Build from repo root |
| `sitesvc/go.mod` | require + replace shared |
| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` |
| `sitesvc/internal/store/store.go` | Uses shared provision |
| `sitesvc/Dockerfile` | Build from repo root |
| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images |
**Deleted:**
| Path |
|---|
| Path |
| ----------------------------------------- |
| `sitesvc/internal/provision/provision.go` |
---
@@ -68,10 +68,12 @@
### Task 1: Scaffold the shared module
**Files:**
- Create: `shared/go.mod`
- Create: `go.work`
**Interfaces:**
- Consumes: nothing
- Produces: an importable but empty module
@@ -81,7 +83,7 @@
cd c:/Work/Repos/vantage
mkdir -p shared
cd shared
go mod init github.com/mrhid6/vantage/shared
go mod init gitea.hostxtra.co.uk/mrhid6/vantage/shared
go get go.mongodb.org/mongo-driver/v2@latest
go get golang.org/x/crypto@latest
go get github.com/google/uuid@latest
@@ -106,7 +108,7 @@ use (
- [ ] **Step 3: Verify the workspace resolves**
Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5`
Expected: output includes `github.com/mrhid6/vantage/shared`, `github.com/mrhid6/vantage/server` and `github.com/mrhid6/vantage/sitesvc`. No error.
Expected: output includes `gitea.hostxtra.co.uk/mrhid6/vantage/shared`, `gitea.hostxtra.co.uk/mrhid6/vantage/server` and `gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc`. No error.
- [ ] **Step 4: Commit**
@@ -122,21 +124,23 @@ git commit -m "chore: scaffold shared module"
Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review.
**Files:**
- Create: `shared/provision/slug.go`
- Create: `shared/models/org.go`
- Create: `shared/models/user.go`
- Create: `shared/models/settings.go`
**Interfaces:**
- Consumes: nothing
- Produces:
- `const MinSlugLength = 3`, `MaxSlugLength = 40`
- `var ReservedSlugs map[string]bool`
- `func Slugify(name string) string`
- `func BaseSlug(name string) (string, error)`
- `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
- `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
- `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`
- `const MinSlugLength = 3`, `MaxSlugLength = 40`
- `var ReservedSlugs map[string]bool`
- `func Slugify(name string) string`
- `func BaseSlug(name string) (string, error)`
- `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
- `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
- `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`
- [ ] **Step 1: Write the slug rules**
@@ -342,7 +346,7 @@ package main
import (
"fmt"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
)
func main() {
@@ -392,18 +396,20 @@ The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behavio
2. Error text uses "organisation", not "organization".
**Files:**
- Create: `shared/provision/org.go`
- Create: `shared/provision/user.go`
**Interfaces:**
- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug`
- Produces:
- `var ErrNameRejected error`, `var ErrEmailTaken error`
- `const BcryptCost = 12`
- `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
- `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
- `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
- `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`
- `var ErrNameRejected error`, `var ErrEmailTaken error`
- `const BcryptCost = 12`
- `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
- `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
- `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
- `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`
`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes.
@@ -421,7 +427,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -503,7 +509,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
@@ -582,9 +588,11 @@ previously returned an error when it lost the slug race."
`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal.
**Files:**
- Create: `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: nothing
- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error`
@@ -639,7 +647,7 @@ func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
```bash
cd c:/Work/Repos/vantage/shared
go build ./... && go vet ./...
go list -m all | grep -Ev "^github.com/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
go list -m all | grep -Ev "^gitea.hostxtra.co.uk/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
```
Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been.
@@ -658,6 +666,7 @@ git commit -m "feat(shared): add EnsureCoreIndexes"
Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched.
**Files:**
- Modify: `server/go.mod`
- Modify: `server/internal/models/org.go`, `user.go`, `settings.go`
- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`)
@@ -665,6 +674,7 @@ Server keeps its own package paths so no call site outside these files changes.
- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`)
**Interfaces:**
- Consumes: everything from Tasks 24
- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures.
@@ -672,8 +682,8 @@ Server keeps its own package paths so no call site outside these files changes.
```bash
cd c:/Work/Repos/vantage/server
go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=github.com/mrhid6/vantage/shared=../shared
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```
@@ -684,7 +694,7 @@ Replace the entire contents of `server/internal/models/org.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
// Org is defined in the shared module because sitesvc writes the same
// documents. Aliased rather than re-declared so existing call sites are
@@ -697,7 +707,7 @@ Replace the entire contents of `server/internal/models/user.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
@@ -715,7 +725,7 @@ Replace the entire contents of `server/internal/models/settings.go` with:
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
@@ -763,7 +773,7 @@ func CreateOrg(name string) (*models.Org, error) {
Add to the import block:
```go
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
```
Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you.
@@ -785,7 +795,7 @@ func CreateUser(orgID, email, password, role, authSource string) (*models.User,
}
```
The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"github.com/mrhid6/vantage/shared/provision"` to the imports.
The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"` to the imports.
- [ ] **Step 6: Delegate EnsureAuthIndexes**
@@ -799,7 +809,7 @@ func EnsureAuthIndexes() error {
}
```
Add `"github.com/mrhid6/vantage/shared/indexes"` to the imports.
Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"` to the imports.
- [ ] **Step 7: Build and vet**
@@ -831,12 +841,14 @@ git commit -m "refactor(server): use shared models, provision and indexes"
### Task 6: Wire sitesvc to shared and delete the duplicates
**Files:**
- Modify: `sitesvc/go.mod`
- Modify: `sitesvc/internal/models/models.go`
- Modify: `sitesvc/internal/store/store.go`
- Delete: `sitesvc/internal/provision/provision.go`
**Interfaces:**
- Consumes: everything from Tasks 24
- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures.
@@ -844,8 +856,8 @@ git commit -m "refactor(server): use shared models, provision and indexes"
```bash
cd c:/Work/Repos/vantage/sitesvc
go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=github.com/mrhid6/vantage/shared=../shared
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```
@@ -867,7 +879,7 @@ import (
// collection exists.
//
// Org and User used to be mirrored here by hand. They now come from
// github.com/mrhid6/vantage/shared/models, which is the only copy.
// gitea.hostxtra.co.uk/mrhid6/vantage/shared/models, which is the only copy.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
@@ -894,17 +906,17 @@ In `sitesvc/internal/store/store.go`:
Replace the two local imports
```go
"github.com/mrhid6/vantage/sitesvc/internal/models"
"github.com/mrhid6/vantage/sitesvc/internal/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/provision"
```
with
```go
"github.com/mrhid6/vantage/shared/indexes"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
```
Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`:
@@ -1006,7 +1018,7 @@ cd c:/Work/Repos/vantage
grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/
```
Expected: no output. Any `provision.`-qualified *references* are fine; what must be gone are local *definitions*.
Expected: no output. Any `provision.`-qualified _references_ are fine; what must be gone are local _definitions_.
- [ ] **Step 7: Commit**
@@ -1025,10 +1037,12 @@ control plane and sitesvc now share one definition of both."
`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead.
**Files:**
- Modify: `server/Dockerfile`, `sitesvc/Dockerfile`
- Modify: `.gitea/workflows/server-deploy.yml`
**Interfaces:**
- Consumes: the module layout from Tasks 16
- Produces: images identical in content to today's, built from a different context
@@ -1097,15 +1111,15 @@ Expected: both succeed, each ending with `naming to docker.io/library/vantage-..
In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only:
```yaml
context: .
file: server/Dockerfile
context: .
file: server/Dockerfile
```
and
```yaml
context: .
file: sitesvc/Dockerfile
context: .
file: sitesvc/Dockerfile
```
Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module.
@@ -1191,7 +1205,7 @@ curl -s -X POST localhost:8080/auth/bootstrap \
Expected: bootstrap succeeds. Then in `mongosh`:
```javascript
db.orgs.findOne({}, {org_id: 1, name: 1, slug: 1})
db.orgs.findOne({}, { org_id: 1, name: 1, slug: 1 });
```
Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`.
@@ -1209,7 +1223,7 @@ curl -s -X POST localhost:8080/auth/login \
Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm:
```javascript
db.orgs.find({}, {slug: 1}).sort({slug: 1})
db.orgs.find({}, { slug: 1 }).sort({ slug: 1 });
```
Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`.
@@ -1261,7 +1275,7 @@ import (
"fmt"
"os"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -1298,6 +1312,7 @@ grep -rn "bson:\"org_id\"" --include=*.go .
```
Expected:
- `func Slugify` — exactly one hit, in `shared/provision/slug.go`
- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go`
- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.**
@@ -12,13 +12,13 @@
- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry".
- **Admin's control-plane writes are confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry` on `instances`. Do not widen `inject`.
@@ -46,37 +46,37 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Created:**
| Path | Responsibility |
|---|---|
| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane |
| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices |
| `server/internal/services/reap.go` | the Free-instance purge and its scheduler |
| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component |
| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` |
| Path | Responsibility |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| `admin/internal/cloudprov/cloudprov.go` | admin's ONLY instance/user write path into the control plane |
| `admin/internal/lifecycle/lifecycle.go` | lapse sweep and the four renewal notices |
| `server/internal/services/reap.go` | the Free-instance purge and its scheduler |
| `adminsite/app/(customer)/instances/new/page.tsx` | create-instance form |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component |
| `site/components/AccountForm.tsx` | replaces `InstanceForm.tsx` |
**Modified:**
| Path | Change |
|---|---|
| `admin/internal/db/db.go` | `ControlDB()` accessor |
| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` |
| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` |
| `admin/internal/mail/mail.go` | four lifecycle emails |
| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` |
| `admin/internal/api/routes.go` | the two new customer routes |
| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` |
| `admin/cmd/main.go` | start the lifecycle sweeper |
| `server/cmd/main.go` | start the reaper |
| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status |
| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar |
| `adminsite/app/(customer)/page.tsx` | create-instance call to action |
| `site/app/start/page.tsx` | account-signup copy |
| `site/lib/submit.ts` | `submitSignup` posts to admin |
| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed |
| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` |
| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables |
| Path | Change |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `admin/internal/db/db.go` | `ControlDB()` accessor |
| `admin/internal/config/config.go` | `AppLoginURL` from `APP_LOGIN_URL` |
| `admin/internal/models/models.go` | `StatusDeleted`, `NoticesSent` on `Instance` |
| `admin/internal/mail/mail.go` | four lifecycle emails |
| `admin/internal/api/customer.go` | `createInstance`, `renewInstance` |
| `admin/internal/api/routes.go` | the two new customer routes |
| `admin/internal/inject/inject.go` | reconciler marks vanished instances `deleted` |
| `admin/cmd/main.go` | start the lifecycle sweeper |
| `server/cmd/main.go` | start the reaper |
| `adminsite/lib/api.ts` | `createInstance`, `renewInstance`, `"deleted"` status |
| `adminsite/components/InstanceCard.tsx` | renew action, deletion countdown, monthly-aware bar |
| `adminsite/app/(customer)/page.tsx` | create-instance call to action |
| `site/app/start/page.tsx` | account-signup copy |
| `site/lib/submit.ts` | `submitSignup` posts to admin |
| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed |
| `deploy/docker-compose.site.yml` | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
| `.gitea/workflows/server-deploy.yml` | `ADMIN_API_URL` build arg for `site` |
| `CLAUDE.md` | the boundary paragraph, sitesvc's table, the env tables |
**Deleted:** `sitesvc/internal/api/signup.go`, `site/components/InstanceForm.tsx`.
@@ -85,17 +85,19 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
### Task 1: `cloudprov` — admin's instance and user write path
**Files:**
- Create: `admin/internal/cloudprov/cloudprov.go`
- Modify: `admin/internal/db/db.go`, `admin/internal/config/config.go`
**Interfaces:**
- Consumes: `shared/provision.CreateInstance`, `CreateUserWithHash`, `RollbackInstance`; `shared/models`.
- Produces:
- `db.ControlDB() *mongo.Database`
- `config.Config.AppLoginURL string`
- `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
- `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
- `cloudprov.RollbackInstance(ctx, instanceID string) error`
- `db.ControlDB() *mongo.Database`
- `config.Config.AppLoginURL string`
- `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
- `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
- `cloudprov.RollbackInstance(ctx, instanceID string) error`
- [ ] **Step 1: Add the `ControlDB` accessor**
@@ -152,9 +154,9 @@ import (
"context"
"fmt"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -225,17 +227,21 @@ func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
- [ ] **Step 4: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 5: Confirm the write boundary holds**
Run:
```sh
grep -rn 'db.Control(' --include=*.go admin/ | grep -v '_test'
```
Expected: matches only in `admin/internal/inject/inject.go`, `admin/internal/cloudprov/cloudprov.go`, and read-only uses in `admin/internal/api/staff.go` and `admin/internal/licensing/link.go`. Any **write** (`UpdateOne`, `InsertOne`, `DeleteOne`) outside `inject` and `cloudprov` is a boundary violation — report it rather than fixing it silently.
- [ ] **Step 6: Commit**
@@ -260,15 +266,17 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: Create an instance
**Files:**
- Modify: `admin/internal/models/models.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `cloudprov` from Task 1; `licensing.Issue`, `licensing.ErrFreeLimit`; `inject.Deliver`; `auth.Current`.
- Produces:
- `models.StatusDeleted = "deleted"`
- `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
- `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
- `POST /api/instances`
- `models.StatusDeleted = "deleted"`
- `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
- `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
- `POST /api/instances`
- [ ] **Step 1: Add the model fields**
@@ -453,7 +461,7 @@ var appLoginURL string
func SetAppLoginURL(v string) { appLoginURL = v }
```
Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"github.com/mrhid6/vantage/admin/internal/audit"`, `"github.com/mrhid6/vantage/admin/internal/cloudprov"`, `"github.com/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.
Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.
- [ ] **Step 4: Wire the route and the config**
@@ -474,9 +482,11 @@ Import `admin/internal/api` there if it is not already imported (it will be, for
- [ ] **Step 5: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 6: Commit**
@@ -501,14 +511,16 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: Renew a Free instance
**Files:**
- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/mail/mail.go`
**Interfaces:**
- Consumes: `ownedInstance`, `licensing.Issue`, `inject.Deliver`.
- Produces:
- `mail.SendRenewed(to, instanceName string, expires time.Time) error`
- `POST /api/instances/:id/renew`
- `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`
- `mail.SendRenewed(to, instanceName string, expires time.Time) error`
- `POST /api/instances/:id/renew`
- `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`
- [ ] **Step 1: Add the renewal window constant**
@@ -621,9 +633,11 @@ In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.POST("/i
- [ ] **Step 5: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 6: Commit**
@@ -646,23 +660,25 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 4: The lapse sweep and the four notices
**Files:**
- Create: `admin/internal/lifecycle/lifecycle.go`
- Modify: `admin/internal/mail/mail.go`, `admin/cmd/main.go`
**Interfaces:**
- Consumes: `models.Instance`, `models.License`, `mail`.
- Produces:
- `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
- `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`
- `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
- `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`
The notice schedule, all relative to the licence's `ExpiresAt` (which already includes the three-day grace):
| Key | Sent when | Says |
|---|---|---|
| `expiring` | 7 days before expiry | renew, one click |
| `expired` | at expiry | read-only; deleted in N days |
| `delete_7` | 7 days before deletion | deleted in 7 days |
| `delete_1` | 1 day before deletion | deleted tomorrow |
| Key | Sent when | Says |
| ---------- | ---------------------- | ---------------------------- |
| `expiring` | 7 days before expiry | renew, one click |
| `expired` | at expiry | read-only; deleted in N days |
| `delete_7` | 7 days before deletion | deleted in 7 days |
| `delete_1` | 1 day before deletion | deleted tomorrow |
- [ ] **Step 1: Add the three emails**
@@ -723,10 +739,10 @@ import (
"slices"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -917,9 +933,11 @@ In `admin/cmd/main.go`, next to `inject.StartReconciler(ctx)`:
- [ ] **Step 4: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 5: Commit**
@@ -943,10 +961,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 5: The reaper
**Files:**
- Create: `server/internal/services/reap.go`
- Modify: `server/cmd/main.go`
**Interfaces:**
- Consumes: `db.Col`, `db.Database`.
- Produces: `services.ReapFreeInstances(ctx) (checked, purged int, err error)`, `services.StartReaper(ctx)`, `services.PurgeInstance(ctx, instanceID string) (map[string]int64, error)`.
@@ -966,8 +986,8 @@ import (
"os"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1173,18 +1193,23 @@ In `server/cmd/main.go`, next to `monitorsched.Start(context.Background())`:
- [ ] **Step 3: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: no output.
- [ ] **Step 4: Confirm the collection list is complete**
Run:
```sh
grep -rho 'db\.Col("[a-z_]*"' server/internal/ | sed 's/db.Col("//;s/"//' | sort -u
```
Expected output, exactly:
```
assignments
audit_logs
@@ -1232,9 +1257,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: The reconciler marks reaped instances
**Files:**
- Modify: `admin/internal/inject/inject.go`
**Interfaces:**
- Consumes: `models.StatusDeleted` from Task 2.
- Produces: nothing new.
@@ -1282,9 +1309,11 @@ Add `"errors"` and `"go.mongodb.org/mongo-driver/v2/mongo"` to the imports.
- [ ] **Step 2: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output.
- [ ] **Step 3: Commit**
@@ -1305,10 +1334,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: The HQ portal — create and renew
**Files:**
- Create: `adminsite/app/(customer)/instances/new/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/components/InstanceCard.tsx`, `adminsite/app/(customer)/page.tsx`
**Interfaces:**
- Consumes: `POST /api/instances` (Task 2), `POST /api/instances/:id/renew` (Task 3).
- Produces: `api.createInstance(name)`, `api.renewInstance(id)`.
@@ -1319,15 +1350,16 @@ Design rules from `CLAUDE.md` that bind this task: `adminsite` uses only `var(--
In `adminsite/lib/api.ts`:
- Change `InstanceStatus` to include `"deleted"`:
```ts
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
```
```ts
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
```
- Add `notices_sent?: string[];` to the `Instance` interface.
- Add to the `api` object, after `link`:
```ts
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
```
```ts
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
```
- [ ] **Step 2: Write the create form**
@@ -1363,8 +1395,7 @@ export function CreateForm() {
await qc.invalidateQueries({ queryKey: ["account"] });
router.push("/");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
const slug = slugify(name);
@@ -1388,10 +1419,7 @@ export function CreateForm() {
hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
/>
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing one does not
change the other afterwards.
</p>
<p className="text-[0.82rem] text-ink-2">You sign in to it with this same email address and password. Changing one does not change the other afterwards.</p>
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "Creating…" : "Create instance"}
@@ -1422,9 +1450,8 @@ export default function NewInstancePage() {
<header className="grid gap-2">
<h1 className="text-3xl">Create a free instance</h1>
<p className="max-w-prose text-ink-2">
An instance owns its servers, keys, workflows, monitors and secrets. Nothing
inside it is visible to any other instance. Free covers three servers, and the
licence runs for a month at a time — we email you before it needs renewing.
An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month
at a time — we email you before it needs renewing.
</p>
</header>
<CreateForm />
@@ -1438,36 +1465,28 @@ export default function NewInstancePage() {
In `adminsite/app/(customer)/page.tsx`, replace the "No instances yet" block's contents with a version that offers the action. Keep the existing wrapper classes; change the inner markup to:
```tsx
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied
automatically. Or buy a self-hosted licence, install Vantage on your own
server, and link it here to get your licence file.
</p>
<Link
href="/instances/new"
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
>
Create a free instance
</Link>
</div>
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">No instances yet</h2>
<p className="text-ink-2">
Create a free cloud instance and we host it, with your licence applied automatically. Or buy a self-hosted licence, install Vantage on your own server, and link it here to get your licence
file.
</p>
<Link href="/instances/new" className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink">
Create a free instance
</Link>
</div>
```
And below the instances grid, when the account has instances but no Free cloud one, add:
```tsx
{data.instances.length > 0 &&
!data.instances.some(
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
) && (
<Link
href="/instances/new"
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
>
Create a free instance
</Link>
)}
{
data.instances.length > 0 && !data.instances.some((i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted") && (
<Link href="/instances/new" className="justify-self-start text-[0.82rem] font-semibold text-accent underline">
Create a free instance
</Link>
);
}
```
- [ ] **Step 5: Renew action and deletion countdown on the card**
@@ -1476,61 +1495,62 @@ In `adminsite/components/InstanceCard.tsx`:
- Make it a client component: add `"use client";` as the first line, and import `useMutation`, `useQueryClient` from `@tanstack/react-query` and `api` from `@/lib/api`.
- The progress bar currently divides by 365, which renders a 30-day Free licence as a 8% sliver. Make the denominator depend on the tier:
```tsx
const termDays = instance.tier === "free" ? 30 : 365;
```
and use `(days / termDays) * 100` in the width calculation.
```tsx
const termDays = instance.tier === "free" ? 30 : 365;
```
and use `(days / termDays) * 100` in the width calculation.
- Add, after the `state === "expired"` paragraph, a deletion countdown driven by props rather than colour:
```tsx
{state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0
? "Scheduled for deletion."
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
)}
```
Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
```tsx
{
state === "expired" && deleteInDays !== null && (
<p className="text-[0.82rem] font-semibold text-expired">
{deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
</p>
);
}
```
Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
- Add a Renew button for Free instances inside the window:
```tsx
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
```
and render it beside the existing link when `canRenew`:
```tsx
{canRenew && (
<button
type="button"
onClick={() => renew.mutate()}
disabled={renew.isPending}
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
>
{renew.isPending ? "Renewing…" : "Renew"}
</button>
)}
```
```tsx
const qc = useQueryClient();
const renew = useMutation({
mutationFn: () => api.renewInstance(instance.instance_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
});
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
```
and render it beside the existing link when `canRenew`:
```tsx
{
canRenew && (
<button type="button" onClick={() => renew.mutate()} disabled={renew.isPending} className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink">
{renew.isPending ? "Renewing…" : "Renew"}
</button>
);
}
```
`daysRemaining` already exists in `adminsite/lib/format.ts`; check its exact signature before use.
- [ ] **Step 6: Confirm it builds**
Run:
```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful Next.js build. Type errors here are real — fix them rather than loosening types.
- [ ] **Step 7: Confirm no hex colours were introduced**
Run:
```sh
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/ adminsite/app/ --include=*.tsx
```
Expected: no matches. `CLAUDE.md` requires `adminsite` components to reference `var(--…)` tokens only.
- [ ] **Step 8: Commit**
@@ -1554,11 +1574,13 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 8: The marketing form creates an account
**Files:**
- Create: `site/components/AccountForm.tsx`
- Delete: `site/components/InstanceForm.tsx`
- Modify: `site/lib/submit.ts`, `site/app/start/page.tsx`
**Interfaces:**
- Consumes: `POST /auth/signup` on admin (already exists; unchanged by this phase).
- Produces: `submitAccountSignup(fields)` in `site/lib/submit.ts`.
@@ -1581,12 +1603,7 @@ Replace the whole `submitSignup` function with:
* signup are owned by different services, and an implied shared host is how they
* silently end up pointing at the wrong one.
*/
export async function submitAccountSignup(fields: {
name: string;
email: string;
password: string;
website: string;
}): Promise<SubmitResult> {
export async function submitAccountSignup(fields: { name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
if (!ADMIN_API) {
return {
state: "error",
@@ -1607,12 +1624,12 @@ Create `site/components/AccountForm.tsx` by adapting `site/components/InstanceFo
- Delete the `slug` state, the `slugify` helper and the `<span className="hostline">` preview entirely.
- Call `submitAccountSignup` with `{ name, email, password, website }`.
- Change the success panel text to:
```
Check your email.
We sent a confirmation link. Open it and your Vantage account is ready — then you
can create your first instance from the portal. The link works once and expires in
24 hours.
```
```
Check your email.
We sent a confirmation link. Open it and your Vantage account is ready — then you
can create your first instance from the portal. The link works once and expires in
24 hours.
```
Then delete `site/components/InstanceForm.tsx`.
@@ -1623,32 +1640,36 @@ In `site/app/start/page.tsx`:
- Import `AccountForm` instead of `InstanceForm` and render it.
- Change the `<h1>` to `Create your account.`
- Change the lede to:
```
Your account is where instances, licences and billing live. Confirm your email and
you can create a free instance straight away — three servers, hosted by us.
```
```
Your account is where instances, licences and billing live. Confirm your email and
you can create a free instance straight away — three servers, hosted by us.
```
- Change the "What happens next" specs to four steps, in this order:
1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
- Update `metadata.description` to describe an account rather than an instance.
- [ ] **Step 4: Confirm it builds**
Run:
```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/site":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build.
- [ ] **Step 5: Confirm nothing still imports the deleted component**
Run:
```sh
grep -rn "InstanceForm\|submitSignup" site/
```
Expected: no matches.
- [ ] **Step 6: Commit**
@@ -1670,10 +1691,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 9: Retire sitesvc's signup
**Files:**
- Delete: `sitesvc/internal/api/signup.go`
- Modify: `sitesvc/internal/api/api.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models/`, `sitesvc/cmd/main.go`
**Interfaces:**
- Consumes: nothing.
- Produces: sitesvc serving `POST /api/contact` and nothing else.
@@ -1701,15 +1724,19 @@ In `sitesvc/cmd/main.go`, remove `APP_LOGIN_URL` and any signup-only wiring. Lea
- [ ] **Step 4: Confirm it compiles and the signup surface is gone**
Run:
```sh
sh /tmp/gorun.sh sitesvc go build ./...
```
Expected: no output.
Run:
```sh
grep -rn "site_pending_signups\|CreatePending\|handleSignup\|handleVerify" sitesvc/
```
Expected: no matches.
- [ ] **Step 5: Run `go mod tidy` with GOWORK off**
@@ -1721,6 +1748,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod
-w /src/sitesvc -e GOWORK=off golang:1.26 go mod tidy
sh /tmp/gorun.sh sitesvc go build ./...
```
Expected: build still clean. `GOWORK=off` is mandatory — in workspace mode `tidy` drops `require` lines and the Docker build then fails with "missing go.sum entry".
- [ ] **Step 6: Commit**
@@ -1745,6 +1773,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 10: Configuration, docs and end-to-end verification
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`
This task proves the phase. With no test suite, this transcript is the only evidence — run it in full.
@@ -1762,6 +1791,7 @@ In `deploy/docker-compose.site.yml`:
```sh
grep -n "FREE_INSTANCE_REAP_AFTER" deploy/docker-compose.yml
```
Expected: no matches.
The two values must match: admin uses it to name the deletion date in emails, the server uses it to decide. `CLAUDE.md` documents them as a pair in Step 3.
@@ -1778,7 +1808,7 @@ In `.gitea/workflows/server-deploy.yml`, wherever the `site` image is built, pas
- In **Environment Variables (server)**, add:
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
- In **Design Decisions**, add:
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
- Update the sentence in **Admin REST API** or **Security** that describes admin's control-plane access as read-only apart from three licence fields, to name `cloudprov` as the second write path.
- [ ] **Step 4: Build every image**
@@ -1788,6 +1818,7 @@ MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:p2 .
```
Expected: three image IDs. A "missing go.sum entry" failure means Step 5 of Task 9 was run in workspace mode.
- [ ] **Step 5: Start scratch infrastructure**
@@ -1804,6 +1835,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```
Expected: `reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)`. **This is the safety default and must appear.**
- [ ] **Step 6: Start admin with a real signing key**
@@ -1830,6 +1862,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-admin -p 8094:8083 \
sleep 6
curl -s http://localhost:8094/healthz
```
Expected: `{"ok":true}`.
- [ ] **Step 7: Create an account and verify it**
@@ -1856,6 +1889,7 @@ That hash is a well-known bcrypt test vector; if the sign-in below returns 401,
curl -s -X POST http://localhost:8094/auth/login -H 'Content-Type: application/json' \
-c /tmp/p2.jar -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
```
Expected: `{"kind":"customer","email":"owner@example.com"}`.
- [ ] **Step 8: Seed the Free plan and create an instance**
@@ -1867,12 +1901,14 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval \
'printjson(db.plans.find({},{tier:1,deployment:1,_id:0}).toArray())'
```
Expected: includes `{tier:"free", deployment:"cloud"}`. If `plans` is empty, admin's seeding did not run — report it.
```sh
curl -s -X POST http://localhost:8094/api/instances -b /tmp/p2.jar \
-H 'Content-Type: application/json' -d '{"name":"Northgate Systems"}'
```
Expected: `201` with an instance whose `slug` is `northgate-systems`, `deployment` `cloud`, `status` `active`, and a `tier` of `free`.
- [ ] **Step 9: Confirm the whole chain landed**
@@ -1893,6 +1929,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
```
Expected, all of them:
- `owner: owner@example.com role=owner auth_source=hq hq_user_id=cu-test`
- `license_tier: free`, an expiry roughly one month and three days out
- `blob present: true` — this proves injection ran
@@ -1907,6 +1944,7 @@ curl -s -X POST http://localhost:8092/auth/login -H 'Host: northgate-systems.van
-d '{"email":"owner@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8092/auth/me -H 'Host: northgate-systems.vantage.test' -b /tmp/p2i.jar
```
Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the payoff of the whole phase — the HQ password works on the instance.
- [ ] **Step 11: Confirm the Free cap**
@@ -1915,6 +1953,7 @@ Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8094/api/instances \
-b /tmp/p2.jar -H 'Content-Type: application/json' -d '{"name":"Second One"}'
```
Expected: `409`.
```sh
@@ -1922,6 +1961,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
'print("instances: " + db.instances.countDocuments({}))'
```
Expected: `instances: 1`. The refusal must leave nothing behind.
- [ ] **Step 12: Confirm renewal refuses outside the window**
@@ -1932,6 +1972,7 @@ INST=$(MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-g
'print(db.instances.findOne({slug:"northgate-systems"}).instance_id)')
curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```
Expected: `400` with "not due yet".
- [ ] **Step 13: Confirm renewal works inside the window**
@@ -1947,6 +1988,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```
Expected: `200` with a new licence whose `expires_at` is about a month out, and a `reason` of `renewal`.
```sh
@@ -1958,6 +2000,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
const inst = c.instances.findOne({slug:"northgate-systems"});
print("injected expiry: " + inst.license_expiry);'
```
Expected: 2 licences, 1 superseded, and the injected expiry matching the new one.
- [ ] **Step 14: Confirm the reaper stays off, then deletes only what it should**
@@ -1974,6 +2017,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```
Expected: `reaper: ENABLED — Free instances are deleted 336h0m0s after their licence expires`.
Now seed three instances the reaper must treat differently, plus rows scoped to the doomed one:
@@ -2005,6 +2049,7 @@ MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i "REAPING\|reaped\|reaper:"
```
Expected: a `REAPING instance doomed (Doomed, slug=doomed)` line naming the expiry and window, then `reaped instance doomed: map[...]` listing the deleted counts, then `reaper: checked 1, purged 1`.
**`checked` must be 1.** If it is higher, something ineligible was selected — stop and report it. This is the one bug in this phase that destroys customer data.
@@ -2027,6 +2072,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
```
Expected, every line:
- all four `doomed` counts are `0`
- all four survivor counts are `1`
@@ -2041,6 +2087,7 @@ MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | tail -20 | grep -i "reaper\|panic\|error"
```
Expected: `reaper: ENABLED …` and nothing else — no `REAPING`, no panic, no error.
- [ ] **Step 15c: Confirm the collection list is exhaustive**
@@ -2099,7 +2146,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
- `deploy/docker-compose.yml` does not mention `FREE_INSTANCE_REAP_AFTER`.
- `grep -rn "InstanceForm\|submitSignup" site/` and `grep -rn "site_pending_signups" sitesvc/` both return nothing.
**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice *selection* run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.
**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice _selection_ run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.
## Deployment order
@@ -12,13 +12,13 @@
- **No automated Go tests.** Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step below is a command with expected output. This matches plans 0a through 4.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper from earlier plans:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry".
- **`shared/` is consumed through `replace` directives** in `server`, `admin` and `sitesvc`. A change to `shared/` reaches all three on their next build; there is no version to bump.
@@ -38,17 +38,17 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Modified:**
| Path | Change |
|---|---|
| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
| `admin/internal/auth/cloud.go` | **deleted** |
| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
| `CLAUDE.md` | the index security-property paragraph, and the auth section |
| Path | Change |
| ----------------------------------- | -------------------------------------------------------------------------- |
| `shared/indexes/indexes.go` | compound `(instance_id, email)` unique index; idempotent drop of `email_1` |
| `shared/models/user.go` | `HQUserID` field, `AuthLocal`/`AuthOIDC`/`AuthHQ` constants |
| `server/internal/services/users.go` | `GetUserByEmail` deleted, `GetUserInInstanceByEmail` added |
| `server/internal/auth/local.go` | `resolveLoginInstance`, scoped sign-in |
| `server/internal/auth/oidc.go` | scoped lookup, cross-instance guard deleted |
| `admin/internal/auth/cloud.go` | **deleted** |
| `admin/internal/api/routes.go` | `/auth/login` points at `HandleCustomerLogin`; new staff route |
| `admin/internal/api/staff.go` | `staffCreateAccountUser` |
| `CLAUDE.md` | the index security-property paragraph, and the auth section |
**Created:** none.
@@ -57,9 +57,11 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
### Task 1: Compound index and the drop
**Files:**
- Modify: `shared/indexes/indexes.go`
**Interfaces:**
- Consumes: nothing new.
- Produces: `indexes.EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` — unchanged signature, new behaviour. Called at boot by `server`, `sitesvc` and `admin`.
@@ -172,17 +174,21 @@ func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string)
- [ ] **Step 2: Confirm it compiles**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm the legacy index is not declared anywhere else**
Run:
```sh
grep -rn '"email"' --include=*.go shared/ server/ sitesvc/ admin/ | grep -i index
```
Expected: no matches. If sitesvc or the server declares its own `users.email` index, it would recreate what Task 1 drops.
- [ ] **Step 4: Commit**
@@ -206,15 +212,17 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: `hq` fields on the user document
**Files:**
- Modify: `shared/models/user.go`
- Modify: `server/internal/models/user.go`
**Interfaces:**
- Consumes: nothing.
- Produces:
- `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
- `shared/models.AuthLocal = "local"`, `AuthOIDC = "oidc"`, `AuthHQ = "hq"`
- `shared/models.User.HQUserID string` — bson `hq_user_id,omitempty`
- the same three constants re-exported from `server/internal/models`, which is a thin alias file over `shared/models` and is what server code imports
Nothing writes `AuthHQ` or `HQUserID` in this phase. They land now so phases 2 and 3 do not have to change the shared module and rebuild every service again.
@@ -261,7 +269,7 @@ type User struct {
```go
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
@@ -283,10 +291,12 @@ func ValidRole(role string) bool { return shared.ValidRole(role) }
- [ ] **Step 3: Confirm both compile**
Run:
```sh
sh /tmp/gorun.sh shared go build ./...
sh /tmp/gorun.sh server go build ./...
```
Expected: no output from either.
- [ ] **Step 4: Commit**
@@ -306,9 +316,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: Scoped lookup in the user service
**Files:**
- Modify: `server/internal/services/users.go:65-75`
**Interfaces:**
- Consumes: `shared/indexes` from Task 1.
- Produces: `services.GetUserInInstanceByEmail(instanceID, email string) (*models.User, error)`.
- Removes: `services.GetUserByEmail`. Tasks 4 and 5 fix its two callers; the build will be red between this task and Task 5, which is expected and is why they are adjacent.
@@ -343,15 +355,19 @@ func GetUserInInstanceByEmail(instanceID, email string) (*models.User, error) {
- [ ] **Step 2: Confirm the unscoped helper is gone and the build is red for the expected reason**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: exactly two matches, both call sites — `server/internal/auth/local.go` and `server/internal/auth/oidc.go`. No definition.
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at those two call sites. Any other error means something else was broken.
- [ ] **Step 3: Do not commit yet**
@@ -363,9 +379,11 @@ The build is red. Commit at the end of Task 5, when both callers are fixed. A co
### Task 4: Scoped local login
**Files:**
- Modify: `server/internal/auth/local.go:25-49`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3, `services.CountInstances` and `services.FirstInstance` from `server/internal/services/instances.go:57` and `:63`, `auth.InstanceFromHost` from `server/internal/auth/instancehost.go:53`.
- Produces: `resolveLoginInstance(c *gin.Context) (string, error)`, unexported, used only by this file.
@@ -426,9 +444,11 @@ The `SaveSession` call below it is unchanged: it already reads `u.InstanceID`.
- [ ] **Step 2: Confirm only the OIDC caller is left broken**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.go:130` only.
---
@@ -436,9 +456,11 @@ Expected: FAIL with `undefined: services.GetUserByEmail` at `internal/auth/oidc.
### Task 5: Scoped OIDC callback
**Files:**
- Modify: `server/internal/auth/oidc.go:129-141`
**Interfaces:**
- Consumes: `services.GetUserInInstanceByEmail` from Task 3.
- Produces: nothing new.
@@ -485,7 +507,7 @@ with:
`services.CreateUser`'s signature is `CreateUser(instanceID, email, password, role, authSource string)` — the argument order above matches it, with the two string literals the old code passed replaced by the constants Task 2 added.
`oidc.go` already imports `github.com/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
`oidc.go` already imports `gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models`; confirm it before relying on the constants:
```sh
grep -n "server/internal/models" server/internal/auth/oidc.go
@@ -496,23 +518,29 @@ If that returns nothing, add the import rather than reverting to string literals
- [ ] **Step 2: Confirm the build is green**
Run:
```sh
sh /tmp/gorun.sh server go build ./...
```
Expected: no output.
- [ ] **Step 3: Confirm no unscoped email lookup survives anywhere in the server**
Run:
```sh
grep -rn "GetUserByEmail" --include=*.go .
```
Expected: no matches at all.
Run:
```sh
grep -rn 'FindOne(ctx, bson.M{"email"' --include=*.go server/
```
Expected: no matches.
**Coverage note.** The spec's phase-1 test 6 exercises this path end to end, which needs a working identity provider and is not reproducible in the container harness Task 7 uses. It is verified here by inspection and by the greps in Step 3 instead: the lookup is scoped by `instanceID`, which comes from `ConsumeStateInstance` and not from user input, and the deleted guard was the only other consumer of the unscoped helper. The first real OIDC sign-in after deployment is the confirming evidence — check that an existing SSO user still lands in their own instance before considering this closed.
@@ -541,11 +569,13 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: Remove admin's unscoped control-plane login
**Files:**
- Delete: `admin/internal/auth/cloud.go`
- Modify: `admin/internal/api/routes.go:30`, `admin/internal/api/routes.go:50-52`
- Modify: `admin/internal/api/staff.go`
**Interfaces:**
- Consumes: `auth.CreateCustomerUser(ctx, accountID, email, password string) error` from `admin/internal/auth/customer.go:32`.
- Produces: `POST /api/staff/accounts/:id/users`.
@@ -635,23 +665,29 @@ grep -n '"strings"' admin/internal/api/staff.go
- [ ] **Step 5: Confirm the build is green and nothing still references the deleted handler**
Run:
```sh
grep -rn "HandleCloudLogin" --include=*.go .
```
Expected: no matches.
Run:
```sh
sh /tmp/gorun.sh admin go build ./...
```
Expected: no output. If `sharedmodels` is now an unused import in some file, remove that import line.
- [ ] **Step 6: Confirm admin has no unscoped control-plane user lookup left**
Run:
```sh
grep -rn 'db.Control("users")' --include=*.go admin/
```
Expected: no matches.
- [ ] **Step 7: Commit**
@@ -677,9 +713,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: Documentation and end-to-end verification
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: everything above.
- Produces: nothing.
@@ -691,7 +729,7 @@ In the **Auth and Orgs** section, replace the paragraph beginning "Unique indexe
```markdown
Unique indexes are a **security property**, not an optimisation. `users` is
unique on `(instance_id, email)` — one address is one user *within* an instance,
unique on `(instance_id, email)` — one address is one user _within_ an instance,
and the same address may hold a user in several instances, because an account's
people are projected into each instance they are granted. This is sufficient only
because **every lookup by email is scoped by instance**; there is deliberately no
@@ -718,6 +756,7 @@ In the **MongoDB Collections** notes, add:
MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:test .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:test .
```
Expected: two image IDs. A "missing go.sum entry" failure here means `go mod tidy` was run in workspace mode.
- [ ] **Step 3: Start a scratch Mongo and Redis, and seed the OLD index**
@@ -732,6 +771,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.users.createIndex({email:1},{unique:true}); db.getCollection("users").getIndexes().map(i=>i.name)'
```
Expected: output includes `email_1`. This reproduces a database that predates the change.
- [ ] **Step 4: Boot the server and confirm the swap**
@@ -747,6 +787,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'db.getCollection("users").getIndexes().map(i=>({name:i.name,key:i.key,unique:i.unique}))'
```
Expected: `instance_email_unique` present with key `{instance_id:1, email:1}` and `unique:true`; **no `email_1`**.
- [ ] **Step 5: Confirm a second boot is a no-op**
@@ -756,6 +797,7 @@ MSYS_NO_PATHCONV=1 docker restart vantage-idx-server
sleep 5
MSYS_NO_PATHCONV=1 docker logs vantage-idx-server 2>&1 | grep -i "index\|fatal" | tail -5
```
Expected: no index error and no fatal. The drop must tolerate the index already being gone.
- [ ] **Step 6: Bootstrap instance A and capture its user's password hash**
@@ -765,6 +807,7 @@ curl -s -X POST http://localhost:8091/auth/bootstrap \
-H 'Content-Type: application/json' \
-d '{"instance_name":"Alpha","email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: JSON with `instance_id` and `"slug":"alpha"`.
```sh
@@ -772,6 +815,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const u=db.users.findOne({email:"shared@example.com"}); print(u.user_id); print(u.password_hash)'
```
Expected: a UUID and a bcrypt hash. Keep both.
- [ ] **Step 7: Create instance B with the SAME address — the case that was impossible before**
@@ -795,6 +839,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
print("users with that address: " + db.users.countDocuments({email:"shared@example.com"}));
'
```
Expected: `users with that address: 2`. Under the old global index this insert would have failed with E11000 — that failure is exactly what this phase removes.
- [ ] **Step 8: Confirm the compound index still refuses a duplicate WITHIN one instance**
@@ -810,6 +855,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
} catch (e) { print("refused as expected: " + (e.code === 11000)); }
'
```
Expected: `refused as expected: true`. A `FAIL` line means the compound index is missing or not unique.
- [ ] **Step 9: Confirm each host signs in to its own instance — the whole point of the phase**
@@ -820,6 +866,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: alpha.vantage.test' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: alpha.vantage.test' -b /tmp/alpha.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Alpha**.
```sh
@@ -828,6 +875,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: beta.vantage.test' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8091/auth/me -H 'Host: beta.vantage.test' -b /tmp/beta.jar
```
Expected: `{"ok":true}`, then a body whose `instance` is **Beta**, with a different `instance_id` from the Alpha response.
Two sign-ins, one address, one password, two different tenants. If both responses name the same instance, the lookup is not scoped.
@@ -839,6 +887,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/logi
-H 'Host: vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `400`. Then read the message:
```sh
@@ -846,6 +895,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: an error naming both the instance count and the host. A `200` here would mean an arbitrary tenant was chosen.
- [ ] **Step 11: Confirm a wrong password still fails, on the right host**
@@ -855,6 +905,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8091/auth/logi
-H 'Host: alpha.vantage.test' -H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"wrongwrongwrong"}'
```
Expected: `401`.
- [ ] **Step 12: Confirm a single-instance deployment still signs in on a bare host**
@@ -864,6 +915,7 @@ MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway
mongosh "mongodb://host.docker.internal:27023/vantage_idx" --quiet --eval \
'const b=db.instances.findOne({slug:"beta"}); db.users.deleteMany({instance_id:b.instance_id}); db.instances.deleteOne({slug:"beta"}); print(db.instances.countDocuments({}))'
```
Expected: `1`.
```sh
@@ -871,6 +923,7 @@ curl -s -X POST http://localhost:8091/auth/login -H 'Host: vantage.test' \
-H 'Content-Type: application/json' \
-d '{"email":"shared@example.com","password":"hunter2hunter2"}'
```
Expected: `{"ok":true}`. This is the self-hosted path, and it must keep working.
- [ ] **Step 13: Confirm admin boots and its login route still works**
@@ -887,6 +940,7 @@ MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-admin -p 8093:8083 \
sleep 5
curl -s http://localhost:8093/healthz
```
Expected: `{"ok":true}`. A boot failure here most likely means an unused-import error that `go build` caught but the image build did not, or a missing env var.
```sh
@@ -894,6 +948,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8093/auth/logi
-H 'Content-Type: application/json' \
-d '{"email":"nobody@example.com","password":"hunter2hunter2"}'
```
Expected: `401`, not `500`. This proves `/auth/login` is wired to a live handler after `HandleCloudLogin` was deleted.
- [ ] **Step 14: Tear the scratch environment down**
@@ -12,13 +12,13 @@
- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
golang:1.26 "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry".
- **Admin's control-plane writes stay confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry`. **Do not widen `inject`** — the password reconciliation pass added by this phase lives in its own package, `hqsync`, precisely so `inject` stays licence-only.
@@ -47,59 +47,61 @@ Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](..
**Created:**
| Path | Responsibility |
|---|---|
| `admin/internal/models/members.go` | `InstanceMember`, account-role constants |
| `admin/internal/db/backfill.go` | one-shot boot backfill: account roles, and members for phase-2 instances |
| `admin/internal/api/people.go` | account people: list, invite, role, delete, password |
| `admin/internal/api/members.go` | instance members: list, grant, role, revoke |
| `admin/internal/hqsync/hqsync.go` | the 15-minute password repair pass |
| `adminsite/app/(customer)/users/page.tsx` | the account's people |
| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table |
| `adminsite/app/(customer)/settings/page.tsx` | change password |
| `adminsite/components/MembersPanel.tsx` | who is on one instance |
| `adminsite/app/accept-invite/page.tsx` | an invitee sets their own password |
| Path | Responsibility |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `admin/internal/models/members.go` | `InstanceMember`, account-role constants |
| `admin/internal/db/backfill.go` | one-shot boot backfill: account roles, and members for phase-2 instances |
| `admin/internal/api/people.go` | account people: list, invite, role, delete, password |
| `admin/internal/api/members.go` | instance members: list, grant, role, revoke |
| `admin/internal/hqsync/hqsync.go` | the 15-minute password repair pass |
| `adminsite/app/(customer)/users/page.tsx` | the account's people |
| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table |
| `adminsite/app/(customer)/settings/page.tsx` | change password |
| `adminsite/components/MembersPanel.tsx` | who is on one instance |
| `adminsite/app/accept-invite/page.tsx` | an invitee sets their own password |
**Modified:**
| Path | Change |
|---|---|
| `admin/internal/models/models.go` | `CustomerUser.AccountRole`, `HQSyncFailedAt` |
| `admin/internal/db/db.go` | `instance_members` indexes |
| `admin/internal/auth/customer.go` | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup |
| `admin/internal/auth/middleware.go` | `RequireAccountRole`, `CurrentUser` |
| `admin/internal/cloudprov/cloudprov.go` | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
| `admin/internal/api/customer.go` | `createInstance` writes the owner's `instance_members` row |
| `admin/internal/api/routes.go` | the ten new customer routes |
| `admin/internal/mail/mail.go` | `SendInvite` |
| `admin/cmd/main.go` | run the backfill, start `hqsync` |
| `server/internal/services/users.go` | `ErrHQManaged` on role change and delete |
| `server/internal/api/instance.go` | map `ErrHQManaged` to 409 |
| `web/lib/api.ts` | `auth_source: "hq"`, `hq_user_id` on `InstanceUser` |
| `web/app/(app)/settings/instance/page.tsx` | read-only treatment for `hq` rows |
| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml` | `NEXT_PUBLIC_HQ_URL` |
| `adminsite/lib/api.ts` | member/people/password calls, `AccountRole` |
| `adminsite/app/(customer)/layout.tsx` | People and Settings nav |
| `adminsite/app/(customer)/instances/[id]/page.tsx` | mount `MembersPanel` |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it |
| `adminsite/app/verify/page.tsx` | route an invite token to `/accept-invite` |
| `CLAUDE.md` | membership model, the new routes, `NEXT_PUBLIC_HQ_URL` |
| Path | Change |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/internal/models/models.go` | `CustomerUser.AccountRole`, `HQSyncFailedAt` |
| `admin/internal/db/db.go` | `instance_members` indexes |
| `admin/internal/auth/customer.go` | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup |
| `admin/internal/auth/middleware.go` | `RequireAccountRole`, `CurrentUser` |
| `admin/internal/cloudprov/cloudprov.go` | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
| `admin/internal/api/customer.go` | `createInstance` writes the owner's `instance_members` row |
| `admin/internal/api/routes.go` | the ten new customer routes |
| `admin/internal/mail/mail.go` | `SendInvite` |
| `admin/cmd/main.go` | run the backfill, start `hqsync` |
| `server/internal/services/users.go` | `ErrHQManaged` on role change and delete |
| `server/internal/api/instance.go` | map `ErrHQManaged` to 409 |
| `web/lib/api.ts` | `auth_source: "hq"`, `hq_user_id` on `InstanceUser` |
| `web/app/(app)/settings/instance/page.tsx` | read-only treatment for `hq` rows |
| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml` | `NEXT_PUBLIC_HQ_URL` |
| `adminsite/lib/api.ts` | member/people/password calls, `AccountRole` |
| `adminsite/app/(customer)/layout.tsx` | People and Settings nav |
| `adminsite/app/(customer)/instances/[id]/page.tsx` | mount `MembersPanel` |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it |
| `adminsite/app/verify/page.tsx` | route an invite token to `/accept-invite` |
| `CLAUDE.md` | membership model, the new routes, `NEXT_PUBLIC_HQ_URL` |
---
### Task 1: The membership model, its indexes, and the phase-2 backfill
**Files:**
- Create: `admin/internal/models/members.go`, `admin/internal/db/backfill.go`
- Modify: `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`
**Interfaces:**
- Consumes: `db.Admin`, `db.Control`, `shared/models.RoleOwner`, `shared/license.DeploymentCloud`.
- Produces:
- `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
- `models.InstanceMember` struct
- `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
- `db.Backfill(ctx context.Context) error`
- `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
- `models.InstanceMember` struct
- `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
- `db.Backfill(ctx context.Context) error`
- [ ] **Step 1: Add the account-role constants and the member document**
@@ -235,9 +237,9 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -371,17 +373,19 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 2: `cloudprov` learns to project, revoke and repair
**Files:**
- Modify: `admin/internal/cloudprov/cloudprov.go`
**Interfaces:**
- Consumes: `db.Control`, `db.ControlDB`, `shared/provision.CreateUserWithHash`, `shared/models`.
- Produces:
- `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
- `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
- `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
- `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
- `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
- `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`
- `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
- `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
- `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
- `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
- `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
- `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`
- [ ] **Step 1: Append the projection functions**
@@ -497,10 +501,12 @@ Expected: no output.
Run: `grep -n 'db.Control("' admin/internal/cloudprov/cloudprov.go admin/internal/inject/inject.go | grep -o 'db.Control("[a-z_]*")' | sort -u`
Expected exactly two lines:
```
db.Control("instances")
db.Control("users")
```
If a third collection appears, stop — that is the design change the package comment forbids.
- [ ] **Step 4: Commit**
@@ -522,22 +528,24 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 3: The account's people — invite, accept, role, remove
**Files:**
- Create: `admin/internal/api/people.go`
- Modify: `admin/internal/auth/customer.go`, `admin/internal/auth/middleware.go`, `admin/internal/mail/mail.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `models.AccountRole*`, `models.InstanceMember`, `cloudprov.RevokeUser`, `cloudprov.CountOtherOwners`, `auth.CreateCustomerUser`.
- Produces:
- `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
- `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
- `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
- `auth.HandleAcceptInvite(c *gin.Context)`
- `mail.SendInvite(to, accountName string) error`
- routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`
- `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
- `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
- `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
- `auth.HandleAcceptInvite(c *gin.Context)`
- `mail.SendInvite(to, accountName string) error`
- routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`
- [ ] **Step 1: Add `CurrentUser` and the role guard**
In `admin/internal/auth/middleware.go`, add the import block entries `"github.com/mrhid6/vantage/admin/internal/db"`, `"github.com/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:
In `admin/internal/auth/middleware.go`, add the import block entries `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:
```go
const ctxCustomerUser = "admin_customer_user"
@@ -616,6 +624,7 @@ and
```
Update the two existing callers:
- in `HandleSignup`: `CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner)`
- in `admin/internal/api/staff.go`, `staffCreateAccountUser`: `auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner)` — staff attaching a legacy customer are attaching the person who runs that account.
@@ -784,12 +793,12 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1046,7 +1055,7 @@ Add `"fmt"` to the imports.
- [ ] **Step 7: Mount the routes**
In `admin/internal/api/routes.go`, add `"github.com/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:
In `admin/internal/api/routes.go`, add `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:
```go
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
@@ -1118,10 +1127,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 4: Instance members — grant, role, revoke
**Files:**
- Create: `admin/internal/api/members.go`
- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `ownedInstance`, `cloudprov.GrantUser/RevokeUser/SetMemberRole/CountOtherOwners`, `models.InstanceMember`.
- Produces: routes `GET,POST /api/instances/:id/members`, `PUT /api/instances/:id/members/:uid/role`, `DELETE /api/instances/:id/members/:uid`. `:uid` is the **customer_users.user_id**, not the control-plane user_id — the portal never has to know the projected ID.
@@ -1150,7 +1161,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
}
```
Add `"github.com/google/uuid"` and `sharedmodels "github.com/mrhid6/vantage/shared/models"` to that file's imports.
Add `"github.com/google/uuid"` and `sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"` to that file's imports.
- [ ] **Step 2: Write the member handlers**
@@ -1166,14 +1177,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/provision"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"errors"
)
@@ -1442,9 +1453,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 5: One password, every instance
**Files:**
- Modify: `admin/internal/api/people.go`, `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `cloudprov.SetPasswordHash`, `auth.BcryptCost`.
- Produces: route `PUT /api/account/password`, handler `changeAccountPassword`.
@@ -1565,10 +1578,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 6: `hqsync` — the fifteen-minute repair
**Files:**
- Create: `admin/internal/hqsync/hqsync.go`
- Modify: `admin/cmd/main.go`
**Interfaces:**
- Consumes: `cloudprov.ProjectedUsers`, `cloudprov.SetPasswordHash`, `db.Admin`.
- Produces: `hqsync.Reconcile(ctx) (checked, repaired int, err error)`, `hqsync.Start(ctx context.Context)`.
@@ -1592,9 +1607,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1697,7 +1712,7 @@ func runOnce(ctx context.Context) {
- [ ] **Step 2: Start it at boot**
In `admin/cmd/main.go`, add the import `"github.com/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:
In `admin/cmd/main.go`, add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:
```go
hqsync.Start(reconcileCtx)
@@ -1734,9 +1749,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 7: The control plane refuses to edit what HQ owns
**Files:**
- Modify: `server/internal/services/users.go`, `server/internal/api/instance.go`
**Interfaces:**
- Produces: `services.ErrHQManaged error`; `UpdateUserRole` and `DeleteUser` return it for `auth_source == "hq"`; the API maps it to 409.
- [ ] **Step 1: Add the error and the two guards**
@@ -1807,10 +1824,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 8: The HQ portal — people, members, password
**Files:**
- Create: `adminsite/app/(customer)/users/page.tsx`, `adminsite/app/(customer)/users/InvitePanel.tsx`, `adminsite/app/(customer)/settings/page.tsx`, `adminsite/app/accept-invite/page.tsx`, `adminsite/components/MembersPanel.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(customer)/instances/[id]/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`, `adminsite/app/verify/page.tsx`
**Interfaces:**
- Consumes: every route from Tasks 35.
- Produces: `AccountRole`, `InstanceRole`, `AccountUser`, `InstanceMember` types; `api.accountUsers`, `api.invite`, `api.setAccountRole`, `api.removeAccountUser`, `api.changePassword`, `api.acceptInvite`, `api.members`, `api.grantMember`, `api.setMemberRole`, `api.revokeMember`.
@@ -1819,10 +1838,9 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
In `adminsite/lib/api.ts`, add after the `post` helper:
```ts
const put = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const put = <T>(path: string, payload?: unknown) => req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
const del = <T>(path: string) => req<T>(path, { method: "DELETE" });
```
Add to the types section:
@@ -1951,8 +1969,7 @@ export function InvitePanel() {
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const invite = useMutation({
mutationFn: () => api.invite(email.trim().toLowerCase(), role),
@@ -1982,11 +1999,7 @@ export function InvitePanel() {
return (
<div className="grid gap-6">
{error && (
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
{error}
</p>
)}
{error && <p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">{error}</p>}
<table className="w-full border-collapse text-left text-[0.9rem]">
<thead>
@@ -2025,26 +2038,17 @@ export function InvitePanel() {
))}
</select>
) : (
<span className="font-mono text-[0.82rem]">
{u.account_role}
</span>
<span className="font-mono text-[0.82rem]">{u.account_role}</span>
)}
</td>
<td className="py-2.5 text-ink-2">
{u.verified_at ? "Active" : "Invitation pending"}
</td>
<td className="py-2.5 text-ink-2">{u.verified_at ? "Active" : "Invitation pending"}</td>
<td className="py-2.5 text-right">
{canManage && !isSelf && (
<button
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (
confirm(
`Remove ${u.email}? They lose access to every instance on this account.`,
)
)
remove.mutate(u.user_id);
if (confirm(`Remove ${u.email}? They lose access to every instance on this account.`)) remove.mutate(u.user_id);
}}
>
Remove
@@ -2076,14 +2080,8 @@ export function InvitePanel() {
hint="They choose their own password from the emailed link. Nothing happens until they open it."
/>
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Account role</span>
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
{assignable.map((r) => (
<option key={r} value={r}>
{r}
@@ -2091,10 +2089,7 @@ export function InvitePanel() {
))}
</select>
</label>
<p className="text-[0.82rem] text-ink-2">
An account role is not access to an instance. Give them that on the
instance itself.
</p>
<p className="text-[0.82rem] text-ink-2">An account role is not access to an instance. Give them that on the instance itself.</p>
<Button type="submit" disabled={invite.isPending || !email.trim()}>
{invite.isPending ? "Sending…" : "Send invitation"}
</Button>
@@ -2115,10 +2110,7 @@ export default function UsersPage() {
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">People</h1>
<p className="text-ink-2">
Everyone on this account. Owners and admins can invite people and grant them
access to instances; billing stays with owners.
</p>
<p className="text-ink-2">Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners.</p>
</header>
<InvitePanel />
</div>
@@ -2159,8 +2151,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
const fail = (e: unknown) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
const grant = useMutation({
mutationFn: () => api.grantMember(instanceId, selected, role),
@@ -2172,8 +2163,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
onError: fail,
});
const changeRole = useMutation({
mutationFn: (v: { uid: string; role: InstanceRole }) =>
api.setMemberRole(instanceId, v.uid, v.role),
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
onSuccess: refresh,
onError: fail,
});
@@ -2187,29 +2177,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const canManage = myRole === "owner" || myRole === "admin";
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
const candidates = (people.data ?? []).filter(
(p) => !granted.has(p.user_id) && p.verified_at,
);
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
return (
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
<div className="grid gap-1">
<h2 className="text-xl">Who can sign in</h2>
<p className="text-[0.82rem] text-ink-2">
Each person here has a real user inside this instance and signs in with their
Vantage HQ password.
</p>
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<ul className="grid gap-2">
{(members.data ?? []).map((m) => (
<li
key={m.member_id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
>
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
<span>{m.email}</span>
<span className="flex items-center gap-3">
{canManage ? (
@@ -2237,8 +2219,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
type="button"
className="text-[0.82rem] font-semibold text-expired underline"
onClick={() => {
if (confirm(`Remove ${m.email} from this instance?`))
revoke.mutate(m.customer_user_id);
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
}}
>
Remove
@@ -2247,9 +2228,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
</ul>
{canManage && (
@@ -2262,14 +2241,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
}}
>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Add someone
</span>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
<option value="">Choose a person…</option>
{candidates.map((p) => (
<option key={p.user_id} value={p.user_id}>
@@ -2279,14 +2252,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</select>
</label>
<label className="grid gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Role here
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as InstanceRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
@@ -2302,8 +2269,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
{canManage && pending > 0 && (
<p className="text-[0.82rem] text-ink-3">
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
yet and cannot be added until they do.
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
</p>
)}
</section>
@@ -2316,14 +2282,13 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
In `adminsite/app/(customer)/instances/[id]/page.tsx`, add the import `import { MembersPanel } from "@/components/MembersPanel";` and, immediately before the closing `</div>` of the returned tree:
```tsx
{instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings → Instance. We do not
have access to your own deployment.
</p>
)}
{
instance.deployment === "cloud" ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">Users for this install are managed inside it, in Settings → Instance. We do not have access to your own deployment.</p>
);
}
```
- [ ] **Step 6: The settings page**
@@ -2350,24 +2315,16 @@ export default function SettingsPage() {
onSuccess: (res) => {
setCurrent("");
setNext("");
setDone(
res.propagation_pending
? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes."
: "Password changed everywhere.",
);
setDone(res.propagation_pending ? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes." : "Password changed everywhere.");
},
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
return (
<div className="grid gap-8">
<header className="grid gap-2">
<h1 className="text-3xl">Settings</h1>
<p className="text-ink-2">
Your password signs you in here and into every Vantage instance you belong to.
Changing it changes all of them.
</p>
<p className="text-ink-2">Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them.</p>
</header>
<form
@@ -2379,14 +2336,7 @@ export default function SettingsPage() {
change.mutate();
}}
>
<Field
label="Current password"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
/>
<Field label="Current password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
<Field
label="New password"
type="password"
@@ -2432,8 +2382,7 @@ function AcceptForm() {
const accept = useMutation({
mutationFn: () => api.acceptInvite(token, password),
onSuccess: () => setDone(true),
onError: (e) =>
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
});
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
@@ -2458,10 +2407,7 @@ function AcceptForm() {
}}
>
<h1 className="text-3xl">Choose a password</h1>
<p className="text-ink-2">
This password signs you into Vantage HQ and into every instance you are given
access to. Nobody who invited you can see it.
</p>
<p className="text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to. Nobody who invited you can see it.</p>
<Field
label="New password"
type="password"
@@ -2496,10 +2442,10 @@ export default function AcceptInvitePage() {
Read `adminsite/app/verify/page.tsx`, find where it renders success from `api.verify(token)`, and add a branch before it: when the response has `needs_password`, redirect with
```tsx
if (data?.needs_password) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
return null;
}
if (data?.needs_password) {
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
return null;
}
```
using `useRouter` from `next/navigation`. This exists because an invitation and a verification link are the same shape, and someone will paste one into the other.
@@ -2520,19 +2466,18 @@ In `adminsite/app/(customer)/layout.tsx`, add two links inside the nav after the
In `adminsite/app/(customer)/instances/new/CreateForm.tsx`, phase 2's copy is now false — the password does propagate. Replace that paragraph with:
```tsx
<p className="text-[0.82rem] text-ink-2">
You sign in to it with this same email address and password. Changing your Vantage
HQ password changes it here too.
</p>
<p className="text-[0.82rem] text-ink-2">You sign in to it with this same email address and password. Changing your Vantage HQ password changes it here too.</p>
```
- [ ] **Step 10: Build the site**
Run:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build listing `/users`, `/settings` and `/accept-invite` among the routes.
- [ ] **Step 11: Confirm no hex colours crept in**
@@ -2561,9 +2506,11 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 9: `web/` shows what it does not own
**Files:**
- Modify: `web/lib/api.ts`, `web/app/(app)/settings/instance/page.tsx`, `web/Dockerfile`, `.gitea/workflows/server-deploy.yml`
**Interfaces:**
- Consumes: `InstanceUser.auth_source === "hq"` from the existing `/instance/users` response.
- Produces: `NEXT_PUBLIC_HQ_URL` at build time; locked rows in `MembersCard`.
@@ -2591,65 +2538,58 @@ export interface InstanceUser {
In `web/app/(app)/settings/instance/page.tsx`, inside `MembersCard`, add above the `return`:
```tsx
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
```
Then, in the row map, replace
```tsx
const locked = isSelf || (u.role === "owner" && !isOwner);
const locked = isSelf || (u.role === "owner" && !isOwner);
```
with
```tsx
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
```
Replace the sign-in cell with:
```tsx
<Td>
<Badge variant="neutral">
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
</Badge>
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
```
And replace the actions cell with:
```tsx
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a
href={hqUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-text-secondary underline"
>
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
<Td className="text-right">
{managedByHQ ? (
hqUrl ? (
<a href={hqUrl} target="_blank" rel="noreferrer" className="text-xs text-text-secondary underline">
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
```
- [ ] **Step 3: Wire the build variable**
@@ -2666,16 +2606,18 @@ Empty default on purpose: a self-hosted install has no HQ portal, and the label
In `.gitea/workflows/server-deploy.yml`, in the web image step, add the build arg:
```yaml
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
```
- [ ] **Step 4: Build**
Run:
```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/web":/app -w /app node:26-alpine \
sh -c "npm ci --silent && npm run build"
```
Expected: a successful build.
- [ ] **Step 5: Commit**
@@ -2697,6 +2639,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
### Task 10: Documentation and the end-to-end proof
**Files:**
- Modify: `CLAUDE.md`
This task proves the phase. With no test suite, this transcript is the only evidence — run it in full. Numbers in brackets are the spec's phase-3 test list.
@@ -2708,6 +2651,7 @@ cd deploy
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --build admin adminsite server web
docker compose logs -f admin | head -40
```
Expected in the log: `connected: admin=… control=…`, then any `backfill:` lines, then `admin listening on :8083`. A `backfill: FATAL` stops the phase — read it before continuing.
- [ ] **Step 2: Confirm the backfill did its two jobs**
@@ -2718,6 +2662,7 @@ docker compose exec -T mongo mongosh --quiet vantage_admin --eval '
print("members: " + db.instance_members.countDocuments({}));
print("cloud instances: " + db.admin_instances.countDocuments({deployment:"cloud",status:{$ne:"deleted"}}));'
```
Expected: `no role: 0`, and `members` equal to the cloud-instance count (or lower only where the log said an instance had no hq-sourced owner).
Restart admin and run the same query. Expected: identical numbers — the backfill is idempotent.
@@ -2730,6 +2675,7 @@ Sign in as an account owner in the portal, invite `tester@example.com` as a memb
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$INSTANCE/members \
-H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```
Expected: HTTP 409, `{"error":"they have not accepted their invitation yet"}`, and `db.users.countDocuments({instance_id:"…",email:"tester@example.com"})` still `0`.
- [ ] **Step 4: Accept, grant, and sign in to the instance [24]**
@@ -2741,6 +2687,7 @@ curl -sk -X POST https://<slug>.vantage.hostxtra.co.uk/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"tester@example.com","password":"<the password they chose>"}'
```
Expected: HTTP 200. This is the payoff — the HQ password authenticates against the instance with no call to admin.
- [ ] **Step 5: Two instances, two roles [25]**
@@ -2751,6 +2698,7 @@ Create a second Free instance is refused (one per account), so use a second acco
docker compose exec -T mongo mongosh --quiet vantage --eval '
db.users.find({hq_user_id:"'$TESTER_USER_ID'"},{instance_id:1,role:1,_id:0}).forEach(printjson)'
```
Expected: two rows, different `instance_id`, roles `member` and `admin`.
- [ ] **Step 6: Revoke removes the login but only there [26]**
@@ -2769,6 +2717,7 @@ Against a linked self-hosted instance:
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$SELFHOSTED/members \
-H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```
Expected: HTTP 400 with the "manages its own users" message, and `db.instance_members.countDocuments({instance_id:"$SELFHOSTED"})` is `0`.
- [ ] **Step 9: A member can do none of it [29]**
@@ -2788,6 +2737,7 @@ docker compose exec -T mongo mongosh --quiet vantage --eval '
docker compose restart admin
docker compose logs admin | grep hqsync
```
Expected: `hqsync: repaired 1 projected user(s) for tester@example.com` on the boot pass, and the hash matches `customer_users` again.
- [ ] **Step 11: The instance API refuses, not just the UI [31]**
@@ -2799,6 +2749,7 @@ curl -sk -b app_cookies.txt -X PUT https://<slug>.vantage.hostxtra.co.uk/api/ins
-H 'Content-Type: application/json' -d '{"role":"owner"}'
curl -sk -b app_cookies.txt -X DELETE https://<slug>.vantage.hostxtra.co.uk/api/instance/users/$HQ_CONTROL_USER_ID
```
Expected: HTTP 409 from both, with the "managed in Vantage HQ" message. **This is the check that matters most** — the UI lock is decoration; this is the boundary.
- [ ] **Step 12: Update `CLAUDE.md`**
@@ -2830,7 +2781,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
`instance_members` in admin's database is only admin's *index* of those
`instance_members` in admin's database is only admin's _index_ of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
@@ -2865,7 +2816,7 @@ and add `POST /auth/accept-invite` to the unauthenticated block.
In **MongoDB Collections**, note admin's `instance_members` alongside the existing admin-side collections description, and in the CI **Secrets / variables** table add:
```markdown
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
```
- [ ] **Step 13: Commit**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -16,27 +16,27 @@ Spec: [`docs/superpowers/specs/2026-07-24-paddle-billing-design.md`](../specs/20
- **No automated Go tests.** Verify by compiler, `grep`, `curl`, and running built images against scratch databases. **Do not add `*_test.go` files.**
- **Never run `go` or `npm` on the host.** Use the container wrappers:
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" golang:1.26 "$@"
```
```sh
# /tmp/npmrun.sh <dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
-w "/src/$DIR" node:26-alpine "$@"
```
```sh
# /tmp/gorun.sh <module-dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
-v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" golang:1.26 "$@"
```
```sh
# /tmp/npmrun.sh <dir> <command...>
DIR="$1"; shift
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
-w "/src/$DIR" node:26-alpine "$@"
```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** **Run `go mod tidy` with `GOWORK=off`.**
- **No price ID, product ID or Paddle URL is hard-coded**, in Go or TypeScript. Price IDs live in the `catalogue` collection (shipped), keyed environment→term, edited through the staff catalogue screen (shipped). The only Paddle literals allowed are the SDK's own base URLs and the two public env vars the browser build bakes in.
- **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action**. Never write an earlier `expires_at`.
- **Self-hosted is annual only.** A resolved self-hosted price whose term is not `annual` is a configuration error and must fail the handler loudly. `catalogue` already refuses to store such a price and `ResolveItems`/`LineItems` already return `ErrTermNotSold`.
- **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a *handled* event returns `200` and does nothing; a retry of a *failed* event is reprocessed.
- **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a _handled_ event returns `200` and does nothing; a retry of a _failed_ event is reprocessed.
- **`licensing.Issue` stays the only signer.** Billing calls it; billing never touches `licenses` or signs anything.
- **Admin's control-plane writes stay confined to `inject` and `cloudprov`.** This plan adds no third write path. Cloud delivery is `inject.Deliver`; self-hosted delivery is `mail.SendLicense`.
- **Customer endpoints answer 404, never 403,** for another account's resource — every instance handler goes through `ownedInstance`.
- **A licence is signed only from `granted`, never `desired`.** A webhook that confirms payment promotes `desired``granted` *then* issues. Nothing else promotes.
- **A licence is signed only from `granted`, never `desired`.** A webhook that confirms payment promotes `desired``granted` _then_ issues. Nothing else promotes.
- **Free stays outside Paddle entirely.** No £0 subscription, no Paddle customer at signup. The shipped self-serve Free flow (`POST /api/instances`, `/renew`, `/claim-free`, the lifecycle notices, the reaper) is untouched. `paddle_customer_id` is learned from the first real subscription webhook.
## What spec 7 already settled (do NOT rebuild)
@@ -59,59 +59,61 @@ Live Paddle sandbox work is **out of this plan's automated scope**: creating the
**Created:**
| Path | Responsibility |
|---|---|
| `admin/internal/paddle/client.go` | the `Client` interface, our own request/response types, and `Init` |
| `admin/internal/paddle/sdk.go` | the one adapter binding `Client` to `github.com/PaddleHQ/paddle-go-sdk` — the ONLY file that imports the SDK |
| `admin/internal/paddle/webhook.go` | signature verification of a raw request body |
| `admin/internal/billing/events.go` | idempotent claim of an event ID and the dispatch switch |
| `admin/internal/billing/subscription.go` | `subscription.*` → entitlement promotion + reissue, as a function of current state |
| `admin/internal/billing/transaction.go` | renewal and payment-failure handling |
| `admin/internal/billing/deliver.go` | inject for cloud, email the blob for self-hosted, from a background context |
| `admin/internal/api/paddle.go` | `POST /api/paddle/webhook`: read raw body, verify, claim, dispatch |
| `admin/internal/api/checkout.go` | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `PUT /api/instances/:id/entitlement`, `POST /api/billing/portal` |
| `adminsite/lib/paddle.ts` | memoised `initializePaddle` |
| `adminsite/components/CheckoutButton.tsx` | opens the overlay with the resolved line items and `custom_data` |
| `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx` | self-hosted purchase: configure, checkout, then link |
| Path | Responsibility |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `admin/internal/paddle/client.go` | the `Client` interface, our own request/response types, and `Init` |
| `admin/internal/paddle/sdk.go` | the one adapter binding `Client` to `github.com/PaddleHQ/paddle-go-sdk` — the ONLY file that imports the SDK |
| `admin/internal/paddle/webhook.go` | signature verification of a raw request body |
| `admin/internal/billing/events.go` | idempotent claim of an event ID and the dispatch switch |
| `admin/internal/billing/subscription.go` | `subscription.*` → entitlement promotion + reissue, as a function of current state |
| `admin/internal/billing/transaction.go` | renewal and payment-failure handling |
| `admin/internal/billing/deliver.go` | inject for cloud, email the blob for self-hosted, from a background context |
| `admin/internal/api/paddle.go` | `POST /api/paddle/webhook`: read raw body, verify, claim, dispatch |
| `admin/internal/api/checkout.go` | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `PUT /api/instances/:id/entitlement`, `POST /api/billing/portal` |
| `adminsite/lib/paddle.ts` | memoised `initializePaddle` |
| `adminsite/components/CheckoutButton.tsx` | opens the overlay with the resolved line items and `custom_data` |
| `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx` | self-hosted purchase: configure, checkout, then link |
**Modified:**
| Path | Change |
|---|---|
| `admin/go.mod`, `admin/go.sum` | the Paddle SDK |
| `admin/internal/config/config.go` | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret` (all required), read into `Config` |
| `admin/internal/models/models.go` | `PaddleEvent`, term + subscription-status constants, `Subscription` gains `Items`, `Instance` gains `Placeholder` |
| `admin/internal/db/db.go` | unique index on `paddle_events.event_id` |
| `admin/internal/mail/mail.go` | `SendCancelled`, `SendPastDue`, `SendLinkReminder` |
| `admin/internal/lifecycle/lifecycle.go` | awaiting-link sweep: backstop issuance from the entitlement, plus 24h/72h reminders |
| `admin/internal/api/customer.go` | `linkInstance` claims a placeholder + issues when a subscription is already recorded |
| `admin/internal/api/staff.go` | `staffBillingHealth` |
| `admin/internal/api/routes.go` | the webhook route, three customer routes, one staff route |
| `admin/cmd/main.go` | `paddle.Init` before the server starts |
| `adminsite/package.json`, `adminsite/Dockerfile` | `@paddle/paddle-js`; `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + `NEXT_PUBLIC_PADDLE_ENV` build args |
| `adminsite/lib/api.ts` | checkout-options + self-hosted-create + entitlement + portal calls and types |
| `adminsite/components/InstanceRecord.tsx` | an upgrade/configure entry on a cloud instance |
| `CLAUDE.md`, `docs/superpowers/specs/README.md` | the webhook, the new collections and env vars, spec status |
| Path | Change |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `admin/go.mod`, `admin/go.sum` | the Paddle SDK |
| `admin/internal/config/config.go` | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret` (all required), read into `Config` |
| `admin/internal/models/models.go` | `PaddleEvent`, term + subscription-status constants, `Subscription` gains `Items`, `Instance` gains `Placeholder` |
| `admin/internal/db/db.go` | unique index on `paddle_events.event_id` |
| `admin/internal/mail/mail.go` | `SendCancelled`, `SendPastDue`, `SendLinkReminder` |
| `admin/internal/lifecycle/lifecycle.go` | awaiting-link sweep: backstop issuance from the entitlement, plus 24h/72h reminders |
| `admin/internal/api/customer.go` | `linkInstance` claims a placeholder + issues when a subscription is already recorded |
| `admin/internal/api/staff.go` | `staffBillingHealth` |
| `admin/internal/api/routes.go` | the webhook route, three customer routes, one staff route |
| `admin/cmd/main.go` | `paddle.Init` before the server starts |
| `adminsite/package.json`, `adminsite/Dockerfile` | `@paddle/paddle-js`; `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + `NEXT_PUBLIC_PADDLE_ENV` build args |
| `adminsite/lib/api.ts` | checkout-options + self-hosted-create + entitlement + portal calls and types |
| `adminsite/components/InstanceRecord.tsx` | an upgrade/configure entry on a cloud instance |
| `CLAUDE.md`, `docs/superpowers/specs/README.md` | the webhook, the new collections and env vars, spec status |
---
### Task 1: The Paddle client, config, event model, and index
**Files:**
- Create: `admin/internal/paddle/client.go`, `admin/internal/paddle/sdk.go`, `admin/internal/paddle/webhook.go`
- Modify: `admin/internal/config/config.go`, `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`, `admin/go.mod`
**Interfaces:**
- Produces:
- `paddle.Client` interface: `UpdateSubscriptionItems(ctx, paddleSubID string, items []paddle.LineItem) error`, `PortalSession(ctx, paddleCustomerID string) (string, error)`, `Env() string`
- `paddle.LineItem{PriceID string; Quantity int}`
- `paddle.Init(cfg) (Client, error)` and package var access via `paddle.Get()`
- `paddle.VerifySignature(secret string, header string, body []byte) bool`
- `models.PaddleEvent`, `models.ClaimEvent(ctx, eventID, eventType string) (claimed bool, err error)`
- subscription-status constants `models.SubActive`, `SubCanceled`, `SubPastDue`, `SubTrialing`; term constants `models.TermMonthly = "monthly"`, `TermAnnual = "annual"`
- `models.Subscription.Items []models.SubItem` where `SubItem{PriceID string; Quantity int}`
- `models.Instance.Placeholder bool`
- `config.Config` fields `PaddleEnv`, `PaddleAPIKey`, `PaddleClientToken` (browser token is adminsite build-time, not read here), `PaddleWebhookSecret`
- `paddle.Client` interface: `UpdateSubscriptionItems(ctx, paddleSubID string, items []paddle.LineItem) error`, `PortalSession(ctx, paddleCustomerID string) (string, error)`, `Env() string`
- `paddle.LineItem{PriceID string; Quantity int}`
- `paddle.Init(cfg) (Client, error)` and package var access via `paddle.Get()`
- `paddle.VerifySignature(secret string, header string, body []byte) bool`
- `models.PaddleEvent`, `models.ClaimEvent(ctx, eventID, eventType string) (claimed bool, err error)`
- subscription-status constants `models.SubActive`, `SubCanceled`, `SubPastDue`, `SubTrialing`; term constants `models.TermMonthly = "monthly"`, `TermAnnual = "annual"`
- `models.Subscription.Items []models.SubItem` where `SubItem{PriceID string; Quantity int}`
- `models.Instance.Placeholder bool`
- `config.Config` fields `PaddleEnv`, `PaddleAPIKey`, `PaddleClientToken` (browser token is adminsite build-time, not read here), `PaddleWebhookSecret`
- [ ] **Step 1: Add config fields**
@@ -264,7 +266,7 @@ func (c *sdkClient) PortalSession(ctx context.Context, customerID string) (strin
func ptr[T any](v T) *T { return &v }
```
If a symbol above does not exist under that exact name in the installed SDK version, adjust *this file only* until `go build` passes; the `Client` interface must not change.
If a symbol above does not exist under that exact name in the installed SDK version, adjust _this file only_ until `go build` passes; the `Client` interface must not change.
- [ ] **Step 4: Webhook signature verification**
@@ -387,7 +389,7 @@ import (
"errors"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
@@ -451,7 +453,7 @@ In `admin/cmd/main.go`, after config load and before `api.Routes`, add:
}
```
Add the import `"github.com/mrhid6/vantage/admin/internal/paddle"`.
Add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"`.
- [ ] **Step 8: Add the SDK and confirm it builds**
@@ -459,6 +461,7 @@ Add the import `"github.com/mrhid6/vantage/admin/internal/paddle"`.
GOWORK=off /tmp/gorun.sh admin sh -c "go get github.com/PaddleHQ/paddle-go-sdk/v4 && go mod tidy"
GOWORK=off /tmp/gorun.sh admin go build ./...
```
Expected: no output. If the SDK's major version or symbol names differ, fix `sdk.go` only.
- [ ] **Step 9: Commit**
@@ -473,14 +476,16 @@ git commit -m "feat(admin): Paddle client behind an interface, config, and the e
### Task 2: The webhook endpoint — verify, claim, dispatch
**Files:**
- Create: `admin/internal/billing/events.go`, `admin/internal/api/paddle.go`
- Modify: `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `paddle.VerifySignature`, `models.ClaimEvent`, `models.MarkEventProcessed`.
- Produces:
- `billing.Event` — the decoded envelope `{EventID, EventType string; Data json.RawMessage; OccurredAt time.Time}`
- `billing.Dispatch(ctx context.Context, ev billing.Event) error`
- `billing.Event` — the decoded envelope `{EventID, EventType string; Data json.RawMessage; OccurredAt time.Time}`
- `billing.Dispatch(ctx context.Context, ev billing.Event) error`
- [ ] **Step 1: The envelope and dispatch switch**
@@ -570,10 +575,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
)
// paddleWebhook is the ingress for every Paddle event.
@@ -642,6 +647,7 @@ It must NOT be under the `cust` (`/api`) group's session middleware — Paddle c
```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```
Expected: no output.
Against admin running on a scratch database, a bad signature is rejected and a duplicate is a no-op:
@@ -650,6 +656,7 @@ Against admin running on a scratch database, a bad signature is rejected and a d
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8083/api/paddle/webhook \
-H 'Paddle-Signature: ts=1;h1=deadbeef' -d '{"event_id":"evt_x","event_type":"customer.updated","data":{}}'
```
Expected: `401`.
(A correctly-signed duplicate check needs the real secret; it is exercised in task 9's sandbox pass. The idempotency LOGIC is unit-visible: `ClaimEvent` twice against the scratch DB returns `true` then `false` — confirm with a tiny throwaway `curl` once a valid signature path exists, or by inserting the same `event_id` twice with `mongosh` and watching the second collide.)
@@ -666,14 +673,16 @@ git commit -m "feat(admin): Paddle webhook ingress — verify, idempotent claim,
### Task 3: Subscription events → entitlement promotion and reissue
**Files:**
- Modify: `admin/internal/billing/subscription.go` (create), `admin/internal/billing/events.go` (remove the three subscription stubs)
- Create: `admin/internal/billing/deliver.go`
**Interfaces:**
- Consumes: `catalogue.ResolveItems`, `catalogue.Match`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.Config`, `licensing.Issue`, `paddle.Get().Env()`, `inject.Deliver`, `mail.SendLicense`.
- Produces:
- `billing.deliver(ctx, inst *models.Instance, lic *models.License)`
- the real `handleSubscription`, `handleCanceled`, `handlePastDue`
- `billing.deliver(ctx, inst *models.Instance, lic *models.License)`
- the real `handleSubscription`, `handleCanceled`, `handlePastDue`
- [ ] **Step 1: Background delivery**
@@ -685,10 +694,10 @@ package billing
import (
"context"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
@@ -718,11 +727,11 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -944,7 +953,7 @@ func handlePastDue(ctx context.Context, ev Event) error {
}
```
`sendCancelled`/`sendPastDue` are thin wrappers over the mail functions added in task 6; declare them there. For this task to compile, add the mail functions in task 6 *first*, or add temporary local wrappers — the plan orders task 6's mail additions before this compiles cleanly, so add the three mail funcs now (they are small; see task 6 step 1) if executing strictly in order.
`sendCancelled`/`sendPastDue` are thin wrappers over the mail functions added in task 6; declare them there. For this task to compile, add the mail functions in task 6 _first_, or add temporary local wrappers — the plan orders task 6's mail additions before this compiles cleanly, so add the three mail funcs now (they are small; see task 6 step 1) if executing strictly in order.
- [ ] **Step 4: `customer.updated`**
@@ -978,6 +987,7 @@ Remove the corresponding stubs from `events.go`.
GOWORK=off /tmp/gorun.sh admin go build ./...
GOWORK=off /tmp/gorun.sh admin go vet ./...
```
Expected: no output.
`grep` the two guard rails:
@@ -985,11 +995,13 @@ Expected: no output.
```bash
grep -rn "expires_at\|ExpiresAt" admin/internal/billing/
```
Expected: no assignment of an earlier expiry — cancel/past-due touch only `status`.
```bash
grep -rn "\.Desired" admin/internal/billing/
```
Expected: no read of `Desired` — billing signs from `granted` only (`promoteAndIssue` writes both from the confirmed match).
- [ ] **Step 6: Commit**
@@ -1004,9 +1016,11 @@ git commit -m "feat(admin): subscription webhooks promote the entitlement and re
### Task 4: Renewals and payment failures
**Files:**
- Modify: `admin/internal/billing/transaction.go` (create), `admin/internal/billing/events.go` (remove the two transaction stubs)
**Interfaces:**
- Consumes: `catalogue.ResolveItems`, `licensing.Issue` with `Reason: models.ReasonRenewal`, `models.GetEntitlement`, `models.UpsertEntitlement`.
- Produces: `handleTransactionCompleted`, `handlePaymentFailed`.
@@ -1022,11 +1036,11 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/admin/internal/catalogue"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1158,11 +1172,13 @@ Remove the two transaction stubs from `events.go`.
```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```
Expected: no output.
```bash
grep -rn "ReasonRenewal" admin/internal/billing/transaction.go
```
Expected: one hit — the renewal issue. A renewal that used `ReasonEntitlementChange` would not reset `relink_count`.
- [ ] **Step 3: Commit**
@@ -1177,16 +1193,18 @@ git commit -m "feat(admin): renewals issue the next term and collapse a schedule
### Task 5: Checkout options, self-hosted placeholder, entitlement update, portal
**Files:**
- Create: `admin/internal/api/checkout.go`
- Modify: `admin/internal/api/routes.go`
**Interfaces:**
- Consumes: `models.AllCatalogue`/`CatalogueFor`, `models.GetPlan`, `catalogue.LineItems`, `paddle.Get()`, `ownedInstance`, `auth.Current`, `shared/provision` slug rules (reuse whatever `createInstance` uses to mint an instance id), `models.UpsertEntitlement`, `models.GetEntitlement`.
- Produces:
- `GET /api/checkout/options``{plans: []Plan, catalogue: []CatalogueRow, env: string, client_token_note}` (client token is baked into the browser build, not served)
- `POST /api/instances/self-hosted` → creates a placeholder instance, returns `{instance_id}`
- `PUT /api/instances/:id/entitlement` → sets `desired`, computes line items, calls `paddle.UpdateSubscriptionItems`, returns the entitlement
- `POST /api/billing/portal``{url}`
- `GET /api/checkout/options``{plans: []Plan, catalogue: []CatalogueRow, env: string, client_token_note}` (client token is baked into the browser build, not served)
- `POST /api/instances/self-hosted` → creates a placeholder instance, returns `{instance_id}`
- `PUT /api/instances/:id/entitlement` → sets `desired`, computes line items, calls `paddle.UpdateSubscriptionItems`, returns the entitlement
- `POST /api/billing/portal``{url}`
- [ ] **Step 1: Checkout options**
@@ -1199,9 +1217,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -1413,6 +1431,7 @@ In `routes.go`, in the `cust` group:
```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```
Expected: no output.
Against a scratch DB with a staff session, `GET /api/checkout/options` returns six plans and sixteen catalogue rows; `POST /api/instances/self-hosted` returns an `instance_id` and leaves a `placeholder:true` row:
@@ -1437,9 +1456,11 @@ git commit -m "feat(admin): checkout options, self-hosted placeholder, entitleme
### Task 6: The awaiting-link sweep, reminders, and placeholder claim
**Files:**
- Modify: `admin/internal/mail/mail.go`, `admin/internal/lifecycle/lifecycle.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/api/routes.go`, `admin/internal/billing/subscription.go` (wire `sendCancelled`/`sendPastDue`)
**Interfaces:**
- Produces: `mail.SendCancelled`, `mail.SendPastDue`, `mail.SendLinkReminder`; `staffBillingHealth`; `linkInstance` claiming a placeholder and issuing when a subscription exists.
- [ ] **Step 1: Mail functions**
@@ -1571,6 +1592,7 @@ func staffBillingHealth(c *gin.Context) {
GOWORK=off /tmp/gorun.sh admin go build ./...
GOWORK=off /tmp/gorun.sh admin go vet ./...
```
Expected: no output.
- [ ] **Step 6: Commit**
@@ -1585,10 +1607,12 @@ git commit -m "feat(admin): awaiting-link reminders, placeholder claim-and-issue
### Task 7: The customer purchase and upgrade UI
**Files:**
- Create: `adminsite/lib/paddle.ts`, `adminsite/components/CheckoutButton.tsx`, `adminsite/app/(customer)/purchase/page.tsx`, `adminsite/app/(customer)/purchase/PurchaseForm.tsx`
- Modify: `adminsite/package.json`, `adminsite/lib/api.ts`, `adminsite/components/InstanceRecord.tsx`
**Interfaces:**
- Consumes: shipped `PlanConfigurator`/`PlanChoice`, `api.staff.plans` shape (reuse types), new `api.checkoutOptions`, `api.createSelfHosted`, `api.updateEntitlement`, `api.billingPortal`.
- Produces: `initPaddle()`, `<CheckoutButton items custom_data />`.
@@ -1611,8 +1635,7 @@ let cached: Promise<Paddle | undefined> | null = null;
export function initPaddle(): Promise<Paddle | undefined> {
if (!cached) {
cached = initializePaddle({
environment:
(process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
});
}
@@ -1655,12 +1678,7 @@ export function CheckoutButton({
});
}
return (
<button
type="button"
disabled={disabled || busy || items.length === 0}
onClick={open}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
<button type="button" disabled={disabled || busy || items.length === 0} onClick={open} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
{busy ? "Opening…" : label}
</button>
);
@@ -1710,6 +1728,7 @@ sh /tmp/npmrun.sh adminsite npm run build
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/app/\(customer\)/purchase/
grep -rn "pri_\|sandbox\|production" adminsite/ --include=*.tsx --include=*.ts | grep -v "NEXT_PUBLIC\|process.env\|\"sandbox\"\|\"production\"" | grep -v "pri_…"
```
Expected: successful build; no hex; no hard-coded price IDs.
- [ ] **Step 8: Commit**
@@ -1724,6 +1743,7 @@ git commit -m "feat(adminsite): self-hosted purchase, cloud upgrade, checkout ov
### Task 8: Deployment configuration and docs
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md`
- [ ] **Step 1: Compose env**
@@ -60,7 +60,7 @@ rather than two.
```
vantage/
├── go.work # NEW: server, sitesvc, shared (NOT agent)
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
├── shared/ # NEW module: gitea.hostxtra.co.uk/mrhid6/vantage/shared
│ ├── go.mod
│ ├── models/
│ │ ├── org.go # Org
@@ -93,8 +93,8 @@ use (
Each consumer's `go.mod` also carries an explicit replace:
```
require github.com/mrhid6/vantage/shared v0.0.0
replace github.com/mrhid6/vantage/shared => ../shared
require gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
```
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
@@ -111,11 +111,11 @@ keeps sitesvc small.
**`shared/models`** — the three documents written by more than one service:
| Type | From | Written by |
|---|---|---|
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
| Type | From | Written by |
| ------------------------------------- | ------------------------------------ | ---------------------------------- |
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
@@ -272,12 +272,12 @@ No database migration. No downtime.
## Risks
| Risk | Mitigation |
|---|---|
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| Risk | Mitigation |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
## Follow-on
+1 -1
View File
@@ -2,7 +2,7 @@ syntax = "proto3";
package vantage.v1;
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
option go_package = "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb";
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
+6 -6
View File
@@ -6,13 +6,13 @@ import (
"os"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/api"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/db"
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
"github.com/mrhid6/vantage/server/internal/monitorsched"
"github.com/mrhid6/vantage/server/internal/services"
)
func main() {
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/vantage/server
module gitea.hostxtra.co.uk/mrhid6/vantage/server
go 1.26
@@ -38,7 +38,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mrhid6/vantage/shared v0.0.0
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -58,4 +58,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
+3 -3
View File
@@ -3,10 +3,10 @@ package api
import (
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -7,9 +7,9 @@ import (
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"os"
"strconv"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func actorFromCtx(c *gin.Context) string {
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func listInstanceUsers(c *gin.Context) {
+3 -3
View File
@@ -6,10 +6,10 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"regexp"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
)
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+3 -3
View File
@@ -10,10 +10,10 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func registerWorkflowRoutes(g *gin.RouterGroup) {
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
type cachedInstance struct {
+2 -2
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func SetSessionCookie(c *gin.Context, sessionID string) {
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"strings"
"sync"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"golang.org/x/oauth2"
)
+4 -4
View File
@@ -7,10 +7,10 @@ import (
"net"
"time"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/encoding"
+1 -1
View File
@@ -1,6 +1,6 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
+1 -1
View File
@@ -1,6 +1,6 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
+1 -1
View File
@@ -1,6 +1,6 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type User = shared.User
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
const reloadInterval = 30 * time.Second
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
type Event struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"time"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
var httpClient = &http.Client{Timeout: 10 * time.Second}
+2 -2
View File
@@ -3,8 +3,8 @@ package notify
import (
"fmt"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/mail"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
)
// dispatchSMTP delivers a state change over one channel's own SMTP settings.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+3 -3
View File
@@ -4,10 +4,10 @@ import (
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+2 -2
View File
@@ -10,9 +10,9 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/indexes"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+3 -3
View File
@@ -5,10 +5,10 @@ import (
"path/filepath"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings"
"sync"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
)
type commandDispatcher struct {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+9 -9
View File
@@ -8,20 +8,20 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LicenseState is the resolved licence for one instance.
type LicenseState struct {
Status license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
SupportLevel string `json:"support_level,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
Status license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
SupportLevel string `json:"support_level,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
// Source is "stored", "env" or "none" — useful when a self-hosted operator
// asks why the licence they pasted is not the one in effect.
Source string `json:"source"`
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+1 -1
View File
@@ -6,8 +6,8 @@ import (
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+4 -4
View File
@@ -7,11 +7,11 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"os"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sort"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+2 -2
View File
@@ -10,9 +10,9 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
+2 -2
View File
@@ -16,8 +16,8 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
const StepDocKind = "vantage.step/v1"
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package services
import (
"sync"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
)
type stepResultRegistry struct {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)

Some files were not shown because too many files have changed in this diff Show More