feat: Updated package path to match repo
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
module github.com/mrhid6/vantage/agent
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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{}}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 2–3'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 2–4
|
||||
- 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 2–4
|
||||
- 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 1–6
|
||||
- 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 3–5.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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._-]+$`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user