From dabe6fe3aaf5bbc7b9be247fc5e86dd0fb0664ab Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 28 Jul 2026 10:01:40 +0100 Subject: [PATCH] feat: Updated package path to match repo --- admin/cmd/adminctl/main.go | 6 +- admin/cmd/main.go | 26 +- admin/go.mod | 6 +- admin/internal/api/checkout.go | 16 +- admin/internal/api/customer.go | 22 +- admin/internal/api/entitlement.go | 10 +- admin/internal/api/members.go | 16 +- admin/internal/api/paddle.go | 8 +- admin/internal/api/people.go | 12 +- admin/internal/api/routes.go | 6 +- admin/internal/api/staff.go | 22 +- admin/internal/audit/audit.go | 4 +- admin/internal/auth/customer.go | 10 +- admin/internal/auth/middleware.go | 4 +- admin/internal/auth/staff.go | 6 +- admin/internal/billing/cloudprovision.go | 14 +- admin/internal/billing/deliver.go | 8 +- admin/internal/billing/subscription.go | 14 +- admin/internal/billing/transaction.go | 8 +- admin/internal/catalogue/catalogue.go | 4 +- admin/internal/catalogue/items.go | 4 +- admin/internal/cloudprov/cloudprov.go | 6 +- admin/internal/db/db.go | 2 +- admin/internal/hqsync/hqsync.go | 6 +- admin/internal/inject/inject.go | 8 +- admin/internal/licensing/issue.go | 10 +- admin/internal/licensing/link.go | 8 +- admin/internal/lifecycle/lifecycle.go | 8 +- admin/internal/mail/mail.go | 2 +- admin/internal/models/backfill.go | 6 +- admin/internal/models/catalogue.go | 4 +- admin/internal/models/entitlements.go | 4 +- admin/internal/models/models.go | 6 +- admin/internal/models/paddle_events.go | 2 +- admin/internal/models/plans.go | 4 +- agent/cmd/main.go | 4 +- agent/go.mod | 2 +- agent/internal/exec/exec.go | 21 +- agent/internal/grpc/client.go | 6 +- agent/internal/inventory/collect_linux.go | 8 +- agent/internal/inventory/inventory.go | 4 +- agent/internal/monitors/monitors.go | 16 +- agent/internal/sync/sync.go | 53 +- .../plans/2026-07-24-admin-backend.md | 287 ++++---- .../plans/2026-07-24-instance-licensing.md | 152 +++-- .../plans/2026-07-24-instance-rename.md | 163 +++-- .../plans/2026-07-24-licensing-core.md | 66 +- .../plans/2026-07-24-shared-module.md | 159 +++-- .../2026-07-26-cloud-instance-creation.md | 373 +++++----- .../2026-07-26-cloud-instance-identity.md | 103 ++- .../2026-07-26-cloud-instance-membership.md | 431 ++++++------ .../plans/2026-07-26-metered-licensing.md | 609 ++++++++--------- .../plans/2026-07-26-paddle-billing.md | 646 +++++++++--------- .../plans/2026-07-27-paddle-billing.md | 208 +++--- .../specs/2026-07-24-shared-module-design.md | 26 +- proto/vantage/v1/vantage.proto | 2 +- server/cmd/main.go | 12 +- server/go.mod | 6 +- server/internal/api/channels.go | 6 +- server/internal/api/console.go | 4 +- server/internal/api/handlers.go | 6 +- server/internal/api/instance.go | 6 +- server/internal/api/licence.go | 6 +- server/internal/api/monitors.go | 6 +- server/internal/api/secrets.go | 4 +- server/internal/api/workflows.go | 6 +- server/internal/auth/instancehost.go | 4 +- server/internal/auth/local.go | 4 +- server/internal/auth/oidc.go | 4 +- server/internal/grpc/server.go | 8 +- server/internal/models/instance.go | 2 +- server/internal/models/settings.go | 2 +- server/internal/models/user.go | 2 +- server/internal/monitorsched/scheduler.go | 6 +- server/internal/notify/dispatch.go | 2 +- server/internal/notify/http.go | 2 +- server/internal/notify/smtp.go | 4 +- server/internal/services/audit.go | 4 +- server/internal/services/audit_retention.go | 4 +- server/internal/services/channels.go | 6 +- server/internal/services/console.go | 4 +- server/internal/services/coreindexes.go | 4 +- server/internal/services/defaults.go | 6 +- server/internal/services/dispatch.go | 2 +- server/internal/services/instance_oidc.go | 4 +- server/internal/services/instances.go | 6 +- server/internal/services/inventory.go | 4 +- server/internal/services/keys.go | 4 +- server/internal/services/licence.go | 18 +- server/internal/services/licence_limits.go | 4 +- server/internal/services/migrate.go | 2 +- server/internal/services/monitors.go | 8 +- server/internal/services/reap.go | 4 +- server/internal/services/secrets.go | 4 +- server/internal/services/servers.go | 4 +- server/internal/services/settings.go | 4 +- server/internal/services/stepio.go | 2 +- server/internal/services/steplogs.go | 2 +- server/internal/services/stepresults.go | 2 +- server/internal/services/sync.go | 4 +- server/internal/services/users.go | 6 +- server/internal/services/validate.go | 2 +- server/internal/services/workflow_runner.go | 6 +- server/internal/services/workflows.go | 4 +- shared/cmd/lkctl/main.go | 2 +- shared/go.mod | 2 +- shared/provision/instance.go | 2 +- shared/provision/user.go | 2 +- sitesvc/cmd/main.go | 4 +- sitesvc/go.mod | 6 +- sitesvc/internal/api/api.go | 2 +- 111 files changed, 1988 insertions(+), 1899 deletions(-) diff --git a/admin/cmd/adminctl/main.go b/admin/cmd/adminctl/main.go index 3b9d61b..c6d4c2f 100644 --- a/admin/cmd/adminctl/main.go +++ b/admin/cmd/adminctl/main.go @@ -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" ) diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 4143418..2bc9cfa 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -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() { diff --git a/admin/go.mod b/admin/go.mod index 6c170ec..eba9c7f 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -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 diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go index 1efc345..ce6f4f6 100644 --- a/admin/internal/api/checkout.go +++ b/admin/internal/api/checkout.go @@ -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" ) diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 81da1af..8e2eb98 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -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" ) diff --git a/admin/internal/api/entitlement.go b/admin/internal/api/entitlement.go index 27679df..2b5cd63 100644 --- a/admin/internal/api/entitlement.go +++ b/admin/internal/api/entitlement.go @@ -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" ) diff --git a/admin/internal/api/members.go b/admin/internal/api/members.go index ba567ff..d27c7b9 100644 --- a/admin/internal/api/members.go +++ b/admin/internal/api/members.go @@ -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" ) diff --git a/admin/internal/api/paddle.go b/admin/internal/api/paddle.go index c387eeb..d138c6d 100644 --- a/admin/internal/api/paddle.go +++ b/admin/internal/api/paddle.go @@ -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. diff --git a/admin/internal/api/people.go b/admin/internal/api/people.go index 3a7c475..66ff7ba 100644 --- a/admin/internal/api/people.go +++ b/admin/internal/api/people.go @@ -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" ) diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 7e9cf75..8c741e8 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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 { diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 3a35b5d..762447b 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -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), }) } diff --git a/admin/internal/audit/audit.go b/admin/internal/audit/audit.go index 25fbd90..bec11f6 100644 --- a/admin/internal/audit/audit.go +++ b/admin/internal/audit/audit.go @@ -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 diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go index 0c4f258..d8e783b 100644 --- a/admin/internal/auth/customer.go +++ b/admin/internal/auth/customer.go @@ -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" ) diff --git a/admin/internal/auth/middleware.go b/admin/internal/auth/middleware.go index babfe66..9050e24 100644 --- a/admin/internal/auth/middleware.go +++ b/admin/internal/auth/middleware.go @@ -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" ) diff --git a/admin/internal/auth/staff.go b/admin/internal/auth/staff.go index b6d2c8d..823e2d6 100644 --- a/admin/internal/auth/staff.go +++ b/admin/internal/auth/staff.go @@ -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" ) diff --git a/admin/internal/billing/cloudprovision.go b/admin/internal/billing/cloudprovision.go index 0cde8db..f72729f 100644 --- a/admin/internal/billing/cloudprovision.go +++ b/admin/internal/billing/cloudprovision.go @@ -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" ) diff --git a/admin/internal/billing/deliver.go b/admin/internal/billing/deliver.go index 38d9b9d..82b3d24 100644 --- a/admin/internal/billing/deliver.go +++ b/admin/internal/billing/deliver.go @@ -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; diff --git a/admin/internal/billing/subscription.go b/admin/internal/billing/subscription.go index 96b47a0..bea2abc 100644 --- a/admin/internal/billing/subscription.go +++ b/admin/internal/billing/subscription.go @@ -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" ) diff --git a/admin/internal/billing/transaction.go b/admin/internal/billing/transaction.go index 754b531..72f5588 100644 --- a/admin/internal/billing/transaction.go +++ b/admin/internal/billing/transaction.go @@ -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" ) diff --git a/admin/internal/catalogue/catalogue.go b/admin/internal/catalogue/catalogue.go index 6313e31..f3f20d4 100644 --- a/admin/internal/catalogue/catalogue.go +++ b/admin/internal/catalogue/catalogue.go @@ -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 ( diff --git a/admin/internal/catalogue/items.go b/admin/internal/catalogue/items.go index 476e823..ecd3f16 100644 --- a/admin/internal/catalogue/items.go +++ b/admin/internal/catalogue/items.go @@ -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. diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go index a679117..d516d2b 100644 --- a/admin/internal/cloudprov/cloudprov.go +++ b/admin/internal/cloudprov/cloudprov.go @@ -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" ) diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index 3b29e12..39975ab 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -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" diff --git a/admin/internal/hqsync/hqsync.go b/admin/internal/hqsync/hqsync.go index 9f7ee6e..6d25139 100644 --- a/admin/internal/hqsync/hqsync.go +++ b/admin/internal/hqsync/hqsync.go @@ -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" ) diff --git a/admin/internal/inject/inject.go b/admin/internal/inject/inject.go index a1029dc..e9281ad 100644 --- a/admin/internal/inject/inject.go +++ b/admin/internal/inject/inject.go @@ -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" ) diff --git a/admin/internal/licensing/issue.go b/admin/internal/licensing/issue.go index fa95994..f040240 100644 --- a/admin/internal/licensing/issue.go +++ b/admin/internal/licensing/issue.go @@ -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" ) diff --git a/admin/internal/licensing/link.go b/admin/internal/licensing/link.go index ebfc4a6..41e70b1 100644 --- a/admin/internal/licensing/link.go +++ b/admin/internal/licensing/link.go @@ -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" ) diff --git a/admin/internal/lifecycle/lifecycle.go b/admin/internal/lifecycle/lifecycle.go index a609653..838a9b4 100644 --- a/admin/internal/lifecycle/lifecycle.go +++ b/admin/internal/lifecycle/lifecycle.go @@ -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" ) diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index 16aa9ab..ca8af54 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -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 diff --git a/admin/internal/models/backfill.go b/admin/internal/models/backfill.go index 2660dd3..b150766 100644 --- a/admin/internal/models/backfill.go +++ b/admin/internal/models/backfill.go @@ -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" ) diff --git a/admin/internal/models/catalogue.go b/admin/internal/models/catalogue.go index d7c5a4a..a5b89fc 100644 --- a/admin/internal/models/catalogue.go +++ b/admin/internal/models/catalogue.go @@ -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" ) diff --git a/admin/internal/models/entitlements.go b/admin/internal/models/entitlements.go index dd822ec..a0a798e 100644 --- a/admin/internal/models/entitlements.go +++ b/admin/internal/models/entitlements.go @@ -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" diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index e4897e3..feec4af 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -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. diff --git a/admin/internal/models/paddle_events.go b/admin/internal/models/paddle_events.go index c4e7722..fa0956c 100644 --- a/admin/internal/models/paddle_events.go +++ b/admin/internal/models/paddle_events.go @@ -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" ) diff --git a/admin/internal/models/plans.go b/admin/internal/models/plans.go index b5da069..3a2e5ce 100644 --- a/admin/internal/models/plans.go +++ b/admin/internal/models/plans.go @@ -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" ) diff --git a/agent/cmd/main.go b/agent/cmd/main.go index d1bbc6d..f26bfa1 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -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" diff --git a/agent/go.mod b/agent/go.mod index b2d64ed..275f341 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -1,4 +1,4 @@ -module github.com/mrhid6/vantage/agent +module gitea.hostxtra.co.uk/mrhid6/vantage/agent go 1.26 diff --git a/agent/internal/exec/exec.go b/agent/internal/exec/exec.go index 979ecbd..a68f967 100644 --- a/agent/internal/exec/exec.go +++ b/agent/internal/exec/exec.go @@ -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) diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go index 9f872fb..10171e4 100644 --- a/agent/internal/grpc/client.go +++ b/agent/internal/grpc/client.go @@ -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) } diff --git a/agent/internal/inventory/collect_linux.go b/agent/internal/inventory/collect_linux.go index 58f9797..7601d04 100644 --- a/agent/internal/inventory/collect_linux.go +++ b/agent/internal/inventory/collect_linux.go @@ -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 } } diff --git a/agent/internal/inventory/inventory.go b/agent/internal/inventory/inventory.go index fbb898f..8875d40 100644 --- a/agent/internal/inventory/inventory.go +++ b/agent/internal/inventory/inventory.go @@ -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{}} diff --git a/agent/internal/monitors/monitors.go b/agent/internal/monitors/monitors.go index 77ec420..62ddb81 100644 --- a/agent/internal/monitors/monitors.go +++ b/agent/internal/monitors/monitors.go @@ -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() diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index 8a31683..0a7213f 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -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 { diff --git a/docs/superpowers/plans/2026-07-24-admin-backend.md b/docs/superpowers/plans/2026-07-24-admin-backend.md index d19b593..4e0984c 100644 --- a/docs/superpowers/plans/2026-07-24-admin-backend.md +++ b/docs/superpowers/plans/2026-07-24-admin-backend.md @@ -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 - 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 + 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 | diff --git a/docs/superpowers/plans/2026-07-24-instance-licensing.md b/docs/superpowers/plans/2026-07-24-instance-licensing.md index 24ce8dc..9c96cd4 100644 --- a/docs/superpowers/plans/2026-07-24-instance-licensing.md +++ b/docs/superpowers/plans/2026-07-24-instance-licensing.md @@ -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

