Compare commits

...
14 Commits
Author SHA1 Message Date
mrhid6andClaude Opus 5 946a748038 chore: declare go 1.26 rather than 1.26.4 across the workspace
Server Deploy / deploy (push) Successful in 5m58s
The 1.26.4 floor meant a base toolchain of 1.26.2 could not load the
workspace at all: the terminal worked only because GOTOOLCHAIN=auto
silently swapped in a downloaded 1.26.4, and gopls -- which does not get
that switch -- failed every packages.Load with

  go: go.work requires go >= 1.26.4 (running go 1.26.2)

Nothing needed the patch-level floor. agent/go.mod already declared plain
go 1.26, so this makes the workspace uniform rather than introducing a new
convention, and CI is unaffected because golang:1.26 is already newer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:33:58 +01:00
mrhid6andClaude Opus 5 481649e03f chore: update go.work.sum for the admin module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:21:53 +01:00
mrhid6andClaude Opus 5 cfdc00552e docs: mark the admin backend shipped and verified
Ticks all 66 plan steps and records spec 3 as shipped.

Also corrects a stale line that still claimed existing cloud tenants are
grandfathered by migration 0005. That migration was reverted before plan 2
shipped; those instances are read-only until licensed by hand, and the same
file already said so one table above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:21:45 +01:00
mrhid6andClaude Opus 5 b7221d8111 fix(admin): reconcile once at boot, not only on the ticker
StartReconciler only fired on its 15-minute ticker, so nothing reconciled
until a full interval had passed and restarting admin repaired nothing.

Injection failures are most likely around a deploy or a crash, which is
exactly when the backstop was asleep -- a paying customer could sit
read-only for 15 minutes with the repair already computable. A restart is
now also a supported way to force reconciliation.