Instance ID — needed to activate a licence

{created.instance_id} -
@@ -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()`, `` @@ -1144,9 +1156,10 @@ export function LicenseBanner() { const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently"; return (
- Your Vantage licence expired on {when}. Your servers and monitors are still running, - but changes are disabled until it is renewed.{" "} - Add a licence + Your Vantage licence expired on {when}. Your servers and monitors are still running, but changes are disabled until it is renewed.{" "} + + Add a licence +
); } @@ -1155,7 +1168,9 @@ export function LicenseBanner() { return (
This instance has no valid licence. Changes are disabled.{" "} - Add a licence + + Add a licence +
); } @@ -1218,19 +1233,18 @@ export default function LicensePage() {

State: {license.state} - {license.tier ? <> · Tier: {license.tier} : null} + {license.tier ? ( + <> + {" "} + · Tier: {license.tier} + + ) : null} {license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()} : null}

-

- Instance ID — quote this when buying or activating a licence -

+

Instance ID — quote this when buying or activating a licence

{license.instance_id} -
@@ -1239,9 +1253,15 @@ export default function LicensePage() {

Usage

    -
  • Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
  • -
  • Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
  • -
  • Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
  • +
  • + Servers: {license.usage.servers} of {cap(license.limits.max_servers)} +
  • +
  • + Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)} +
  • +
  • + Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)} +
  • Browser console: {license.features.console ? "Included" : "Not included"}
  • Single sign-on: {license.features.oidc ? "Included" : "Not included"}
@@ -1265,12 +1285,7 @@ export default function LicensePage() { }} /> {error ?

{error}

: null} -
@@ -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 ."` 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 | diff --git a/docs/superpowers/plans/2026-07-24-instance-rename.md b/docs/superpowers/plans/2026-07-24-instance-rename.md index bd32e0c..9716dfb 100644 --- a/docs/superpowers/plans/2026-07-24-instance-rename.md +++ b/docs/superpowers/plans/2026-07-24-instance-rename.md @@ -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 ["", "", ""].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 ["", "", ""].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: diff --git a/docs/superpowers/plans/2026-07-24-licensing-core.md b/docs/superpowers/plans/2026-07-24-licensing-core.md index 231527e..09cf2b7 100644 --- a/docs/superpowers/plans/2026-07-24-licensing-core.md +++ b/docs/superpowers/plans/2026-07-24-licensing-core.md @@ -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() { diff --git a/docs/superpowers/plans/2026-07-24-shared-module.md b/docs/superpowers/plans/2026-07-24-shared-module.md index 1e73b02..da90ab9 100644 --- a/docs/superpowers/plans/2026-07-24-shared-module.md +++ b/docs/superpowers/plans/2026-07-24-shared-module.md @@ -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/`.** diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md index 13873fa..f29455d 100644 --- a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md @@ -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 - 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 + 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 " ### 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 " ### 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 " ### 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 " ### 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 " ### 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 " ### 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("/api/instances", { name }), - renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}), - ``` + + ```ts + createInstance: (name: string) => post("/api/instances", { name }), + renewInstance: (id: string) => post(`/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`} /> -

- You sign in to it with this same email address and password. Changing one does not - change the other afterwards. -

+

You sign in to it with this same email address and password. Changing one does not change the other afterwards.

- )} - ``` + ```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 && ( + + ); + } + ``` `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 " ### 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 { +export async function submitAccountSignup(fields: { name: string; email: string; password: string; website: string }): Promise { 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 `` 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 `

` 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 " ### 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 " ### 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 " - `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 diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md b/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md index 608006a..6b99e38 100644 --- a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md @@ -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 - 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 + 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 " ### 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 " ### 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 " ### 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 " ### 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** diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md b/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md index 9c761ac..955a2fb 100644 --- a/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md @@ -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 - 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 + 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 " ### 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 " ### 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 " ### 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 " } ``` -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 " ### 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 " ### 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 " ### 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 " ### 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 " In `adminsite/lib/api.ts`, add after the `post` helper: ```ts -const put = (path: string, payload?: unknown) => - req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined }); +const put = (path: string, payload?: unknown) => req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined }); -const del = (path: string) => req(path, { method: "DELETE" }); +const del = (path: string) => req(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 (
- {error && ( -

- {error} -

- )} + {error &&

{error}

} @@ -2025,26 +2038,17 @@ export function InvitePanel() { ))} ) : ( - - {u.account_role} - + {u.account_role} )} - + + ``` And replace the actions cell with: ```tsx - + ``` - [ ] **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 " ### 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://.vantage.hostxtra.co.uk/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"tester@example.com","password":""}' ``` + 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://.vantage.hostxtra.co.uk/api/ins -H 'Content-Type: application/json' -d '{"role":"owner"}' curl -sk -b app_cookies.txt -X DELETE https://.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** diff --git a/docs/superpowers/plans/2026-07-26-metered-licensing.md b/docs/superpowers/plans/2026-07-26-metered-licensing.md index 9fda067..2ceebe7 100644 --- a/docs/superpowers/plans/2026-07-26-metered-licensing.md +++ b/docs/superpowers/plans/2026-07-26-metered-licensing.md @@ -14,19 +14,19 @@ Spec: [`docs/superpowers/specs/2026-07-26-metered-licensing-design.md`](../specs - **No automated Go tests.** This repo has no Go test suite (one exception: `agent/internal/config/config_test.go`). Verification is by compiler, `grep`, `curl` and running built images against scratch databases. Every "confirm" step below 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. Create the wrappers if missing: - ```sh - # /tmp/gorun.sh - 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="$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 + 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="$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.** 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 plan adds no Paddle dependency, no webhook and no checkout.** See "The seam with plan 5" below. If you find yourself importing a Paddle SDK, stop. @@ -44,7 +44,7 @@ Spec 7 lands **before** [plan 5](2026-07-26-paddle-billing.md), whose preamble i **In this plan:** the data model, both resolution folds, issuance from an entitlement, control-plane enforcement, and staff-facing screens to edit plans, the catalogue and an instance's entitlement. -**Left to plan 5:** the Paddle client, webhooks, `GET /api/checkout/options`, the outbound subscription update, and the *customer's* purchase configurator. +**Left to plan 5:** the Paddle client, webhooks, `GET /api/checkout/options`, the outbound subscription update, and the _customer's_ purchase configurator. The customer-facing configurator is deliberately not built here. It cannot function without a checkout, and a dead screen is worse than no screen. Instead, task 8 builds the configurator as a **component** driven entirely by the catalogue, mounted first on the staff instance screen. Plan 5 then mounts the same component in the customer purchase flow and hands it a checkout. This mirrors how spec 3 made staff issuance work through the API before any UI existed for it. @@ -70,68 +70,70 @@ Shipped and load-bearing here: **Created:** -| Path | Responsibility | -|---|---| -| `admin/internal/models/catalogue.go` | the `CatalogueRow` document, its kind constants, its price lookup, and its seed | -| `admin/internal/models/entitlements.go` | the `Entitlement` and `Config` documents, and the per-instance read/upsert helpers | -| `admin/internal/catalogue/catalogue.go` | row loading, and the fold to `license.Limits` + features | -| `admin/internal/catalogue/items.go` | the fold to Paddle line items, and the reverse fold from an item list | -| `admin/internal/api/entitlement.go` | staff and customer entitlement endpoints | -| `server/internal/services/audit_retention.go` | the audit-log retention sweep | -| `adminsite/components/PlanConfigurator.tsx` | deployment/tier/term/servers/features, catalogue-driven, priced | -| `adminsite/app/(staff)/staff/catalogue/page.tsx` | the price-ID editor, one table per environment | +| Path | Responsibility | +| ------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `admin/internal/models/catalogue.go` | the `CatalogueRow` document, its kind constants, its price lookup, and its seed | +| `admin/internal/models/entitlements.go` | the `Entitlement` and `Config` documents, and the per-instance read/upsert helpers | +| `admin/internal/catalogue/catalogue.go` | row loading, and the fold to `license.Limits` + features | +| `admin/internal/catalogue/items.go` | the fold to Paddle line items, and the reverse fold from an item list | +| `admin/internal/api/entitlement.go` | staff and customer entitlement endpoints | +| `server/internal/services/audit_retention.go` | the audit-log retention sweep | +| `adminsite/components/PlanConfigurator.tsx` | deployment/tier/term/servers/features, catalogue-driven, priced | +| `adminsite/app/(staff)/staff/catalogue/page.tsx` | the price-ID editor, one table per environment | **Modified:** -| Path | Change | -|---|---| -| `shared/license/license.go` | `Limits` gains two fields; `License` gains `SupportLevel`; `FillUnset`; support constants | -| `shared/license/plans.go` | six plans keyed on `(deployment, tier)`; `PlanFor` re-signed; `NormaliseTier` | -| `shared/cmd/lkctl/main.go` | the new `PlanFor` signature and a `--deployment` flag | -| `admin/internal/models/models.go` | `Plan` re-shaped; `ReasonEntitlementChange` | -| `admin/internal/models/plans.go` | `SeedPlans` writes six rows; `GetPlan` takes a deployment | -| `admin/internal/models/backfill.go` | re-key plans, re-tier self-hosted instances, backfill entitlements | -| `admin/internal/db/db.go` | drop `plans.tier` unique; add three indexes | -| `admin/internal/licensing/issue.go` | issue from the entitlement; Free limit per deployment | -| `admin/internal/api/customer.go` | the Free pre-check gains a deployment; `claimFree`; `renewInstance` handles both deployments | -| `admin/internal/lifecycle/lifecycle.go` | deletion warnings and reaping stay cloud-only | -| `web/lib/api.ts` | `LicenseInfo` gains two limits, one usage count and the support level | -| `admin/internal/api/staff.go` | `staffUpdatePlan` re-keyed; catalogue handlers | -| `admin/internal/api/routes.go` | the re-keyed plan route and five new routes | -| `admin/cmd/main.go` | seed the catalogue at boot | -| `server/internal/services/licence.go` | fill unset limits from the plan base at the decode site | -| `server/internal/services/licence_limits.go` | `CheckMonitorLimit`; monitors in `LicenseUsage` | -| `server/internal/api/licence.go` | monitors in the usage response | -| `server/internal/api/monitors.go` | enforce the monitor cap on create | -| `server/internal/auth/oidc.go` | gate the callback | -| `server/cmd/main.go` | start the audit sweeper | -| `web/app/(app)/settings/license/page.tsx` | show monitors, audit retention and support level | -| `adminsite/lib/api.ts` | the re-shaped `Plan`, `CatalogueRow`, `Entitlement`, and their calls | -| `adminsite/app/(staff)/staff/plans/page.tsx` | six plans, allowances and support level; prices move out | -| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the entitlement configurator | -| `adminsite/components/AppBar.tsx` | a staff nav entry for Catalogue | -| `CLAUDE.md` | the collection list, the admin route table, the licensing summary | -| `docs/superpowers/specs/README.md` | spec 7 status | -| `docs/superpowers/plans/2026-07-26-paddle-billing.md` | the preamble, narrowed to what remains | +| Path | Change | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `shared/license/license.go` | `Limits` gains two fields; `License` gains `SupportLevel`; `FillUnset`; support constants | +| `shared/license/plans.go` | six plans keyed on `(deployment, tier)`; `PlanFor` re-signed; `NormaliseTier` | +| `shared/cmd/lkctl/main.go` | the new `PlanFor` signature and a `--deployment` flag | +| `admin/internal/models/models.go` | `Plan` re-shaped; `ReasonEntitlementChange` | +| `admin/internal/models/plans.go` | `SeedPlans` writes six rows; `GetPlan` takes a deployment | +| `admin/internal/models/backfill.go` | re-key plans, re-tier self-hosted instances, backfill entitlements | +| `admin/internal/db/db.go` | drop `plans.tier` unique; add three indexes | +| `admin/internal/licensing/issue.go` | issue from the entitlement; Free limit per deployment | +| `admin/internal/api/customer.go` | the Free pre-check gains a deployment; `claimFree`; `renewInstance` handles both deployments | +| `admin/internal/lifecycle/lifecycle.go` | deletion warnings and reaping stay cloud-only | +| `web/lib/api.ts` | `LicenseInfo` gains two limits, one usage count and the support level | +| `admin/internal/api/staff.go` | `staffUpdatePlan` re-keyed; catalogue handlers | +| `admin/internal/api/routes.go` | the re-keyed plan route and five new routes | +| `admin/cmd/main.go` | seed the catalogue at boot | +| `server/internal/services/licence.go` | fill unset limits from the plan base at the decode site | +| `server/internal/services/licence_limits.go` | `CheckMonitorLimit`; monitors in `LicenseUsage` | +| `server/internal/api/licence.go` | monitors in the usage response | +| `server/internal/api/monitors.go` | enforce the monitor cap on create | +| `server/internal/auth/oidc.go` | gate the callback | +| `server/cmd/main.go` | start the audit sweeper | +| `web/app/(app)/settings/license/page.tsx` | show monitors, audit retention and support level | +| `adminsite/lib/api.ts` | the re-shaped `Plan`, `CatalogueRow`, `Entitlement`, and their calls | +| `adminsite/app/(staff)/staff/plans/page.tsx` | six plans, allowances and support level; prices move out | +| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the entitlement configurator | +| `adminsite/components/AppBar.tsx` | a staff nav entry for Catalogue | +| `CLAUDE.md` | the collection list, the admin route table, the licensing summary | +| `docs/superpowers/specs/README.md` | spec 7 status | +| `docs/superpowers/plans/2026-07-26-paddle-billing.md` | the preamble, narrowed to what remains | --- ### Task 1: The licence payload, six plans, and the legacy tier **Files:** + - Modify: `shared/license/license.go`, `shared/license/plans.go`, `shared/cmd/lkctl/main.go` **Interfaces:** + - Consumes: nothing from other tasks. - Produces: - - `license.Limits.MaxMonitors int`, `license.Limits.AuditRetentionDays int` - - `func (l Limits) FillUnset(base Limits) Limits` - - `license.License.SupportLevel string` - - `license.TierEnterprise` - - `license.SupportCommunity`, `SupportEmail24x5`, `SupportEmailCall24x7` - - `func PlanFor(deployment, tier string) (Plan, bool)` - - `func NormaliseTier(deployment, tier string) (string, string)` - - `Plan.SupportLevel string` + - `license.Limits.MaxMonitors int`, `license.Limits.AuditRetentionDays int` + - `func (l Limits) FillUnset(base Limits) Limits` + - `license.License.SupportLevel string` + - `license.TierEnterprise` + - `license.SupportCommunity`, `SupportEmail24x5`, `SupportEmailCall24x7` + - `func PlanFor(deployment, tier string) (Plan, bool)` + - `func NormaliseTier(deployment, tier string) (string, string)` + - `Plan.SupportLevel string` - [ ] **Step 1: Add the tier, the support levels, and the two limit fields** @@ -401,16 +403,19 @@ Finally, carry the support level into the payload. Find where the `license.Licen ```bash GOWORK=off /tmp/gorun.sh shared go build ./... ``` + Expected: no output. ```bash GOWORK=off /tmp/gorun.sh server go build ./... ``` + Expected: no output. The server never calls `PlanFor` yet. ```bash GOWORK=off /tmp/gorun.sh admin go build ./... ``` + Expected: **failure**, `not enough arguments in call to license.PlanFor` in `internal/models/plans.go`. Confirm the error names only that one file; anything else means a caller was missed. Confirm the legacy mapping is reachable and the table is complete: @@ -418,11 +423,13 @@ Confirm the legacy mapping is reachable and the table is complete: ```bash GOWORK=off /tmp/gorun.sh shared go vet ./... ``` + Expected: no output. ```bash grep -c "planKey{" shared/license/plans.go ``` + Expected: `7` — one in the type-keyed map literal per plan (6) plus the lookup in `PlanFor`. - [ ] **Step 5: Commit** @@ -452,25 +459,27 @@ Co-Authored-By: Claude Opus 5 " ### Task 2: Plans re-keyed, and the catalogue and entitlement documents **Files:** + - Create: `admin/internal/models/catalogue.go`, `admin/internal/models/entitlements.go` - Modify: `admin/internal/models/models.go`, `admin/internal/models/plans.go`, `admin/internal/models/backfill.go`, `admin/internal/db/db.go`, `admin/cmd/main.go` **Interfaces:** + - Consumes: `license.PlanFor(deployment, tier)`, `license.Tiers()`, `license.Deployments()`, `license.NormaliseTier`, `license.Limits.FillUnset`. - Produces: - - `models.Plan` with `Deployment`, `Tier`, `BaseLimits`, `BaseFeatures`, `SupportLevel`, `Active` - - `models.GetPlan(ctx, deployment, tier) (*Plan, error)` - - `models.SeedPlans(ctx) error` — six rows - - `models.KindBase`, `KindLimit`, `KindFeature` - - `models.LimitKeyServers` - - `models.CatalogueRow` with `PriceID(env, term string) string` - - `models.SeedCatalogue(ctx) error` - - `models.CatalogueFor(ctx, deployment, tier) ([]CatalogueRow, error)` - - `models.Config` with `Servers int`, `Features Features` - - `models.Entitlement` - - `models.GetEntitlement(ctx, instanceID) (*Entitlement, error)` - - `models.ErrNoEntitlement` - - `models.ReasonEntitlementChange` + - `models.Plan` with `Deployment`, `Tier`, `BaseLimits`, `BaseFeatures`, `SupportLevel`, `Active` + - `models.GetPlan(ctx, deployment, tier) (*Plan, error)` + - `models.SeedPlans(ctx) error` — six rows + - `models.KindBase`, `KindLimit`, `KindFeature` + - `models.LimitKeyServers` + - `models.CatalogueRow` with `PriceID(env, term string) string` + - `models.SeedCatalogue(ctx) error` + - `models.CatalogueFor(ctx, deployment, tier) ([]CatalogueRow, error)` + - `models.Config` with `Servers int`, `Features Features` + - `models.Entitlement` + - `models.GetEntitlement(ctx, instanceID) (*Entitlement, error)` + - `models.ErrNoEntitlement` + - `models.ReasonEntitlementChange` - [ ] **Step 1: Re-shape the Plan struct and add the new reason** @@ -576,8 +585,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" ) @@ -725,8 +734,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" @@ -897,7 +906,7 @@ Add `"log"` to that file's imports. - [ ] **Step 6: Extend the backfill** -In `admin/internal/models/backfill.go`, append three passes to `Backfill` before its final `return nil`, and add `"github.com/mrhid6/vantage/shared/license"` if not already imported (it is): +In `admin/internal/models/backfill.go`, append three passes to `Backfill` before its final `return nil`, and add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"` if not already imported (it is): ```go // Pass 3: plans were keyed on tier alone. Give the two cloud tiers their @@ -1069,11 +1078,13 @@ In `admin/cmd/main.go`, find the existing `models.SeedPlans` call and add the ca ```bash GOWORK=off /tmp/gorun.sh admin go build ./... ``` + Expected: no output. Task 1's deliberate failure is now resolved. ```bash GOWORK=off /tmp/gorun.sh admin go vet ./... ``` + Expected: no output. Confirm nothing still reads the deleted Paddle fields on `Plan`: @@ -1081,6 +1092,7 @@ Confirm nothing still reads the deleted Paddle fields on `Plan`: ```bash grep -rn "PaddlePriceIDs\|PaddleProductID" admin/ adminsite/ ``` + Expected: hits **only** in `admin/internal/api/staff.go` (task 6 removes them) and `adminsite/` (task 7). Anything in `models/` means the struct edit was incomplete. - [ ] **Step 9: Verify the migration against a copy of live data** @@ -1097,30 +1109,35 @@ Run admin against the scratch database (the compose/scratch procedure from plan mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.plans.find({},{deployment:1,tier:1,support_level:1,"base_limits.max_servers":1,_id:0}).toArray()' ``` + Expected: **exactly six** rows; `(cloud,free)`, `(cloud,professional)`, `(cloud,enterprise)`, `(self_hosted,free)`, `(self_hosted,professional)`, `(self_hosted,enterprise)`; every one with a non-empty `support_level`; **no row with tier `self_hosted`**; `base_limits.max_servers` of 3, 3, 10, 3, 3, 10. ```bash mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.plans.find({limits:{$exists:true}}).count()' ``` + Expected: `0` — the rename left nothing behind. ```bash mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.catalogue.countDocuments({})' ``` + Expected: `16`. ```bash mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.catalogue.countDocuments({tier:"free"})' ``` + Expected: `0` — Free is priced by nothing, on purpose. ```bash mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.entitlements.find({},{instance_id:1,tier:1,"granted.servers":1,"granted.features":1,_id:0}).toArray()' ``` + Expected: one row per non-deleted instance that has a tier. **Read the `granted.servers` values.** Every pre-metering Professional or self-hosted instance will show the plan base (3), because its licence said Unlimited and an unlimited licence bought no units. **This is the sharp edge, and it needs a human decision before production.** Those instances are allowed fewer servers than they run the moment they are reissued. Existing licences are not reissued by this plan, so nothing breaks today, but the first renewal would cap them. Before deploying to production, list them: @@ -1138,6 +1155,7 @@ Confirm the index swap took: mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.plans.getIndexes().map(i => i.name)' ``` + Expected: includes `deployment_tier_unique`, excludes `tier_unique`. Restart admin once more and confirm idempotency: @@ -1146,6 +1164,7 @@ Restart admin once more and confirm idempotency: mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'print(db.plans.countDocuments({}), db.catalogue.countDocuments({}), db.entitlements.countDocuments({}))' ``` + Expected: identical numbers to the first run. - [ ] **Step 10: Commit** @@ -1181,17 +1200,19 @@ Co-Authored-By: Claude Opus 5 " ### Task 3: The catalogue package — both folds **Files:** + - Create: `admin/internal/catalogue/catalogue.go`, `admin/internal/catalogue/items.go` **Interfaces:** + - Consumes: `models.CatalogueFor`, `models.CatalogueRow`, `models.Plan`, `models.Config`, `models.KindBase/KindLimit/KindFeature`, `license.Limits`, `license.TermsFor`. - Produces: - - `catalogue.Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error)` - - `catalogue.Item` with `PriceID string`, `Quantity int` - - `catalogue.LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error)` - - `catalogue.Match` with `Deployment`, `Tier`, `Term string`, `Servers int`, `Features []string` - - `catalogue.ResolveItems(ctx context.Context, env string, items []Item) (Match, error)` - - `catalogue.ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced` + - `catalogue.Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error)` + - `catalogue.Item` with `PriceID string`, `Quantity int` + - `catalogue.LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error)` + - `catalogue.Match` with `Deployment`, `Tier`, `Term string`, `Servers int`, `Features []string` + - `catalogue.ResolveItems(ctx context.Context, env string, items []Item) (Match, error)` + - `catalogue.ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced` - [ ] **Step 1: Write the fold to a licence** @@ -1213,8 +1234,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 ( @@ -1331,8 +1352,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. @@ -1538,11 +1559,13 @@ func sells(deployment, term string) bool { ```bash GOWORK=off /tmp/gorun.sh admin go build ./... ``` + Expected: no output. ```bash GOWORK=off /tmp/gorun.sh admin go vet ./... ``` + Expected: no output. The base allowance must be included in exactly one of the two folds. Confirm the subtraction exists once: @@ -1550,11 +1573,13 @@ The base allowance must be included in exactly one of the two folds. Confirm the ```bash grep -rn "BaseLimits.MaxServers" admin/internal/catalogue/ ``` + Expected: four hits — `addLimit`'s Unlimited guard and comparison in `catalogue.go`, `billable`'s guard and subtraction in `items.go`, plus the two reconstruction lines in `ResolveItems`. There must be **no** subtraction anywhere outside `billable`. ```bash grep -rn "Servers - \|Servers-" admin/ --include=*.go ``` + Expected: exactly one hit, in `billable`. - [ ] **Step 4: Commit** @@ -1585,13 +1610,15 @@ Co-Authored-By: Claude Opus 5 " ### Task 4: Issue from the entitlement **Files:** + - Modify: `admin/internal/licensing/issue.go`, `admin/internal/api/customer.go` **Interfaces:** + - Consumes: `models.GetEntitlement`, `models.ErrNoEntitlement`, `models.GetPlan(ctx, deployment, tier)`, `catalogue.Resolve`, `license.NormaliseTier`. - Produces: - - `licensing.Issue` snapshotting the entitlement - - `licensing.ErrFreeLimit` scoped per deployment + - `licensing.Issue` snapshotting the entitlement + - `licensing.ErrFreeLimit` scoped per deployment - [ ] **Step 1: Resolve the plan by deployment and issue from the entitlement** @@ -1661,7 +1688,7 @@ and in the `models.License` literal: Features: models.Features(features).OrEmpty(), ``` -Add `"log"` and `"github.com/mrhid6/vantage/admin/internal/catalogue"` to the imports. +Add `"log"` and `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"` to the imports. - [ ] **Step 2: Scope the Free limit per deployment** @@ -1745,6 +1772,7 @@ And its message: ```bash GOWORK=off /tmp/gorun.sh admin go build ./... ``` + Expected: no output. Confirm nothing reads the plan's limits into a licence any more: @@ -1752,6 +1780,7 @@ Confirm nothing reads the plan's limits into a licence any more: ```bash grep -n "plan.Limits\|plan.Features" admin/internal/licensing/issue.go ``` + Expected: no output. Both were replaced; `plan.BaseLimits` and `plan.BaseFeatures` appear only in the fallback assignment. Confirm `Desired` is never read by the issuer: @@ -1759,6 +1788,7 @@ Confirm `Desired` is never read by the issuer: ```bash grep -rn "\.Desired" admin/internal/licensing/ ``` + Expected: no output. Issuing from `Desired` would grant something nobody paid for, and this grep is the guard. Against a scratch database with admin running, set an entitlement by hand and issue: @@ -1781,6 +1811,7 @@ mongosh "$SCRATCH_ADMIN_URI" --quiet --eval ' db.licenses.find({instance_id:""},{"limits.max_servers":1,features:1,tier:1,_id:0}) .sort({issued_at:-1}).limit(1).toArray()' ``` + Expected: `limits.max_servers: 9`, `features: ["console"]`. If it reports `3`, the fallback is being taken — check the entitlement's `instance_id` matches. Now confirm the Free rule per deployment. With an account holding a cloud Free instance, issue a Free licence against a self-hosted instance of the same account: @@ -1818,15 +1849,17 @@ Co-Authored-By: Claude Opus 5 " ### Task 5: The control plane enforces every column **Files:** + - Create: `server/internal/services/audit_retention.go` - Modify: `server/internal/services/licence.go`, `server/internal/services/licence_limits.go`, `server/internal/api/licence.go`, `server/internal/api/monitors.go`, `server/internal/auth/oidc.go`, `server/cmd/main.go` **Interfaces:** + - Consumes: `license.PlanFor`, `license.NormaliseTier`, `license.Limits.FillUnset`, `services.GetLicenseState`, `services.DeploymentMode`. - Produces: - - `services.CheckMonitorLimit(instanceID string) error` - - `services.LicenseUsage` returning a fourth count - - `services.StartAuditSweeper()` + - `services.CheckMonitorLimit(instanceID string) error` + - `services.LicenseUsage` returning a fourth count + - `services.StartAuditSweeper()` - [ ] **Step 1: Fill unset limits at the single decode site** @@ -2000,8 +2033,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" ) @@ -2078,6 +2111,7 @@ func sweepAuditLogs() { ```bash grep -n "bson:\"" server/internal/models/*.go | grep -i audit ``` + If the field is named differently, use that name — a filter on a field that does not exist deletes **every** row, which is why this check is a step rather than an assumption. - [ ] **Step 6: Start the sweeper** @@ -2093,11 +2127,13 @@ In `server/cmd/main.go`, beside the existing `services.StartLogSweeper()` call: ```bash GOWORK=off /tmp/gorun.sh server go build ./... ``` + Expected: no output. If `LicenseUsage` callers were missed the compiler names them — it gained a return value specifically so they cannot be missed silently. ```bash GOWORK=off /tmp/gorun.sh server go vet ./... ``` + Expected: no output. Against a running server with a Free licence (3 monitors), create four monitors: @@ -2109,6 +2145,7 @@ for i in 1 2 3 4; do -d "{\"name\":\"m$i\",\"type\":\"http\",\"target\":\"https://example.com\"}" done ``` + Expected: `201 201 201 403`. ```bash @@ -2116,6 +2153,7 @@ curl -s -b /tmp/cj -X POST localhost:8080/api/monitors \ -H 'Content-Type: application/json' \ -d '{"name":"m5","type":"http","target":"https://example.com"}' | python -m json.tool ``` + Expected: `{"error": "limit_exceeded", "limit": "max_monitors", "current": 3, "max": 3}`. Confirm the usage report: @@ -2123,6 +2161,7 @@ Confirm the usage report: ```bash curl -s -b /tmp/cj localhost:8080/api/license | python -m json.tool ``` + Expected: `usage.monitors: 3`, `limits.max_monitors: 3`, `limits.audit_retention_days: 30`, and a `support_level` of `community`. Confirm the fill works on an old blob. Take a licence signed before this change (any existing one) and store it: @@ -2133,6 +2172,7 @@ Confirm the OIDC callback refuses. On an instance whose licence lacks `oidc`: ```bash curl -si "localhost:8080/auth/oidc/callback?state=anything&code=anything" | head -3 ``` + Expected: `400` "invalid state" — the state check runs first, which is correct. To exercise the gate itself, start a real SSO flow on an instance that HAS the feature, then remove the feature from its licence before completing the callback: Expected: `302` to `/login?error=oidc_unavailable`, and **no session cookie set**. @@ -2143,12 +2183,14 @@ mongosh "$SCRATCH_CONTROL_URI" --quiet --eval ' db.audit_logs.insertOne({instance_id:"", action:"test.old", created_at: new Date(Date.now() - 1000*60*60*24*400)})' ``` + With a Free licence (30 days), restart the server and check the log line, then: ```bash mongosh "$SCRATCH_CONTROL_URI" --quiet --eval \ 'db.audit_logs.countDocuments({instance_id:"", action:"test.old"})' ``` + Expected: `0`, and a log line naming the count. Now the conservatism. Let the licence expire (or store an expired one), insert another old row, restart: @@ -2186,18 +2228,20 @@ Co-Authored-By: Claude Opus 5 " ### Task 6: Admin endpoints for plans, the catalogue and entitlements **Files:** + - Create: `admin/internal/api/entitlement.go` - Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go` **Interfaces:** + - Consumes: `models.AllCatalogue`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `catalogue.Resolve`, `ownedInstance`, `auth.Current`, `audit.Write`. - Produces: - - `PUT /api/staff/plans/:deployment/:tier` - - `GET /api/staff/catalogue` - - `PUT /api/staff/catalogue` - - `GET /api/staff/instances/:id/entitlement` - - `PUT /api/staff/instances/:id/entitlement` - - `GET /api/instances/:id/entitlement` + - `PUT /api/staff/plans/:deployment/:tier` + - `GET /api/staff/catalogue` + - `PUT /api/staff/catalogue` + - `GET /api/staff/instances/:id/entitlement` + - `PUT /api/staff/instances/:id/entitlement` + - `GET /api/instances/:id/entitlement` - [ ] **Step 1: Re-key the plan update and drop the Paddle fields** @@ -2351,10 +2395,10 @@ import ( "time" "github.com/gin-gonic/gin" - "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" + "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" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -2560,18 +2604,20 @@ And in the customer group, beside the other instance reads: cust.GET("/instances/:id/entitlement", getEntitlement) ``` -**Reading is open to any signed-in member**, matching the other instance reads. There is no customer *write* here at all — that is plan 5's `PUT /api/instances/:id/entitlement`, which needs a Paddle call to mean anything. +**Reading is open to any signed-in member**, matching the other instance reads. There is no customer _write_ here at all — that is plan 5's `PUT /api/instances/:id/entitlement`, which needs a Paddle call to mean anything. - [ ] **Step 5: Confirm the endpoints** ```bash GOWORK=off /tmp/gorun.sh admin go build ./... ``` + Expected: no output. ```bash GOWORK=off /tmp/gorun.sh admin go vet ./... ``` + Expected: no output. With admin running against scratch databases and a staff session in `/tmp/staffcj`: @@ -2579,6 +2625,7 @@ With admin running against scratch databases and a staff session in `/tmp/staffc ```bash curl -s -b /tmp/staffcj localhost:8083/api/staff/catalogue | python -m json.tool | head -30 ``` + Expected: sixteen rows, every `price_ids` an empty object. Paste a sandbox price onto the cloud Professional base: @@ -2589,6 +2636,7 @@ curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \ "kind":"base","deployment":"cloud","tier":"professional", "price_ids":{"sandbox":{"monthly":"pri_test_base_m"}}}' ``` + Expected: `{"updated":true}`. Now the refusal that matters — a self-hosted monthly price: @@ -2599,12 +2647,14 @@ curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \ "kind":"base","deployment":"self_hosted","tier":"professional", "price_ids":{"sandbox":{"monthly":"pri_nope"}}}' ``` + Expected: `400`, "self_hosted does not sell monthly". Confirm nothing was written: ```bash mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.catalogue.findOne({deployment:"self_hosted",tier:"professional",kind:"base"}).price_ids' ``` + Expected: `{}` or no `monthly` key. Set an entitlement: @@ -2615,6 +2665,7 @@ curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitleme -d '{"tier":"professional","term":"monthly","servers":9,"features":["console"],"grant":true}' \ | python -m json.tool ``` + Expected: `resolved_limits.max_servers: 9`, `granted.servers: 9`, `pending: false`. Now a reduction without granting it: @@ -2625,6 +2676,7 @@ curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitleme -d '{"tier":"professional","term":"monthly","servers":5,"features":["console"]}' \ | python -m json.tool ``` + Expected: `desired.servers: 5`, **`granted.servers: 9`**, `pending: true`, `scheduled_change_at` set, and `resolved_limits.max_servers: 9`. A reduction must not lower what is granted. Reissue and confirm the licence still says 9: @@ -2635,6 +2687,7 @@ curl -s -b /tmp/staffcj -X POST localhost:8083/api/staff/instances//issue \ mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \ 'db.licenses.find({instance_id:""},{"limits.max_servers":1,_id:0}).sort({issued_at:-1}).limit(1).toArray()' ``` + Expected: `9`. **This is the single most important check in the task** — it proves a pending reduction cannot shorten what a customer currently has. Confirm a below-base configuration is refused: @@ -2643,6 +2696,7 @@ Confirm a below-base configuration is refused: curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances//entitlement \ -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly","servers":1}' ``` + Expected: `400`, "Professional includes 3 servers; cannot configure fewer". Confirm the customer read is scoped. As a customer of another account: @@ -2651,6 +2705,7 @@ Confirm the customer read is scoped. As a customer of another account: curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/othercj \ localhost:8083/api/instances//entitlement ``` + Expected: `404`, never `403`. - [ ] **Step 6: Commit** @@ -2683,15 +2738,17 @@ Co-Authored-By: Claude Opus 5 " ### Task 7: The staff plans and catalogue screens **Files:** + - Create: `adminsite/app/(staff)/staff/catalogue/page.tsx` - Modify: `adminsite/lib/api.ts`, `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/components/AppBar.tsx` **Interfaces:** + - Consumes: `GET/PUT /api/staff/plans/:deployment/:tier`, `GET/PUT /api/staff/catalogue`. - Produces: - - types `Limits` (five fields), `Plan`, `CatalogueRow`, `Term`, `Deployment` - - `api.staff.plans()`, `api.staff.updatePlan(deployment, tier, plan)` - - `api.staff.catalogue()`, `api.staff.updateCatalogue(row)` + - types `Limits` (five fields), `Plan`, `CatalogueRow`, `Term`, `Deployment` + - `api.staff.plans()`, `api.staff.updatePlan(deployment, tier, plan)` + - `api.staff.catalogue()`, `api.staff.updateCatalogue(row)` - [ ] **Step 1: Update the types and calls** @@ -2809,15 +2866,7 @@ const LIMIT_FIELDS = [ * sentinel is a staff screen where nobody can tell whether a plan says * unlimited or nothing at all. */ -function AllowanceForm({ - plan, - onSave, - saving, -}: { - plan: Plan; - onSave: (next: Plan) => void; - saving: boolean; -}) { +function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) { const [draft, setDraft] = useState(plan); const dirty = JSON.stringify(draft) !== JSON.stringify(plan); @@ -2826,9 +2875,7 @@ function AllowanceForm({
{LIMIT_FIELDS.map((f) => ( ))} -