Found by the plan's own Step 8, which assumed this behaviour: verified by
tampering with a control-plane blob, confirming the instance went invalid,
and watching the boot pass restore it (checked 1, repaired 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:21:16 +01:00
mrhid6andClaude Opus 5 7e6d7074d6 feat(admin): compose service and image build
Adds admin to docker-compose.site.yml and a fourth Go image to the build
workflow, both following the conventions the other services already use.

LICENSE_SIGNING_KEY now appears in exactly one service in exactly one
compose file. It must never be added to server, and the self-hosted
docker-compose.yml still does not mention admin at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:11:52 +01:00
mrhid6andClaude Opus 5 268134d821 feat(admin): staff API and the route table
Completes the service -- this is the first commit where the whole thing
compiles and serves.

staffCreateInstance adopts a cloud instance that already exists in the
control plane, taking its name and slug from there and refusing when no
such instance exists: an admin row pointing at nothing would issue licences
nobody can use. Adopt then issue is how the existing read-only cloud
instances get licensed.

staffRelink has no attempt cap. The customer-facing limit exists to put a
human in front of the fourth attempt, and this is that human.

Editing a plan changes what a tier grants from now on only; existing
licences snapshotted theirs at issue time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:11:04 +01:00
mrhid6andClaude Opus 5 c829cc41d9 feat(admin): linking, relink and the scoped customer API
Every customer handler that names an instance resolves it through
ownedInstance, which returns 404 rather than 403 for another account's
instance -- a 403 confirms the instance exists, which is an existence
oracle over customer data.

The unique index on admin_instances.instance_id, not the pre-check, is what
actually prevents two accounts owning one instance. Relink issues a
replacement covering the REMAINING term, so it cannot be used to extend a
subscription, and the old licence is not revoked because offline
verification has no revocation -- its instance binding is what stops it.

The route table lands with the staff handlers in the next commit so every
commit builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:09:13 +01:00
mrhid6andClaude Opus 5 07a3756b18 feat(admin): self-hosted customer accounts with email verification
Mirrors the pattern sitesvc already proves: 32 random bytes, only the
SHA-256 hash stored, a 24-hour expiry, and the token cleared on use -- so a
leaked database yields no working links.

Unverified login returns a distinct "verify your email address first" rather
than the generic error. The address is already known to be theirs, so there
is nothing to disclose and that is the only useful thing to say.

Licence blobs are emailed inline. A blob is signed public data, not a
secret: it is useless on any instance other than the one it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:07:47 +01:00
mrhid6andClaude Opus 5 769839a70d feat(admin): cloud owner login against the control plane
Cloud customers sign in with the control-plane credentials they already
hold, so there is no second password to manage. Two accepted consequences,
documented at the handler: their control-plane password now also unlocks
billing, and only role owner may sign in -- admin and member are refused
because billing is an owner concern.

A self-hosted customer_users row wins over a control-plane user with the
same address, so the precedence is chosen rather than emergent.

Adds the reads of control users this needs; the write surface is still one
UpdateOne on instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:06:23 +01:00
mrhid6andClaude Opus 5 480a578deb feat(admin): sessions, staff auth and adminctl
One Redis session store and one cookie for all three identities. Staff
login returns the same error for every failure mode and spends a bcrypt
comparison against a dummy hash when no user exists, so neither the message
nor the timing confirms which addresses have accounts.

Staff users are created only by adminctl. There is no signup endpoint: a
licensing authority that can be joined over the internet is not one.

Pins gin and go-redis to the versions server/ already uses rather than the
latest tidy would pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:05:28 +01:00
mrhid6andClaude Opus 5 a636a48e07 feat(admin): cloud injection and the 15-minute reconciler
Injection is a single UpdateOne of three licence fields, so it is
idempotent and safe to re-run. The control plane caches licence state for
60 seconds, so an injected licence takes effect within a minute with no
restart.

Deliver never fails its caller. The reconciler, not the issuance path, is
what actually guarantees a cloud instance ends up holding the licence admin
says it holds -- injection at issue time is best-effort and this is the
backstop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:02:45 +01:00
mrhid6andClaude Opus 5 427191b14c feat(admin): licence issuance with plan snapshots and supersession
Issue is the only place that signs. It records the licence, supersedes its
predecessor and updates the instance -- but deliberately does not deliver.

The ordering matters: a licence recorded but not delivered is recoverable,
because the customer can download it. A licence delivered but not recorded
is a support mystery with no paper trail.

Free stays cloud-only through one comparison of plan against instance
deployment, not a flag. Renewals reset relink_count because the cap is per
term.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:01:47 +01:00
mrhid6andClaude Opus 5 b8fcf89ee7 feat(admin): documents, indexes and the plan seed
Adds admin's own documents, its unique indexes and the plan seed from
shared/license.

Licences are append-only -- a renewal writes a new row and supersedes the
old one -- because the history is the support tool. Plans are seeded with
$setOnInsert only, so a redeploy never stamps over staff edits to limits,
features or Paddle IDs.

admin_instances.instance_id unique is a correctness property, not an
optimisation: without it two customers could both claim one self-hosted
UUID and both be issued licences for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:00:37 +01:00
mrhid6andClaude Opus 5 64eac6dbc7 feat(admin): module skeleton, config and two database connections
Adds the admin module: env config with fail-fast validation, two MongoDB
connections (its own vantage_admin database plus a narrow path into the
control plane), the boot sequence and the image.

Config refuses to start without a signing key, and both Mongo URIs must
name their database inline -- admin talks to two databases, so a bare
MONGO_DB would be ambiguous about which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 18:58:44 +01:00
33 changed files with 2639 additions and 76 deletions
+7
View File
@@ -53,3 +53,10 @@ jobs:
# Root context: sitesvc depends on the shared module.
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
docker push "$IMAGE"
- name: Build and push admin image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
# Root context: admin depends on the shared module.
docker build -t "$IMAGE" -f admin/Dockerfile .
docker push "$IMAGE"
+2
View File
@@ -0,0 +1,2 @@
.env
*.lic
+30
View File
@@ -0,0 +1,30 @@
# Context is the repository root; admin depends on the shared module.
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY admin/go.mod admin/go.sum ./admin/
RUN cd admin && go mod download
COPY shared/ ./shared/
COPY admin/ ./admin/
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
FROM alpine:3.20 AS runner
RUN apk add --no-cache ca-certificates && \
addgroup --system --gid 1001 admin && \
adduser --system --uid 1001 --ingroup admin admin
COPY --from=builder /out/admin /usr/local/bin/admin
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
USER admin
EXPOSE 8083
ENV PORT=8083
CMD ["/usr/local/bin/admin"]
+82
View File
@@ -0,0 +1,82 @@
// Command adminctl performs the operations that deliberately have no HTTP
// surface.
//
// adminctl staff-add --email=you@example.com --name="You" --password=...
//
// There is no staff signup endpoint. A licensing authority that can be joined
// over the internet is not one.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"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"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
cfg, err := config.Load()
if err != nil {
fatal("configuration: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.Connect(ctx, cfg); err != nil {
fatal("database: %v", err)
}
switch os.Args[1] {
case "staff-add":
staffAdd(ctx, os.Args[2:])
default:
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
}
func staffAdd(ctx context.Context, args []string) {
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
email := fs.String("email", "", "staff email (required)")
name := fs.String("name", "", "display name")
password := fs.String("password", "", "password, at least 12 characters (required)")
fs.Parse(args)
if *email == "" || len(*password) < 12 {
fatal("--email and a --password of at least 12 characters are required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
fatal("hash: %v", err)
}
u := models.StaffUser{
UserID: uuid.NewString(),
Email: strings.ToLower(strings.TrimSpace(*email)),
PasswordHash: string(hash),
Name: *name,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
fatal("create staff user: %v", err)
}
fmt.Printf("created staff user %s\n", u.Email)
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
"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"
)
func main() {
godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error: %v", err)
}
licensing.SetSigningKey(cfg.SigningKey)
mail.Init(mail.Config{
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
PublicURL: cfg.PublicURL,
})
if !mail.Enabled() {
log.Println("warning: SMTP not configured; verification and licence emails will fail")
}
auth.InitRedis(cfg.RedisAddr)
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := auth.Ping(pingCtx); err != nil {
pingCancel()
log.Fatalf("redis: %v", err)
}
pingCancel()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := db.Connect(ctx, cfg); err != nil {
cancel()
log.Fatalf("database: %v", err)
}
cancel()
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := db.EnsureIndexes(idxCtx); err != nil {
idxCancel()
log.Fatalf("indexes: %v", err)
}
if err := models.SeedPlans(idxCtx); err != nil {
idxCancel()
log.Fatalf("plan seed: %v", err)
}
idxCancel()
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
srv := &http.Server{
Addr: cfg.Addr,
Handler: api.Routes(cfg),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("admin listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-stopCtx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
log.Println("admin stopped")
os.Exit(0)
}
+55
View File
@@ -0,0 +1,55 @@
module github.com/mrhid6/vantage/admin
go 1.26
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
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
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
+149
View File
@@ -0,0 +1,149 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"errors"
"fmt"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
//
// EVERY customer handler that names an instance must go through this. It returns
// 404 for another account's instance rather than 403: a 403 confirms the
// instance exists, which is an existence oracle over customer data.
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
s := auth.Current(c)
if s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return nil, false
}
var inst models.Instance
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return nil, false
}
return &inst, true
}
func getAccount(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(ctx, &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}
func linkInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, inst)
}
func relinkInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
s := auth.Current(c)
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrRelinkLimit) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
deliver(c, inst, lic)
c.JSON(http.StatusOK, lic)
}
func getInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
c.JSON(http.StatusOK, lic)
}
func downloadInstanceLicense(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
var lic models.License
if err := db.Admin("licenses").FindOne(c.Request.Context(),
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
}
func listSubscriptions(c *gin.Context) {
s := auth.Current(c)
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
subs := []models.Subscription{}
if err := cur.All(c.Request.Context(), &subs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subs)
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
// Delivery failures are logged, never returned: the licence is already recorded,
// which is the part that must not be lost.
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
if inst.Deployment == license.DeploymentCloud {
inject.Deliver(c.Request.Context(), lic)
return
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+80
View File
@@ -0,0 +1,80 @@
// Package api mounts admin's HTTP surface.
//
// The route table is the single place scoping is guaranteed. Customer routes
// live behind RequireCustomer and every handler that names an instance calls
// ownedInstance. A new customer route that skips that helper is a scoping bug,
// so keep them together and review them together.
package api
import (
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/config"
)
func Routes(cfg config.Config) http.Handler {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.Use(cors(cfg.AllowedOrigins))
if cfg.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
r.POST("/auth/staff/login", auth.HandleStaffLogin)
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/verify", auth.HandleVerify)
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{
cust.GET("/account", getAccount)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/subscriptions", listSubscriptions)
}
staff := r.Group("/api/staff")
staff.Use(auth.RequireStaff())
{
staff.GET("/accounts", staffListAccounts)
staff.POST("/accounts", staffCreateAccount)
staff.GET("/accounts/:id", staffGetAccount)
staff.GET("/instances", staffListInstances)
staff.POST("/instances", staffCreateInstance)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
staff.PUT("/plans/:tier", staffUpdatePlan)
staff.GET("/audit", staffAudit)
staff.GET("/health/injection", staffInjectionHealth)
}
return r
}
func cors(allowed []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" && slices.Contains(allowed, origin) {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+341
View File
@@ -0,0 +1,341 @@
package api
import (
"net/http"
"time"
"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"
)
func staffListAccounts(c *gin.Context) {
filter := bson.M{}
if q := c.Query("q"); q != "" {
filter["$or"] = []bson.M{
{"name": bson.M{"$regex": q, "$options": "i"}},
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
}
}
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
accounts := []models.Account{}
if err := cur.All(c.Request.Context(), &accounts); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, accounts)
}
func staffCreateAccount(c *gin.Context) {
var body struct {
Name string `json:"name"`
BillingEmail string `json:"billing_email"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
return
}
acct := models.Account{
AccountID: uuid.NewString(),
Name: body.Name,
BillingEmail: body.BillingEmail,
Status: models.AccountActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, acct)
}
func staffGetAccount(c *gin.Context) {
ctx := c.Request.Context()
var acct models.Account
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID})
instances := []models.Instance{}
if cur != nil {
_ = cur.All(ctx, &instances)
}
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
}
func staffListInstances(c *gin.Context) {
filter := bson.M{}
for param, field := range map[string]string{
"account_id": "account_id",
"deployment": "deployment",
"status": "status",
} {
if v := c.Query(param); v != "" {
filter[field] = v
}
}
if c.Query("expiring") == "true" {
// Instances whose licence expires within 14 days, for renewal chasing.
var ids []string
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
"superseded_by": bson.M{"$exists": false},
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
})
if err == nil {
var lics []models.License
if cur.All(c.Request.Context(), &lics) == nil {
for _, l := range lics {
ids = append(ids, l.InstanceID)
}
}
}
filter["instance_id"] = bson.M{"$in": ids}
}
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
options.Find().SetLimit(500))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
instances := []models.Instance{}
if err := cur.All(c.Request.Context(), &instances); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, instances)
}
// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
Deployment string `json:"deployment"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
return
}
ctx := c.Request.Context()
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
inst := models.Instance{
InstanceID: body.InstanceID,
AccountID: body.AccountID,
Name: body.Name,
Deployment: body.Deployment,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if body.Deployment == license.DeploymentCloud {
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
return
}
inst.Name = remote.Name
inst.Slug = remote.Slug
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
c.JSON(http.StatusCreated, inst)
}
func staffIssue(c *gin.Context) {
var body struct {
Tier string `json:"tier"`
Term string `json:"term"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
return
}
if body.Reason == "" {
body.Reason = models.ReasonManual
}
s := auth.Current(c)
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
InstanceID: c.Param("id"),
Tier: body.Tier,
Term: body.Term,
Reason: body.Reason,
IssuedBy: s.Email,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var inst models.Instance
if db.Admin("admin_instances").FindOne(c.Request.Context(),
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
deliver(c, &inst, lic)
}
c.JSON(http.StatusCreated, lic)
}
// staffRelink has no attempt cap. The customer-facing limit exists to put a
// human in front of the fourth attempt; this is that human.
func staffRelink(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
ctx := c.Request.Context()
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lic)
}
func staffListLicenses(c *gin.Context) {
filter := bson.M{}
if v := c.Query("instance_id"); v != "" {
filter["instance_id"] = v
}
if v := c.Query("account_id"); v != "" {
filter["account_id"] = v
}
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
lics := []models.License{}
if err := cur.All(c.Request.Context(), &lics); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, lics)
}
func staffListPlans(c *gin.Context) {
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
plans := []models.Plan{}
if err := cur.All(c.Request.Context(), &plans); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, plans)
}
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
// snapshotted their plan at issue time and are unaffected — the same rule as
// workflow_runs.steps_snapshot.
func staffUpdatePlan(c *gin.Context) {
var body models.Plan
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
return
}
set := bson.M{
"name": body.Name,
"limits": body.Limits,
"features": body.Features,
"paddle_product_id": body.PaddleProductID,
"paddle_price_ids": body.PaddlePriceIDs,
"active": body.Active,
}
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func staffAudit(c *gin.Context) {
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{},
options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
entries := []models.AuditEntry{}
if err := cur.All(c.Request.Context(), &entries); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
// staffInjectionHealth lists instances whose last injection failed. This is the
// page to look at when a customer says their cloud instance is read-only.
func staffInjectionHealth(c *gin.Context) {
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
bson.M{"inject_failed_at": bson.M{"$exists": true}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
failed := []models.Instance{}
if err := cur.All(c.Request.Context(), &failed); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
}
+21
View File
@@ -0,0 +1,21 @@
// Package audit records who did what. Every issuance, link, relink and sign-in
// attempt lands here.
package audit
import (
"context"
"log"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
)
// Write never returns an error: an audit failure must not roll back the action
// it describes. It logs instead, loudly enough to notice.
func Write(ctx context.Context, e models.AuditEntry) {
e.CreatedAt = time.Now().UTC()
if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
}
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"net/http"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's
// users collection, with the credentials they already have.
//
// Two consequences worth stating plainly, because they are real and were
// accepted deliberately:
//
// 1. A cloud user's control-plane password now also unlocks billing. Any
// password change or compromise has a wider blast radius than before.
// 2. Only control-plane role "owner" may sign in here. admin and member are
// refused — billing is an owner concern.
//
// Mitigations: rate limits, an identical error for every failure, and an audit
// entry for every attempt.
func HandleCloudLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
ctx := c.Request.Context()
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
reject := func(reason string) {
audit.Write(ctx, adminmodels.AuditEntry{
Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
}
// A self-hosted customer_users row wins over a control-plane user with the
// same address. Documented so the behaviour is chosen rather than emergent.
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
HandleCustomerLogin(c)
return
}
var u sharedmodels.User
if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
reject("unknown email")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
reject("bad password")
return
}
if u.Role != sharedmodels.RoleOwner {
reject("role " + u.Role + " is not permitted")
return
}
// Resolve the admin-side account that owns this user's instance.
var inst adminmodels.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil {
reject("no account for instance " + u.InstanceID)
return
}
id, err := Save(ctx, Session{
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(ctx, adminmodels.AuditEntry{
Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
+141
View File
@@ -0,0 +1,141 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// BcryptCost matches the control plane and sitesvc. Changing it here alone would
// make hashes inconsistent across services that may one day compare them.
const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
// CreateCustomerUser creates an unverified self-hosted customer login and emails
// the verification link. Called during purchase (spec 5) and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return err
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expiry := time.Now().UTC().Add(VerifyWindow)
u := models.CustomerUser{
UserID: uuid.NewString(),
AccountID: accountID,
Email: strings.ToLower(strings.TrimSpace(email)),
PasswordHash: string(hash),
VerifyTokenHash: hex.EncodeToString(sum[:]),
VerifyTokenExpiry: &expiry,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
return mail.SendVerification(u.Email, token)
}
// HandleVerify consumes a verification token.
func HandleVerify(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
sum := sha256.Sum256([]byte(token))
now := time.Now().UTC()
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
bson.M{
"verify_token_hash": hex.EncodeToString(sum[:]),
"verify_token_expiry": bson.M{"$gt": now},
},
bson.M{
"$set": bson.M{"verified_at": now},
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
})
if err != nil || res.MatchedCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
return
}
c.JSON(http.StatusOK, gin.H{"verified": true})
}
// HandleCustomerLogin authenticates a self-hosted customer.
func HandleCustomerLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
ctx := c.Request.Context()
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
reject := func(reason string) {
audit.Write(ctx, models.AuditEntry{
Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
}
var u models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
reject("unknown email")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
reject("bad password")
return
}
if u.VerifiedAt == nil {
// Distinct from genericAuthError on purpose: the address is already
// known to be theirs, so there is nothing to disclose, and "check your
// email" is the only useful thing to say.
c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
return
}
id, err := Save(ctx, Session{
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(ctx, models.AuditEntry{
Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
+58
View File
@@ -0,0 +1,58 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
)
const ctxSession = "admin_session_obj"
func load(c *gin.Context) *Session {
id, err := c.Cookie(CookieName)
if err != nil || id == "" {
return nil
}
s, err := Get(c.Request.Context(), id)
if err != nil {
return nil
}
return s
}
// Current returns the session, or nil.
func Current(c *gin.Context) *Session {
if v, ok := c.Get(ctxSession); ok {
if s, ok := v.(*Session); ok {
return s
}
}
return nil
}
func RequireStaff() gin.HandlerFunc {
return func(c *gin.Context) {
s := load(c)
if s == nil || s.Kind != KindStaff {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
// RequireCustomer admits both cloud and self-hosted customers. Every handler
// behind it scopes by AccountID via the helper in api/customer.go — never by
// remembering to filter.
func RequireCustomer() gin.HandlerFunc {
return func(c *gin.Context) {
s := load(c)
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"sync"
"time"
)
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
// hour. The email limit stops a targeted attack on one account; the IP limit
// stops a spray across many.
const (
emailLimit = 5
emailWindow = 15 * time.Minute
ipLimit = 20
ipWindow = time.Hour
)
var (
attemptMu sync.Mutex
byEmail = map[string][]time.Time{}
byIP = map[string][]time.Time{}
)
func prune(in []time.Time, cutoff time.Time) []time.Time {
out := in[:0]
for _, t := range in {
if t.After(cutoff) {
out = append(out, t)
}
}
return out
}
func allowAttempt(email, ip string) bool {
now := time.Now()
attemptMu.Lock()
defer attemptMu.Unlock()
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
return false
}
byEmail[email] = append(byEmail[email], now)
byIP[ip] = append(byIP[ip], now)
return true
}
func clearAttempts(email string) {
attemptMu.Lock()
delete(byEmail, email)
attemptMu.Unlock()
}
+90
View File
@@ -0,0 +1,90 @@
// Package auth holds admin's three identities: staff, cloud customers and
// self-hosted customers. All three share one session store and one cookie.
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
CookieName = "admin_session"
SessionTTL = 24 * time.Hour
KindStaff = "staff"
KindCustomer = "customer"
)
type Session struct {
UserID string `json:"user_id"`
Kind string `json:"kind"`
Email string `json:"email"`
AccountID string `json:"account_id,omitempty"` // customers only
}
var rdb *redis.Client
func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) }
func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }
func newID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func Save(ctx context.Context, s Session) (string, error) {
id, err := newID()
if err != nil {
return "", err
}
body, err := json.Marshal(s)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func Get(ctx context.Context, id string) (*Session, error) {
body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
if err != nil {
return nil, err
}
var s Session
if err := json.Unmarshal(body, &s); err != nil {
return nil, err
}
return &s, nil
}
func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }
func SetCookie(c *gin.Context, id string) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
func ClearCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
})
}
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"net/http"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// genericAuthError is returned for every failure mode — unknown email, wrong
// password, wrong role. Distinguishing them would confirm which addresses have
// accounts.
const genericAuthError = "email or password is incorrect"
func HandleStaffLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
var u models.StaffUser
err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
if err != nil {
// Spend the same work as a real comparison so timing does not
// distinguish "no such user" from "wrong password".
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login", IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
}
// dummyHash is a valid bcrypt hash of a random value, compared against when no
// user exists so the timing profile matches.
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"
func HandleLogout(c *gin.Context) {
if id, err := c.Cookie(CookieName); err == nil && id != "" {
Destroy(c.Request.Context(), id)
}
ClearCookie(c)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+110
View File
@@ -0,0 +1,110 @@
// Package config parses and validates admin's environment.
//
// Everything required is checked at boot and the process refuses to start
// without it. A licensing service that cannot sign is worse than one that is
// down, because it looks healthy.
package config
import (
"fmt"
"net/url"
"os"
"strings"
)
type Config struct {
AdminMongoURI string
AdminDBName string
ControlMongoURI string
ControlDBName string
RedisAddr string
SigningKey string
PublicURL string
AllowedOrigins []string
TrustProxy bool
Addr string
SMTPHost string
SMTPPort string
SMTPFrom string
SMTPUsername string
SMTPPassword string
}
// dbNameFromURI reads the database from a Mongo URI path.
//
// Both URIs must name their database inline rather than through a separate
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
// about which, and guessing wrong means writing licence fields into the wrong
// place.
func dbNameFromURI(raw, which string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
}
name := strings.TrimPrefix(u.Path, "/")
if name == "" {
return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
}
return name, nil
}
func Load() (Config, error) {
c := Config{
AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"),
ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
RedisAddr: os.Getenv("REDIS_ADDR"),
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
Addr: ":" + envOr("PORT", "8083"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: envOr("SMTP_PORT", "587"),
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
}
var missing []string
for name, v := range map[string]string{
"ADMIN_MONGO_URI": c.AdminMongoURI,
"CONTROL_MONGO_URI": c.ControlMongoURI,
"REDIS_ADDR": c.RedisAddr,
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
} {
if v == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
}
var err error
if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
return Config{}, err
}
if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
return Config{}, err
}
if c.AdminMongoURI == c.ControlMongoURI {
return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
}
for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
if o = strings.TrimSpace(o); o != "" {
c.AllowedOrigins = append(c.AllowedOrigins, o)
}
}
return c, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+113
View File
@@ -0,0 +1,113 @@
// Package db holds admin's two MongoDB connections.
//
// Admin() is its own database and it owns every collection there. Control() is
// the control plane's database, and admin's access to it is deliberately narrow:
// it reads `instances` and `users`, and writes exactly three licence fields on
// `instances`. Nothing here should ever grow a write path to another collection.
package db
import (
"context"
"fmt"
"time"
"github.com/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"
)
var (
adminDB *mongo.Database
controlDB *mongo.Database
)
func Connect(ctx context.Context, cfg config.Config) error {
ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
if err != nil {
return fmt.Errorf("connect admin mongo: %w", err)
}
if err := ac.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping admin mongo: %w", err)
}
adminDB = ac.Database(cfg.AdminDBName)
cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
if err != nil {
return fmt.Errorf("connect control mongo: %w", err)
}
if err := cc.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping control mongo: %w", err)
}
controlDB = cc.Database(cfg.ControlDBName)
// The control plane must already be deployed and migrated. Without the
// instances collection, injection would silently create it and write
// licence fields into a collection nothing reads.
names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
if err != nil {
return fmt.Errorf("inspect control database: %w", err)
}
if len(names) == 0 {
return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
}
return nil
}
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
func Ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
// EnsureIndexes creates admin's unique indexes.
//
// These are a correctness property, not an optimisation. In particular
// admin_instances.instance_id unique is what stops the same self-hosted UUID
// being linked to two accounts — without it, two customers could both claim one
// instance and both be issued licences for it.
func EnsureIndexes(ctx context.Context) error {
unique := []struct {
coll string
field string
}{
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"plans", "tier"},
{"staff_users", "email"},
{"customer_users", "email"},
}
for _, u := range unique {
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: u.field, Value: 1}},
Options: options.Index().SetUnique(true).SetName(u.field + "_unique"),
}); err != nil {
return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err)
}
}
// Sparse: a subscription exists before Paddle assigns an ID, so empty must
// not collide with empty.
if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"),
}); err != nil {
return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
}
for _, idx := range []struct {
coll string
keys bson.D
}{
{"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}},
{"admin_instances", bson.D{{Key: "account_id", Value: 1}}},
{"admin_audit", bson.D{{Key: "created_at", Value: -1}}},
} {
if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil {
return fmt.Errorf("index %s: %w", idx.coll, err)
}
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
// Package inject writes licences onto control-plane instance documents.
//
// This is admin's ONLY write path into the control plane, and it touches exactly
// three fields on one collection. If this package ever grows a second write
// target, that is a design change and not a refactor.
package inject
import (
"context"
"fmt"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
// ReconcileInterval is how often every cloud instance is compared against what
// admin believes it should hold.
//
// This job, not the issuance path, is what guarantees eventual consistency.
// Injection at issue time is best-effort; this is the backstop.
const ReconcileInterval = 15 * time.Minute
// Cloud writes the licence onto the control-plane instance document.
//
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
// no read-modify-write. Retries three times with backoff.
//
// The control plane caches licence state for 60 seconds, so this takes effect
// within a minute with no restart.
func Cloud(ctx context.Context, lic *models.License) error {
set := bson.M{"$set": bson.M{
"license_blob": lic.Blob,
"license_tier": lic.Tier,
"license_expiry": lic.ExpiresAt,
}}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
res, err := db.Control("instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID}, set)
if err == nil {
if res.MatchedCount == 0 {
return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
}
return nil
}
lastErr = err
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
return fmt.Errorf("inject after 3 attempts: %w", lastErr)
}
// Deliver injects and records the outcome without ever failing the caller.
//
// A licence that is recorded but not injected is recoverable — the reconciler
// will fix it within 15 minutes, and staff can see it on the health endpoint.
// Failing the purchase because one write failed would be worse.
func Deliver(ctx context.Context, lic *models.License) {
if err := Cloud(ctx, lic); err != nil {
log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
now := time.Now().UTC()
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID},
bson.M{"$set": bson.M{"inject_failed_at": now}})
return
}
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": lic.InstanceID},
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}
// Reconcile compares every active cloud instance's current licence against the
// blob actually stored in the control plane, and re-injects on mismatch.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
"deployment": license.DeploymentCloud,
"status": models.StatusActive,
"current_license": bson.M{"$ne": ""},
})
if err != nil {
return 0, 0, err
}
var instances []models.Instance
if err := cur.All(ctx, &instances); err != nil {
return 0, 0, err
}
for _, inst := range instances {
checked++
var lic models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
continue
}
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
continue
}
if remote.LicenseBlob == lic.Blob {
continue
}
log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
if err := Cloud(ctx, &lic); err != nil {
log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
continue
}
repaired++
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
}
return checked, repaired, nil
}
// StartReconciler reconciles once at boot, then on a ticker until ctx is
// cancelled.
//
// The pass at boot matters: injection failures are most likely around a deploy
// or a crash, and waiting a full interval to notice would leave a paying
// customer read-only for that long. It also means a restart is a supported way
// to force reconciliation.
func StartReconciler(ctx context.Context) {
go func() {
runOnce(ctx)
t := time.NewTicker(ReconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runOnce(ctx)
}
}
}()
}
func runOnce(ctx context.Context) {
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
checked, repaired, err := Reconcile(runCtx)
if err != nil {
log.Printf("reconcile: %v", err)
return
}
if repaired > 0 {
log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package licensing issues licences. It is the only place that signs.
package licensing
import (
"context"
"errors"
"fmt"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
ErrUnknownTier = errors.New("unknown tier")
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
ErrFreeLimit = errors.New("this account already has a Free instance")
ErrUnknownInstance = errors.New("instance not found")
)
type IssueInput struct {
InstanceID string
Tier string
Term string // "monthly" or "annual"; ignored when ExpiresAt is set
ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term
Reason string
IssuedBy string // staff email, "system", or "paddle:<event id>"
}
// signingKey is set once at boot from LICENSE_SIGNING_KEY.
var signingKey string
func SetSigningKey(k string) { signingKey = k }
// Issue signs a licence, records it, supersedes its predecessor and updates the
// instance.
//
// It does NOT deliver. Recording and delivery are deliberately separate and
// ordered: a licence recorded but not delivered is recoverable, because the
// customer can download it. A licence delivered but not recorded is a support
// mystery with no paper trail. Callers deliver after this returns.
func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
if signingKey == "" {
return nil, errors.New("no signing key configured")
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil {
return nil, ErrUnknownInstance
}
plan, err := models.GetPlan(ctx, in.Tier)
if err != nil {
return nil, ErrUnknownTier
}
// This single comparison is what makes Free cloud-only. Free's plan is
// deployment "cloud", so it can never be issued against a self-hosted
// instance, and verification on the instance would reject it anyway.
if plan.Deployment != inst.Deployment {
return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment)
}
if plan.Tier == license.TierFree {
if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
return nil, err
}
}
now := time.Now().UTC()
expires := in.ExpiresAt
if expires.IsZero() {
switch in.Term {
case "monthly":
expires = now.AddDate(0, 1, 0).Add(models.GracePeriod)
case "annual", "":
expires = now.AddDate(1, 0, 0).Add(models.GracePeriod)
default:
return nil, fmt.Errorf("unknown term %q", in.Term)
}
}
payload := license.License{
ID: uuid.NewString(),
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
InstanceName: inst.Name,
Tier: plan.Tier,
Deployment: plan.Deployment,
IssuedAt: now,
ExpiresAt: expires,
// Snapshotted, not referenced: editing a plan tomorrow must not change
// what this licence grants.
Limits: plan.Limits,
Features: plan.Features,
}
blob, err := license.Sign(payload, signingKey)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
rec := models.License{
LicenseID: payload.ID,
InstanceID: inst.InstanceID,
AccountID: inst.AccountID,
Tier: plan.Tier,
Deployment: plan.Deployment,
Limits: plan.Limits,
Features: plan.Features,
IssuedAt: now,
ExpiresAt: expires,
Blob: blob,
IssuedBy: in.IssuedBy,
Reason: in.Reason,
}
if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil {
return nil, fmt.Errorf("record licence: %w", err)
}
// Supersede rather than delete. The history is the support tool.
if inst.CurrentLicense != "" {
if _, err := db.Admin("licenses").UpdateOne(ctx,
bson.M{"license_id": inst.CurrentLicense},
bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil {
return nil, fmt.Errorf("supersede previous licence: %w", err)
}
}
set := bson.M{
"current_license": rec.LicenseID,
"tier": plan.Tier,
"status": models.StatusActive,
}
if in.Reason == models.ReasonRenewal {
set["relink_count"] = 0 // the cap is per term
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
return nil, fmt.Errorf("update instance: %w", err)
}
audit.Write(ctx, models.AuditEntry{
Actor: in.IssuedBy,
Action: "license.issued",
AccountID: inst.AccountID,
Target: inst.InstanceID,
Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s",
plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID),
})
return &rec, nil
}
// checkFreeLimit enforces one Free instance per account.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": accountID,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
"instance_id": bson.M{"$ne": exceptInstanceID},
})
if err != nil {
return err
}
if n > 0 {
return ErrFreeLimit
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package licensing
import (
"context"
"errors"
"fmt"
"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"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var (
ErrBadUUID = errors.New("that does not look like an instance ID")
ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
ErrRelinkLimit = errors.New("relink limit reached for this term; contact support")
)
// LinkInstance attaches a self-hosted instance UUID to an account.
//
// The duplicate error deliberately does not say WHICH account holds it. It is a
// small enumeration surface, but there is no reason to leave it open.
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
if _, err := uuid.Parse(instanceID); err != nil {
return nil, ErrBadUUID
}
// A self-hosted UUID must not collide with a cloud instance either.
if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
inst := models.Instance{
InstanceID: instanceID,
AccountID: accountID,
Name: name,
Deployment: license.DeploymentSelfHosted,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
// The unique index is what actually prevents two accounts owning
// one instance. The check above is a nicety; this is the guarantee.
return nil, ErrAlreadyLinked
}
return nil, err
}
audit.Write(ctx, models.AuditEntry{
Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
return &inst, nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
// a way to extend a subscription.
//
// The old licence is not revoked, because offline verification has no
// revocation. It simply no longer matches any UUID the customer controls, and
// its binding stops it working on another machine anyway.
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
if _, err := uuid.Parse(newID); err != nil {
return nil, ErrBadUUID
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
return nil, ErrUnknownInstance
}
// The cap is a signal, not a defence. Its job is to put a human in front of
// the fourth attempt, so staff bypass it.
if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
return nil, ErrRelinkLimit
}
if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
return nil, ErrAlreadyLinked
}
// Preserve the remaining term from the current licence.
remaining := time.Now().UTC().Add(models.GracePeriod)
var current models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err == nil {
remaining = current.ExpiresAt
}
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
actor := accountID
if staff {
actor = "staff"
}
audit.Write(ctx, models.AuditEntry{
Actor: actor, Action: "instance.relinked", AccountID: accountID,
Target: newID, Detail: "was " + oldID})
return Issue(ctx, IssueInput{
InstanceID: newID,
Tier: inst.Tier,
ExpiresAt: remaining,
Reason: models.ReasonRelink,
IssuedBy: actor,
})
}
+55
View File
@@ -0,0 +1,55 @@
// Package mail delivers verification links and licence files.
package mail
import (
"fmt"
"net/smtp"
"strings"
)
type Config struct {
Host, Port, From, Username, Password string
PublicURL string
}
var cfg Config
func Init(c Config) { cfg = c }
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
func send(to, subject, body string) error {
if !Enabled() {
return fmt.Errorf("SMTP is not configured")
}
msg := strings.Join([]string{
"From: " + cfg.From,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"", body,
}, "\r\n")
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg))
}
func SendVerification(to, token string) error {
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
return send(to, "Verify your Vantage account",
"Confirm your email address to finish setting up your Vantage account:\n\n"+
link+"\n\nThis link expires in 24 hours.\n")
}
// SendLicense delivers the blob inline. It is signed public data, not a secret —
// it is useless on any instance other than the one it names.
func SendLicense(to, instanceName, blob string) error {
return send(to, "Your Vantage licence key",
fmt.Sprintf("Your licence for %s is below.\n\n"+
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
instanceName, blob))
}
+165
View File
@@ -0,0 +1,165 @@
// Package models holds admin's own documents.
//
// These are admin-owned and never shared with the control plane. The two
// structs that ARE shared — Instance and User on the control-plane side — come
// from shared/models, so there is no second copy of those shapes to drift.
package models
import (
"time"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Instance statuses.
const (
StatusAwaitingLink = "awaiting_link"
StatusActive = "active"
StatusLapsed = "lapsed"
StatusCancelled = "cancelled"
)
// Account statuses.
const (
AccountActive = "active"
AccountSuspended = "suspended"
)
// Licence issuance reasons. These end up in support conversations, so they are
// stable identifiers rather than prose.
const (
ReasonNew = "new"
ReasonRenewal = "renewal"
ReasonTierChange = "tier_change"
ReasonRelink = "relink"
ReasonManual = "manual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
//
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
// revoked, so a determined customer is not stopped by a counter. Its real job is
// to put a human in front of the fourth attempt.
const MaxRelinksPerTerm = 3
// GracePeriod is added to every licence expiry beyond the billing period end,
// so a renewal webhook arriving slightly late does not create a gap in which a
// paying customer's instance goes read-only.
const GracePeriod = 3 * 24 * time.Hour
type Account struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
AccountID string `bson:"account_id" json:"account_id"`
Name string `bson:"name" json:"name"`
BillingEmail string `bson:"billing_email" json:"billing_email"`
PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"`
Status string `bson:"status" json:"status"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Instance is admin's record of one deployment.
//
// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it
// is the UUID the customer pasted — their database is theirs, and we cannot see
// it, so this row is the only thing that exists on our side.
type Instance struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier,omitempty" json:"tier,omitempty"`
Status string `bson:"status" json:"status"`
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
RelinkCount int `bson:"relink_count" json:"relink_count"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// License is append-only. A renewal writes a new row and sets SupersededBy on
// the old one. Nothing here is ever edited or deleted: when a support question
// arrives about why an instance stopped working on a given date, the answer has
// to still be in the table.
type License struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
LicenseID string `bson:"license_id" json:"license_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
AccountID string `bson:"account_id" json:"account_id"`
Tier string `bson:"tier" json:"tier"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
Blob string `bson:"blob" json:"-"`
SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"`
IssuedBy string `bson:"issued_by" json:"issued_by"`
Reason string `bson:"reason" json:"reason"`
}
type Subscription struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
SubscriptionID string `bson:"subscription_id" json:"subscription_id"`
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Status string `bson:"status" json:"status"`
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
}
// Plan is the authoritative tier definition, seeded from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
type Plan struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Tier string `bson:"tier" json:"tier"`
Name string `bson:"name" json:"name"`
Deployment string `bson:"deployment" json:"deployment"`
Limits license.Limits `bson:"limits" json:"limits"`
Features []string `bson:"features" json:"features"`
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
Active bool `bson:"active" json:"active"`
}
type StaffUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
Name string `bson:"name" json:"name"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// CustomerUser is a self-hosted customer's login. Cloud customers do not have
// one — they authenticate against the control plane with credentials they
// already hold.
type CustomerUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
AccountID string `bson:"account_id" json:"account_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type AuditEntry struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Actor string `bson:"actor" json:"actor"`
Action string `bson:"action" json:"action"`
AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"`
Target string `bson:"target,omitempty" json:"target,omitempty"`
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+50
View File
@@ -0,0 +1,50 @@
package models
import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// SeedPlans inserts the tier table from shared/license on first boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
func SeedPlans(ctx context.Context) error {
for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
p, ok := license.PlanFor(tier)
if !ok {
continue
}
_, err := db.Admin("plans").UpdateOne(ctx,
bson.M{"tier": tier},
bson.M{"$setOnInsert": bson.M{
"tier": p.Tier,
"name": p.Name,
"deployment": p.Deployment,
"limits": p.Limits,
"features": p.Features,
"active": true,
}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return err
}
}
return nil
}
// GetPlan reads a tier's authoritative definition.
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
var p Plan
if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
return nil, err
}
return &p, nil
}
func now() time.Time { return time.Now().UTC() }
+26
View File
@@ -25,3 +25,29 @@ services:
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
SMTP_FROM: ${SITE_SMTP_FROM:-}
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
# The licensing authority. LICENSE_SIGNING_KEY appears in exactly one
# service in exactly one compose file: here. It must never be added to
# `server`, and docker-compose.yml -- the self-hosted deployment -- must
# not mention admin at all.
admin:
image: gitea.hostxtra.co.uk/mrhid6/vantage/admin:latest
restart: unless-stopped
ports:
- 8083:8083
environment:
PORT: "8083"
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:-}
TRUST_PROXY: ${ADMIN_TRUST_PROXY:-true}
SMTP_HOST: ${SITE_SMTP_HOST:-}
SMTP_PORT: ${SITE_SMTP_PORT:-587}
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
SMTP_FROM: ${SITE_SMTP_FROM:-}
depends_on:
- redis
@@ -80,7 +80,7 @@ There is deliberately no automated backfill. Those instances get licensed by han
- `config.Config` with `Load() (Config, error)`
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
- [ ] **Step 1: Create the module**
- [x] **Step 1: Create the module**
```bash
cd c:/Work/Repos/vantage
@@ -120,7 +120,7 @@ use (
)
```
- [ ] **Step 2: Write the config**
- [x] **Step 2: Write the config**
Create `admin/internal/config/config.go`:
@@ -237,7 +237,7 @@ func envOr(key, fallback string) string {
}
```
- [ ] **Step 3: Write the database layer**
- [x] **Step 3: Write the database layer**
Create `admin/internal/db/db.go`:
@@ -305,7 +305,7 @@ func Ctx() (context.Context, context.CancelFunc) {
}
```
- [ ] **Step 4: Write main.go**
- [x] **Step 4: Write main.go**
Create `admin/cmd/main.go`:
@@ -371,7 +371,7 @@ func main() {
}
```
- [ ] **Step 5: Write the Dockerfile**
- [x] **Step 5: Write the Dockerfile**
Create `admin/Dockerfile` — context is the repo root, exactly like `server/`:
@@ -415,7 +415,7 @@ Create `admin/.dockerignore`:
*.lic
```
- [ ] **Step 6: Resolve dependencies outside the workspace**
- [x] **Step 6: Resolve dependencies outside the workspace**
```bash
cd c:/Work/Repos/vantage
@@ -425,7 +425,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod
`GOWORK=off` is required. In workspace mode `tidy` drops the `require` lines the Docker build needs, and the failure only appears at image build time.
- [ ] **Step 7: Build**
- [x] **Step 7: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -433,7 +433,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.)
- [ ] **Step 8: Commit**
- [x] **Step 8: Commit**
```bash
git add admin/ go.work
@@ -456,7 +456,7 @@ git commit -m "feat(admin): module skeleton, config and two database connections
- `models.SeedPlans(ctx) error`
- `db.EnsureIndexes(ctx) error`
- [ ] **Step 1: Write the documents**
- [x] **Step 1: Write the documents**
Create `admin/internal/models/models.go`:
@@ -628,7 +628,7 @@ type AuditEntry struct {
}
```
- [ ] **Step 2: Write the plan seed**
- [x] **Step 2: Write the plan seed**
Create `admin/internal/models/plans.go`:
@@ -685,7 +685,7 @@ func GetPlan(ctx context.Context, tier string) (*Plan, error) {
func now() time.Time { return time.Now().UTC() }
```
- [ ] **Step 3: Add the indexes**
- [x] **Step 3: Add the indexes**
Append to `admin/internal/db/db.go`:
@@ -744,7 +744,7 @@ func EnsureIndexes(ctx context.Context) error {
Add to that file's imports: `"go.mongodb.org/mongo-driver/v2/bson"` and `"go.mongodb.org/mongo-driver/v2/mongo/options"`.
- [ ] **Step 4: Call both at boot**
- [x] **Step 4: Call both at boot**
In `admin/cmd/main.go`, after the connect block:
@@ -763,7 +763,7 @@ In `admin/cmd/main.go`, after the connect block:
Import `"github.com/mrhid6/vantage/admin/internal/models"`.
- [ ] **Step 5: Build**
- [x] **Step 5: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -771,7 +771,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add admin/
@@ -795,7 +795,7 @@ The core of the service. Everything else exists to call this correctly.
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
- `audit.Write(ctx, e models.AuditEntry)`
- [ ] **Step 1: Write the audit helper**
- [x] **Step 1: Write the audit helper**
Create `admin/internal/audit/audit.go`:
@@ -823,7 +823,7 @@ func Write(ctx context.Context, e models.AuditEntry) {
}
```
- [ ] **Step 2: Write issuance**
- [x] **Step 2: Write issuance**
Create `admin/internal/licensing/issue.go`:
@@ -1008,7 +1008,7 @@ func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) err
}
```
- [ ] **Step 3: Wire the signing key at boot**
- [x] **Step 3: Wire the signing key at boot**
In `admin/cmd/main.go`, after config loads:
@@ -1018,7 +1018,7 @@ In `admin/cmd/main.go`, after config loads:
Import `"github.com/mrhid6/vantage/admin/internal/licensing"`.
- [ ] **Step 4: Build**
- [x] **Step 4: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -1026,7 +1026,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add admin/
@@ -1048,7 +1048,7 @@ git commit -m "feat(admin): licence issuance with plan snapshots and supersessio
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
- `inject.StartReconciler(ctx)`
- [ ] **Step 1: Write it**
- [x] **Step 1: Write it**
Create `admin/internal/inject/inject.go`:
@@ -1205,7 +1205,7 @@ func StartReconciler(ctx context.Context) {
}
```
- [ ] **Step 2: Start it at boot**
- [x] **Step 2: Start it at boot**
In `admin/cmd/main.go`, before the HTTP server starts:
@@ -1217,7 +1217,7 @@ In `admin/cmd/main.go`, before the HTTP server starts:
Import `"github.com/mrhid6/vantage/admin/internal/inject"`.
- [ ] **Step 3: Build**
- [x] **Step 3: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -1225,7 +1225,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 4: Confirm the write surface by inspection**
- [x] **Step 4: Confirm the write surface by inspection**
```bash
grep -n "Control(" admin/internal/ -r
@@ -1233,7 +1233,7 @@ grep -n "Control(" admin/internal/ -r
Expected: reads of `instances` and `users`, and writes only to `instances` with the three licence fields. Any other write target is a bug — fix it before continuing.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add admin/
@@ -1256,7 +1256,7 @@ git commit -m "feat(admin): cloud injection and the 15-minute reconciler"
- `auth.HandleStaffLogin`, `auth.HandleLogout`
- `adminctl staff-add`
- [ ] **Step 1: Write sessions**
- [x] **Step 1: Write sessions**
Create `admin/internal/auth/session.go`:
@@ -1353,7 +1353,7 @@ func ClearCookie(c *gin.Context) {
}
```
- [ ] **Step 2: Write the middleware**
- [x] **Step 2: Write the middleware**
Create `admin/internal/auth/middleware.go`:
@@ -1418,7 +1418,7 @@ func RequireCustomer() gin.HandlerFunc {
}
```
- [ ] **Step 3: Write staff login**
- [x] **Step 3: Write staff login**
Create `admin/internal/auth/staff.go`:
@@ -1502,7 +1502,7 @@ func HandleLogout(c *gin.Context) {
}
```
- [ ] **Step 4: Write the rate limiter**
- [x] **Step 4: Write the rate limiter**
Create `admin/internal/auth/ratelimit.go`:
@@ -1564,7 +1564,7 @@ func clearAttempts(email string) {
}
```
- [ ] **Step 5: Write adminctl staff-add**
- [x] **Step 5: Write adminctl staff-add**
Create `admin/cmd/adminctl/main.go`:
@@ -1654,7 +1654,7 @@ func fatal(format string, a ...any) {
}
```
- [ ] **Step 6: Initialise Redis at boot**
- [x] **Step 6: Initialise Redis at boot**
In `admin/cmd/main.go`, after config loads:
@@ -1668,7 +1668,7 @@ In `admin/cmd/main.go`, after config loads:
pingCancel()
```
- [ ] **Step 7: Build**
- [x] **Step 7: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -1676,7 +1676,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 8: Commit**
- [x] **Step 8: Commit**
```bash
git add admin/
@@ -1694,7 +1694,7 @@ git commit -m "feat(admin): sessions, staff auth and adminctl"
- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models`
- Produces: `auth.HandleCloudLogin`
- [ ] **Step 1: Write it**
- [x] **Step 1: Write it**
Create `admin/internal/auth/cloud.go`:
@@ -1797,7 +1797,7 @@ func HandleCloudLogin(c *gin.Context) {
Check the field names on `sharedmodels.User` before building — use `UserID`, `Email`, `PasswordHash`, `Role`, `InstanceID` as defined in `shared/models/user.go`, and correct this file if they differ.
- [ ] **Step 2: Build**
- [x] **Step 2: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -1805,7 +1805,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output. (`HandleCustomerLogin` arrives in Task 7 — if you are building tasks in order, stub it as a 501 handler in `customer.go` and replace it there.)
- [ ] **Step 3: Commit**
- [x] **Step 3: Commit**
```bash
git add admin/
@@ -1823,7 +1823,7 @@ git commit -m "feat(admin): cloud owner login against the control plane"
- Consumes: `db.Admin("customer_users")`, config SMTP
- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense`
- [ ] **Step 1: Write mail**
- [x] **Step 1: Write mail**
Create `admin/internal/mail/mail.go`:
@@ -1885,7 +1885,7 @@ func SendLicense(to, instanceName, blob string) error {
}
```
- [ ] **Step 2: Write customer auth**
- [x] **Step 2: Write customer auth**
Create `admin/internal/auth/customer.go`:
@@ -2033,7 +2033,7 @@ func HandleCustomerLogin(c *gin.Context) {
}
```
- [ ] **Step 3: Initialise mail at boot**
- [x] **Step 3: Initialise mail at boot**
In `admin/cmd/main.go`:
@@ -2048,7 +2048,7 @@ In `admin/cmd/main.go`:
}
```
- [ ] **Step 4: Build**
- [x] **Step 4: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -2056,7 +2056,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add admin/
@@ -2079,7 +2079,7 @@ git commit -m "feat(admin): self-hosted customer accounts with email verificatio
- `api.Routes(cfg) http.Handler`
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
- [ ] **Step 1: Write linking and relink**
- [x] **Step 1: Write linking and relink**
Create `admin/internal/licensing/link.go`:
@@ -2207,7 +2207,7 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
}
```
- [ ] **Step 2: Write the customer handlers**
- [x] **Step 2: Write the customer handlers**
Create `admin/internal/api/customer.go`:
@@ -2386,7 +2386,7 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
}
```
- [ ] **Step 3: Write the route table**
- [x] **Step 3: Write the route table**
Create `admin/internal/api/routes.go`:
@@ -2473,7 +2473,7 @@ func cors(allowed []string) gin.HandlerFunc {
}
```
- [ ] **Step 4: Mount it**
- [x] **Step 4: Mount it**
In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
@@ -2483,7 +2483,7 @@ In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
Import `"github.com/mrhid6/vantage/admin/internal/api"`.
- [ ] **Step 5: Build**
- [x] **Step 5: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -2491,7 +2491,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: failures naming the staff handlers, which arrive in Task 9. Build again after Task 9.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add admin/
@@ -2509,7 +2509,7 @@ git commit -m "feat(admin): linking, relink and the scoped customer API"
- Consumes: `licensing.Issue`, `licensing.Relink`, `models`
- Produces: the handlers named in `routes.go`
- [ ] **Step 1: Write the handlers**
- [x] **Step 1: Write the handlers**
Create `admin/internal/api/staff.go`:
@@ -2857,7 +2857,7 @@ func staffInjectionHealth(c *gin.Context) {
}
```
- [ ] **Step 2: Build**
- [x] **Step 2: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -2865,7 +2865,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output. This is the first build where the whole service compiles.
- [ ] **Step 3: Audit customer scoping by hand**
- [x] **Step 3: Audit customer scoping by hand**
With no test suite, this replaces spec 3's table-driven test 20.
@@ -2876,7 +2876,7 @@ grep -n "func \(getAccount\|linkInstance\|relinkInstance\|getInstanceLicense\|do
Every customer route must either call `ownedInstance` or filter by `s.AccountID`. A route that does neither is a data leak — fix before continuing.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add admin/
@@ -2890,7 +2890,7 @@ git commit -m "feat(admin): staff API"
**Files:**
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`
- [ ] **Step 1: Add the service**
- [x] **Step 1: Add the service**
In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
@@ -2919,11 +2919,11 @@ In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
`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.
- [ ] **Step 2: Add the build**
- [x] **Step 2: Add the build**
In `.gitea/workflows/server-deploy.yml`, add a fourth image alongside `server`, `web`, `site` and `sitesvc`, with context `.` and file `admin/Dockerfile`, following the pattern the other Go services already use.
- [ ] **Step 3: Confirm the self-hosted deployment is untouched**
- [x] **Step 3: Confirm the self-hosted deployment is untouched**
```bash
grep -c "admin" deploy/docker-compose.yml
@@ -2931,7 +2931,7 @@ grep -c "admin" deploy/docker-compose.yml
Expected: `0`.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add deploy/ .gitea/
@@ -2946,7 +2946,7 @@ Everything in containers. With no test suite this is the only evidence.
**Files:** none
- [ ] **Step 1: Build every module and the image**
- [x] **Step 1: Build every module and the image**
```bash
cd c:/Work/Repos/vantage
@@ -2960,7 +2960,7 @@ docker build -q -f server/Dockerfile -t vantage-server:test .
Expected: all succeed. The image build is the one that catches a `go.sum` the workspace was masking — it caught exactly that in plan 2.
- [ ] **Step 2: Start the supporting containers**
- [x] **Step 2: Start the supporting containers**
```bash
docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine
@@ -2968,7 +2968,7 @@ docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine
MongoDB is expected on the host at `27021`, as in plan 2.
- [ ] **Step 3: Boot the control plane and bootstrap a cloud instance**
- [x] **Step 3: Boot the control plane and bootstrap a cloud instance**
```bash
docker run -d --name vadmin-server -p 8080:8080 \
@@ -2984,7 +2984,7 @@ curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json
Record the `instance_id`.
- [ ] **Step 4: Boot admin**
- [x] **Step 4: Boot admin**
```bash
docker run -d --name vadmin -p 8083:8083 \
@@ -3008,7 +3008,7 @@ docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb://
Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`.
- [ ] **Step 5: Adopt the cloud instance and issue through the staff API**
- [x] **Step 5: Adopt the cloud instance and issue through the staff API**
This is the flow the admin UI will drive when you licence your existing cloud
instances by hand.
@@ -3043,7 +3043,7 @@ Expected: adopting the instance returns 201, issuing returns 201, and the contro
plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds,
with no restart**. This is the whole system working end to end.
- [ ] **Step 6: Confirm the Free rule and the deployment check**
- [x] **Step 6: Confirm the Free rule and the deployment check**
Reusing the staff session and `$INSTANCE` from step 5:
@@ -3054,7 +3054,7 @@ curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt
Expected: `"that plan is not available for this deployment type"` — a self-hosted plan cannot be issued to a cloud instance, and the mirror of that check is what makes Free cloud-only.
- [ ] **Step 7: Confirm supersession and the snapshot rule**
- [x] **Step 7: Confirm supersession and the snapshot rule**
```bash
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
@@ -3075,7 +3075,7 @@ curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt
Expected: the existing licences still report `max_servers: -1`. Editing a plan never rewrites history.
- [ ] **Step 8: Confirm reconciliation repairs a tampered instance**
- [x] **Step 8: Confirm reconciliation repairs a tampered instance**
```bash
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
@@ -3093,7 +3093,7 @@ curl -s localhost:8080/api/license -b /tmp/a.txt
Expected: the control plane reports `valid` again once reconciliation has run — the blob is restored from what admin believes it should be. Confirm `reconcile: repairing instance` appears in `docker logs vadmin`.
- [ ] **Step 9: Confirm customer scoping returns 404, not 403**
- [x] **Step 9: Confirm customer scoping returns 404, not 403**
Create a second account and instance via the staff API, sign in as the first customer, and request the second account's instance:
@@ -3104,7 +3104,7 @@ curl -s -o /dev/null -w "other account's instance: %{http_code}\n" \
Expected: **404**. A 403 would confirm the instance exists.
- [ ] **Step 10: Confirm admin is not a runtime dependency**
- [x] **Step 10: Confirm admin is not a runtime dependency**
```bash
docker stop vadmin
@@ -3114,7 +3114,7 @@ curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/a
Expected: the licence still reports `valid` and mutations still work. **This is the most important check in the plan.** Instances verify offline and must never call admin.
- [ ] **Step 11: Confirm the control-plane write surface**
- [x] **Step 11: Confirm the control-plane write surface**
```bash
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
@@ -3124,7 +3124,7 @@ docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
Expected: `servers`, `keys`, `secrets` and every other collection are exactly as the control plane left them. Admin touched only `instances`.
- [ ] **Step 12: Clean up and commit**
- [x] **Step 12: Clean up and commit**
```bash
docker rm -f vadmin vadmin-server vadmin-redis
+9 -5
View File
@@ -8,9 +8,9 @@ Seven specs, designed 2026-07-24. Build in this order.
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** |
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
@@ -66,5 +66,9 @@ Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Existing cloud tenants** are grandfathered to Professional, one year out, by
migration `0005`.
**Existing cloud tenants are not grandfathered.** The migration that would have
done it was removed before plan 2 shipped, so every existing cloud instance is
read-only until it is licensed by hand through the admin service: attach it to an
account with `POST /api/staff/instances`, then `POST /api/staff/instances/:id/issue`.
That flow is verified in plan 3, so it works today via the API and is the first
job the admin UI is used for.
+2 -1
View File
@@ -1,6 +1,7 @@
go 1.26.4
go 1.26
use (
./admin
./server
./shared
./sitesvc
+25 -1
View File
@@ -2,17 +2,41 @@ cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//u
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/mrhid6/vantage/server
go 1.26.4
go 1.26
require (
github.com/coreos/go-oidc/v3 v3.18.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/mrhid6/vantage/shared
go 1.26.4
go 1.26
require (
github.com/google/uuid v1.6.0
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/mrhid6/vantage/sitesvc
go 1.26.4
go 1.26
require (
github.com/google/uuid v1.6.0