- Changes apply to licences issued from now on. Existing licences - snapshotted their plan and are unaffected. -

+

Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.

-
@@ -2898,33 +2927,26 @@ function AllowanceForm({ Group the list by deployment, so six rows read as two families of three rather than a flat list of six: ```tsx -{(["cloud", "self_hosted"] as const).map((deployment) => ( -
-

- {deployment === "cloud" ? "Cloud" : "Self-Hosted"} -

- {plans - .filter((p) => p.deployment === deployment) - .map((p) => ( -
-
-

{p.name}

- - {p.deployment}/{p.tier} - -
- save(p.deployment, p.tier, next)} - /> -
- ))} -
-))} +{ + (["cloud", "self_hosted"] as const).map((deployment) => ( +
+

{deployment === "cloud" ? "Cloud" : "Self-Hosted"}

+ {plans + .filter((p) => p.deployment === deployment) + .map((p) => ( +
+
+

{p.name}

+ + {p.deployment}/{p.tier} + +
+ save(p.deployment, p.tier, next)} /> +
+ ))} +
+ )); +} ``` Wire `save` through TanStack Query's mutation following the file's existing pattern, invalidating the plans query on success. @@ -2978,28 +3000,12 @@ export default function CataloguePage() { return ( - } + header={} rail={ } > @@ -3013,8 +3019,7 @@ export default function CataloguePage() { return (

- {deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "} - {tier} + {deployment === "cloud" ? "Cloud" : "Self-Hosted"} {tier}

- {u.verified_at ? "Active" : "Invitation pending"} - {u.verified_at ? "Active" : "Invitation pending"} {canManage && !isSelf && ( @@ -2115,10 +2110,7 @@ export default function UsersPage() {

People

-

- Everyone on this account. Owners and admins can invite people and grant them - access to instances; billing stays with owners. -

+

Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners.

@@ -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 (

Who can sign in

-

- Each person here has a real user inside this instance and signs in with their - Vantage HQ password. -

+

Each person here has a real user inside this instance and signs in with their Vantage HQ password.

{error &&

{error}

}
    {(members.data ?? []).map((m) => ( -
  • +
  • {m.email} {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 }) {
  • ))} - {members.data?.length === 0 && ( -
  • Nobody has been added yet.
  • - )} + {members.data?.length === 0 &&
  • Nobody has been added yet.
  • }
{canManage && ( @@ -2262,14 +2241,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) { }} >
@@ -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 `` of the returned tree: ```tsx - {instance.deployment === "cloud" ? ( - - ) : ( -

- Users for this install are managed inside it, in Settings → Instance. We do not - have access to your own deployment. -

- )} +{ + instance.deployment === "cloud" ? ( + + ) : ( +

Users for this install are managed inside it, in Settings → Instance. We do not have access to your own deployment.

+ ); +} ``` - [ ] **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 (

Settings

-

- Your password signs you in here and into every Vantage instance you belong to. - Changing it changes all of them. -

+

Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them.

- setCurrent(e.target.value)} - required - /> + setCurrent(e.target.value)} required /> 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

That link is missing its token.

; @@ -2458,10 +2407,7 @@ function AcceptForm() { }} >

Choose a password

-

- This password signs you into Vantage HQ and into every instance you are given - access to. Nobody who invited you can see it. -

+

This password signs you into Vantage HQ and into every instance you are given access to. Nobody who invited you can see it.

- You sign in to it with this same email address and password. Changing your Vantage - HQ password changes it here too. -

+

You sign in to it with this same email address and password. Changing your Vantage HQ password changes it here too.

``` - [ ] **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 " ### 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 -
- - {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} - - + {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} + - {managedByHQ ? ( - hqUrl ? ( - - Managed in Vantage HQ - - ) : ( - Managed in Vantage HQ - ) - ) : ( - !locked && ( - - ) - )} - + {managedByHQ ? ( + hqUrl ? ( + + Managed in Vantage HQ + + ) : ( + Managed in Vantage HQ + ) + ) : ( + !locked && ( + + ) + )} +
@@ -3023,10 +3028,7 @@ export default function CataloguePage() { {ENVS.map((env) => terms.map((t) => ( - )), @@ -3036,35 +3038,19 @@ export default function CataloguePage() { {rows - .filter( - (r) => - r.deployment === deployment && - r.tier === tier, - ) + .filter((r) => r.deployment === deployment && r.tier === tier) .map((r) => { const k = rowKey(r); const ids = drafts[k] ?? r.price_ids ?? {}; - const dirty = - JSON.stringify(ids) !== - JSON.stringify(r.price_ids ?? {}); + const dirty = JSON.stringify(ids) !== JSON.stringify(r.price_ids ?? {}); return ( - - + + {ENVS.map((env) => terms.map((t) => ( -
Component + {env} / {t}
- {componentLabel(r)} -
{componentLabel(r)} + setDrafts({ @@ -3072,12 +3058,8 @@ export default function CataloguePage() { [k]: { ...ids, [env]: { - ...(ids[ - env - ] ?? {}), - [t]: e - .target - .value, + ...(ids[env] ?? {}), + [t]: e.target.value, }, }, }) @@ -3090,9 +3072,7 @@ export default function CataloguePage() { @@ -3290,21 +3264,13 @@ export default function PlanConfigurator({ type="button" disabled={disabled} onClick={() => onChange({ ...value, term: t })} - className={`rounded border px-3 py-1.5 text-[0.85rem] ${ - t === value.term - ? "border-accent text-accent" - : "border-rule text-ink-2" - }`} + className={`rounded border px-3 py-1.5 text-[0.85rem] ${t === value.term ? "border-accent text-accent" : "border-rule text-ink-2"}`} > {t === "monthly" ? "Monthly" : "Annual"} ))} - {deployment === "self_hosted" && ( -

- Self-hosted is annual only. -

- )} + {deployment === "self_hosted" &&

Self-hosted is annual only.

}